From 97daf76f05b369dead9e492873565af17bc465f9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:57:46 +0200 Subject: [PATCH] fix: start over when the mail server behind an account was replaced Stalwart 1.0 has no migration from 0.16, so the way there is a fresh install at the same address, where ids start over ("a", "b", ...) and name other objects. The app persists a lot that only means something on the server it came from: JMAP states and query states, offline bodies keyed by message id, an outbox that replays stored ids (a `destroy` included), the calendar cache and push de-dupe lists. It also turned push off for good when the server no longer had the subscription, and would have taken another device's subscription for its own once the reinstalled server handed out the recorded id again. - Email/changes, Email/queryChanges and Mailbox/changes answers whose Stalwart state went below the one asked from count as not computable and lead to a full fetch (`stalwartChangeId`, a port of the webmail guard). It only reads accounts that list `urn:stalwart:jmap` and states in the exact form Stalwart writes, so it never trips while states grow or on other servers. - Every time a session is established, a fingerprint of the server (API URL, primary account id, the session's and the primary account's capability names) is compared with the one recorded for the account. On a change, before anything reads them, the account's folders, lists, states and query states, offline bodies, queued and failed outbox ops, the calendar and contacts caches and the push de-dupe lists are dropped. Preferences, templates, tags and iCal subscriptions stay. The first sight of an account only records it. - The whole outbox goes: every op names a message by its server id. Offline edits still waiting are lost, but never a message the user wrote, because the outbox holds no sends. - A subscription the server no longer has counts as expired and is re-created, and the recorded one is only ours when its device client id matches, on setup and on teardown. Revoking this device from another one therefore no longer keeps push off here; turning it off on the device does. --- App.tsx | 4 +- .../__tests__/email-state-regression.test.ts | 150 +++++++++++ src/api/__tests__/stalwart-state-writer.ts | 85 +++++++ src/api/__tests__/stalwart-state.test.ts | 118 +++++++++ src/api/email.ts | 34 ++- src/api/stalwart-state.ts | 104 ++++++++ src/lib/__tests__/push-notifications.test.ts | 69 ++++- src/lib/__tests__/server-fingerprint.test.ts | 126 +++++++++ src/lib/push-notifications.ts | 92 ++++--- src/lib/server-change.ts | 82 ++++++ src/lib/server-fingerprint.ts | 58 +++++ .../auth-store-server-change.test.ts | 142 +++++++++++ src/stores/__tests__/server-change.test.ts | 240 ++++++++++++++++++ src/stores/auth-store.ts | 18 ++ src/stores/email-store.ts | 33 +++ src/stores/offline-cache-store.ts | 17 ++ src/stores/outbox-store.ts | 15 ++ 17 files changed, 1325 insertions(+), 62 deletions(-) create mode 100644 src/api/__tests__/email-state-regression.test.ts create mode 100644 src/api/__tests__/stalwart-state-writer.ts create mode 100644 src/api/__tests__/stalwart-state.test.ts create mode 100644 src/api/stalwart-state.ts create mode 100644 src/lib/__tests__/server-fingerprint.test.ts create mode 100644 src/lib/server-change.ts create mode 100644 src/lib/server-fingerprint.ts create mode 100644 src/stores/__tests__/auth-store-server-change.test.ts create mode 100644 src/stores/__tests__/server-change.test.ts diff --git a/App.tsx b/App.tsx index ab73c899..1020ec32 100644 --- a/App.tsx +++ b/App.tsx @@ -546,8 +546,8 @@ export default function App() { // and whenever the FCM token rotates. Honours the user's notification // preference - flipping it off tears down THIS account's subscription so // notifications stop arriving for it. Other logged-in accounts keep their - // setups intact. An account the user turned push off for, or whose device - // registration was revoked, stays off (see resyncPushNotifications). + // setups intact. An account the user turned push off for on this device + // stays off (see resyncPushNotifications). const emailNotificationsEnabled = useSettingsStore( (s) => s.emailNotificationsEnabled, ); diff --git a/src/api/__tests__/email-state-regression.test.ts b/src/api/__tests__/email-state-regression.test.ts new file mode 100644 index 00000000..466e38bd --- /dev/null +++ b/src/api/__tests__/email-state-regression.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Stalwart's change ids only grow. An upgrade that keeps the old change log +// but restarts the counters lower (1.0 counts per collection group where 0.16 +// counted per account) would accept an old state and answer with a lower +// newState and no changes: the delta sync would skip everything since. The +// app persists these states, so it reads such an answer like +// cannotCalculateChanges and fetches in full. + +const { STALWART_ACCOUNTS } = vi.hoisted(() => ({ STALWART_ACCOUNTS: new Set() })); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'f', + request: vi.fn(), + // Stalwart lists urn:stalwart:jmap in every account's capabilities, the + // user's own and shared ones, on 0.16.6 through 1.0. + hasAccountCapability: vi.fn((urn: string, accountId?: string) => + urn === 'urn:stalwart:jmap' && STALWART_ACCOUNTS.has(accountId ?? 'f')), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { getEmailChanges, getEmailListDelta, getEmailQueryChanges, getMailboxChanges } from '../email'; + +const mockRequest = jmapClient.request as ReturnType; + +// Captured from the stw10 test servers for userc: the account's state on +// 0.16.23 (change id 242) and on 1.0 (46). +const STATE_0_16 = 's1iaq'; +const STATE_1_0 = 'sfy'; + +// Exactly how both versions answer a /changes call. +function changes(method: string, oldState: string, newState: string, accountId = 'f') { + return { + methodResponses: [[method, { + accountId, oldState, newState, hasMoreChanges: false, created: [], updated: [], destroyed: [], + }, '0']], + }; +} + +function queryChanges(oldQueryState: string, newQueryState: string) { + return { + methodResponses: [['Email/queryChanges', { + accountId: 'f', oldQueryState, newQueryState, total: 3, removed: [], added: [], + }, '0']], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + STALWART_ACCOUNTS.clear(); + STALWART_ACCOUNTS.add('f'); +}); + +describe('changes whose state went back on Stalwart', () => { + it('reads Mailbox/changes answered with a 1.0 state below the 0.16.23 one as not computable', async () => { + mockRequest.mockResolvedValue(changes('Mailbox/changes', STATE_0_16, STATE_1_0)); + expect(await getMailboxChanges(STATE_0_16)).toBeNull(); + }); + + it('reads Email/changes answered with a lower state as not computable', async () => { + mockRequest.mockResolvedValue(changes('Email/changes', STATE_0_16, STATE_1_0)); + expect(await getEmailChanges(STATE_0_16)).toBeNull(); + }); + + it('reads Email/queryChanges answered with a lower query state as not computable', async () => { + mockRequest.mockResolvedValue(queryChanges(STATE_0_16, STATE_1_0)); + expect(await getEmailQueryChanges('a', STATE_0_16)).toBeNull(); + }); + + it('drops both halves of a list delta whose states went back', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/queryChanges', { accountId: 'f', oldQueryState: STATE_0_16, newQueryState: STATE_1_0, total: 3, removed: [], added: [] }, 'qc'], + ['Email/changes', { accountId: 'f', oldState: STATE_0_16, newState: STATE_1_0, hasMoreChanges: false, created: [], updated: [], destroyed: [] }, 'ch'], + ['Email/get', { accountId: 'f', state: STATE_1_0, list: [], notFound: [] }, 'get'], + ], + }); + + const delta = await getEmailListDelta('a', STATE_0_16, STATE_0_16); + + expect(delta.queryChanges).toBeNull(); + expect(delta.changes).toBeNull(); + }); + + it('judges each half of a list delta on its own', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/queryChanges', { accountId: 'f', oldQueryState: 'sae', newQueryState: 'sai', total: 3, removed: [], added: [] }, 'qc'], + ['Email/changes', { accountId: 'f', oldState: STATE_0_16, newState: STATE_1_0, hasMoreChanges: false, created: [], updated: [], destroyed: [] }, 'ch'], + ['Email/get', { accountId: 'f', state: 'sai', list: [], notFound: [] }, 'get'], + ], + }); + + const delta = await getEmailListDelta('a', 'sae', STATE_0_16); + + expect(delta.queryChanges).toMatchObject({ newQueryState: 'sai' }); + expect(delta.changes).toBeNull(); + }); + + it('guards a shared account too, against that account\'s capabilities', async () => { + STALWART_ACCOUNTS.add('c'); + mockRequest.mockResolvedValue(changes('Email/changes', 'sba', 'sau', 'c')); + + expect(await getEmailChanges('sba', undefined, 'c')).toBeNull(); + expect(jmapClient.hasAccountCapability).toHaveBeenCalledWith('urn:stalwart:jmap', 'c'); + }); +}); + +describe('changes that move on as usual', () => { + it('keeps a delta whose state went up (Stalwart 0.16.23 and 1.0)', async () => { + mockRequest.mockResolvedValue(changes('Mailbox/changes', STATE_1_0, STATE_0_16)); + expect(await getMailboxChanges(STATE_1_0)).toMatchObject({ oldState: STATE_1_0, newState: STATE_0_16 }); + + mockRequest.mockResolvedValue(changes('Email/changes', STATE_1_0, STATE_1_0)); + expect(await getEmailChanges(STATE_1_0)).toMatchObject({ newState: STATE_1_0 }); + }); + + it('keeps the first round of a delta cut short by maxChanges', async () => { + // An intermediate `r` state is not a change id; it is never compared. + mockRequest.mockResolvedValue({ + methodResponses: [['Email/changes', { + accountId: 'f', oldState: STATE_0_16, newState: 'rae2aaaa', hasMoreChanges: true, created: ['x'], updated: [], destroyed: [], + }, '0']], + }); + expect(await getEmailChanges(STATE_0_16, 1)).toMatchObject({ newState: 'rae2aaaa', hasMoreChanges: true }); + }); + + it('does not read the states of servers other than Stalwart', async () => { + STALWART_ACCOUNTS.clear(); + mockRequest.mockResolvedValue(changes('Email/changes', STATE_0_16, STATE_1_0)); + expect(await getEmailChanges(STATE_0_16)).toMatchObject({ newState: STATE_1_0 }); + + mockRequest.mockResolvedValue(queryChanges(STATE_0_16, STATE_1_0)); + expect(await getEmailQueryChanges('a', STATE_0_16)).toMatchObject({ newQueryState: STATE_1_0 }); + }); + + it('still reads cannotCalculateChanges as not computable', async () => { + // What both versions answer for a state their change log doesn't hold. + mockRequest.mockResolvedValue({ + methodResponses: [['error', { + type: 'cannotCalculateChanges', + description: 'The server cannot calculate the changes between the old and new states.', + }, '0']], + }); + expect(await getMailboxChanges('s33333333333333yb')).toBeNull(); + }); +}); diff --git a/src/api/__tests__/stalwart-state-writer.ts b/src/api/__tests__/stalwart-state-writer.ts new file mode 100644 index 00000000..946269d7 --- /dev/null +++ b/src/api/__tests__/stalwart-state-writer.ts @@ -0,0 +1,85 @@ +// Stalwart's JMAP state writer, ported line by line for tests: Base32Writer +// (utils/src/codec/base32_custom.rs) fed by write_leb128 (utils/src/codec/ +// leb128.rs), as `impl Display for State` (jmap-proto/src/types/state.rs) +// uses them. Kept apart from api/stalwart-state.ts on purpose, so tests check +// the decoder against an encoder written the way the server's is. Same as the +// webmail's lib/__tests__/helpers/stalwart-state.ts. + +const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; + +class Base32Writer { + private lastByte = 0; + private pos = 0; + private result = ''; + + pushChar(ch: string): void { + this.result += ch; + } + + private pushByte(byte: number, isRemainder: boolean): void { + let ch1: number; + let ch2: number | null; + switch (this.pos % 5) { + case 0: + ch1 = (byte & 0xf8) >> 3; + ch2 = null; + break; + case 1: + ch1 = ((this.lastByte & 0x07) << 2) | ((byte & 0xc0) >> 6); + ch2 = (byte & 0x3e) >> 1; + break; + case 2: + ch1 = ((this.lastByte & 0x01) << 4) | ((byte & 0xf0) >> 4); + ch2 = null; + break; + case 3: + ch1 = ((this.lastByte & 0x0f) << 1) | (byte >> 7); + ch2 = (byte & 0x7c) >> 2; + break; + default: + ch1 = ((this.lastByte & 0x03) << 3) | ((byte & 0xe0) >> 5); + ch2 = byte & 0x1f; + } + this.result += BASE32_ALPHABET[ch1]; + if (!isRemainder) { + if (ch2 !== null) this.result += BASE32_ALPHABET[ch2]; + this.lastByte = byte; + this.pos += 1; + } + } + + writeLeb128(value: bigint): void { + let rest = value; + for (;;) { + if (rest < BigInt(0x80)) { + this.pushByte(Number(rest), false); + return; + } + this.pushByte(Number((rest & BigInt(0x7f)) | BigInt(0x80)), false); + rest >>= BigInt(7); + } + } + + finalize(): string { + if (this.pos % 5 !== 0) this.pushByte(0, true); + return this.result; + } +} + +/** `State::Exact(id).to_string()` */ +export function stalwartExactState(id: bigint | number): string { + const writer = new Base32Writer(); + writer.pushChar('s'); + writer.writeLeb128(BigInt(id)); + return writer.finalize(); +} + +/** `State::Intermediate { from_id, to_id, items_sent }.to_string()` */ +export function stalwartIntermediateState(fromId: number, toId: number, itemsSent: number): string { + const writer = new Base32Writer(); + writer.pushChar('r'); + writer.writeLeb128(BigInt(fromId)); + writer.writeLeb128(BigInt(toId - fromId)); + writer.writeLeb128(BigInt(itemsSent)); + return writer.finalize(); +} diff --git a/src/api/__tests__/stalwart-state.test.ts b/src/api/__tests__/stalwart-state.test.ts new file mode 100644 index 00000000..82797b36 --- /dev/null +++ b/src/api/__tests__/stalwart-state.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { stalwartChangeId, stalwartStateWentBack } from '../stalwart-state'; +import { stalwartExactState, stalwartIntermediateState } from './stalwart-state-writer'; + +const big = (n: number | string) => BigInt(n); +const U64_MAX = big(2) ** big(64) - big(1); + +// What the stw10 test servers answered to Mailbox/get, Email/get and +// Thread/get with `ids: []` (2026-09-25): usera and the team group, and +// userc, whose account had seen more changes on 0.16.23 (one counter for +// every collection) than on 1.0 (one per collection group). +const CAPTURED_1_0: Array<[string, number]> = [['sae', 1], ['sai', 2], ['saq', 4], ['sau', 5], ['sfy', 46]]; +const CAPTURED_0_16: Array<[string, number]> = [['sae', 1], ['sai', 2], ['say', 6], ['sba', 8], ['s1iaq', 242]]; + +// Written by a separate port of Stalwart's writer, one per LEB128 length and +// at each byte boundary. +const WRITTEN: Array<[string, bigint]> = [ + ['saa', big(0)], + ['sd2', big(31)], + ['sp2', big(127)], + ['sqaaq', big(128)], + ['sqeaq', big(129)], + ['s32aq', big(255)], + ['svqba', big(300)], + ['s303q', big(16383)], + ['sqcaac', big(16384)], + ['syccd7', big(1000000)], + ['sz9bpcbi', big(12345678)], + ['s333333yp', big(2) ** big(32) - big(1)], + ['sqgaibaeaqcaba', big(2) ** big(53) + big(1)], + ['s33333333333333yb', U64_MAX], +]; + +// A small deterministic generator for ids across the whole u64 range. +function* seededIds(count: number): Generator { + let seed = big(0x2545f491); + for (let i = 0; i < count; i++) { + seed = (seed * big('6364136223846793005') + big('1442695040888963407')) & U64_MAX; + yield seed >> big(i % 64); + } +} + +describe('stalwartChangeId', () => { + it('reads the change ids of states captured from Stalwart 1.0 and 0.16.23', () => { + for (const [state, id] of [...CAPTURED_1_0, ...CAPTURED_0_16]) { + expect(stalwartChangeId(state)).toBe(big(id)); + } + }); + + it('reads ids of every LEB128 length, up to u64::MAX', () => { + for (const [state, id] of WRITTEN) { + expect(stalwartExactState(id)).toBe(state); + expect(stalwartChangeId(state)).toBe(id); + } + }); + + it('reads back every id written the way Stalwart writes it', () => { + for (let id = 0; id < 20_000; id++) { + expect(stalwartChangeId(stalwartExactState(id))).toBe(big(id)); + } + for (const id of seededIds(5_000)) { + expect(stalwartChangeId(stalwartExactState(id))).toBe(id); + } + }); + + it('reads nothing from other state formats', () => { + for (const state of [ + 'n', // Initial + stalwartIntermediateState(1024, 2048, 100), // an intermediate `r` state + '', 's', + 's1', // '1' is 30: a LEB128 byte that never ends + 'sZZ', 's!', 'sa e', // not the alphabet + '12345', 'J1234', '2d7a4b56-6c1e-4f2b-9d57-6a3c9e1f0b42', // other servers + ]) { + expect(stalwartChangeId(state)).toBeNull(); + } + }); + + it('reads nothing Stalwart would not have written for that id', () => { + // Stalwart's reader takes these too, but its writer never produces them. + expect(stalwartChangeId('sa')).toBeNull(); // 0 is `saa` + expect(stalwartChangeId('sb')).toBeNull(); // 8 is `sba` + expect(stalwartChangeId('saaaaaaaa')).toBeNull(); + expect(stalwartChangeId('sbb')).toBeNull(); // nonzero padding bits + // Past a u64: ten bytes that overflow it, and eleven. + expect(stalwartChangeId(stalwartExactState(big(2) ** big(64)))).toBeNull(); + expect(stalwartChangeId(stalwartExactState(big(2) ** big(70)))).toBeNull(); + }); +}); + +describe('stalwartStateWentBack', () => { + it('trips when a 0.16.23 state is answered with a lower 1.0 one', () => { + expect(stalwartStateWentBack('s1iaq', 'sfy')).toBe(true); + expect(stalwartStateWentBack('sfy', 's1iaq')).toBe(false); + }); + + it('never trips while states go up or stay put', () => { + const ids = [ + big(0), big(127), big(128), big(16_383), big(16_384), + big(2) ** big(32), big(2) ** big(53) + big(1), U64_MAX - big(1), U64_MAX, + ]; + for (let i = 0; i < ids.length; i++) { + expect(stalwartStateWentBack(stalwartExactState(ids[i]), stalwartExactState(ids[i]))).toBe(false); + if (i + 1 < ids.length) { + expect(stalwartStateWentBack(stalwartExactState(ids[i]), stalwartExactState(ids[i + 1]))).toBe(false); + expect(stalwartStateWentBack(stalwartExactState(ids[i + 1]), stalwartExactState(ids[i]))).toBe(true); + } + } + }); + + it('leaves any other format, or a missing state, alone', () => { + const intermediate = stalwartIntermediateState(1024, 2048, 100); + expect(stalwartStateWentBack(stalwartExactState(5000), intermediate)).toBe(false); + expect(stalwartStateWentBack(intermediate, stalwartExactState(1))).toBe(false); + expect(stalwartStateWentBack('sba', undefined)).toBe(false); + expect(stalwartStateWentBack('s-2', 's-1')).toBe(false); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index e564df04..5cbebde3 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -14,6 +14,7 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; import { buildMdnMessage, type MdnOptions } from '../lib/mdn'; import { hasTruncatedDisplayedBody } from '../lib/email-body'; +import { stalwartStateWentBack } from './stalwart-state'; export const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', @@ -76,6 +77,19 @@ function maxInSet(): number { return typeof jmapClient.getMaxObjectsInSet === 'function' ? jmapClient.getMaxObjectsInSet() : 500; } +// A /changes or /queryChanges answer whose state went back below the one it +// was asked from (see stalwartStateWentBack). The server then skipped every +// change since, so the caller takes it like cannotCalculateChanges and reads +// in full. Only Stalwart's states are read, on accounts that list its +// extension. +function stateWentBack(accountId: string, sinceState: string, newState: unknown): boolean { + if (typeof jmapClient.hasAccountCapability !== 'function') return false; + if (!jmapClient.hasAccountCapability('urn:stalwart:jmap', accountId)) return false; + if (!stalwartStateWentBack(sinceState, newState)) return false; + console.warn(`[email] ${accountId}: state went back from ${sinceState} to ${String(newState)}, reading in full`); + return true; +} + /** * Run one `Email/set` per `maxObjectsInSet`-sized slice of `update` / * `destroy`, checking every slice for `notUpdated` / `notDestroyed`. Over-limit @@ -194,7 +208,8 @@ export interface MailboxChangesResult { } // Returns null when the server can't compute the diff (typically -// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get. +// `cannotCalculateChanges`, or a state that went back); callers should fall +// back to a full Mailbox/get. export async function getMailboxChanges( sinceState: string, accountIdOverride?: string, @@ -205,6 +220,7 @@ export async function getMailboxChanges( ); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; + if (stateWentBack(accountId, sinceState, body.newState)) return null; return { oldState: body.oldState as string, newState: body.newState as string, @@ -555,7 +571,8 @@ export interface EmailQueryChangesResult { // 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. +// (or any other error, or a query state that went back) — caller should fall +// back to a fresh Email/query. export async function getEmailQueryChanges( mailboxId: string | undefined, sinceQueryState: string, @@ -583,6 +600,8 @@ export async function getEmailQueryChanges( const res = await jmapClient.request([['Email/queryChanges', args, '0']]); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; + // A query state is the Email change id the query was answered at. + if (stateWentBack(accountId, sinceQueryState, body.newQueryState)) return null; return { oldQueryState: body.oldQueryState as string, newQueryState: body.newQueryState as string, @@ -659,8 +678,11 @@ export async function getEmailListDelta( const res = await jmapClient.request(calls); const responses = res.methodResponses ?? []; - const qc = methodBody(responses, 'qc', 'Email/queryChanges'); - const ch = methodBody(responses, 'ch', 'Email/changes'); + // A state that went back counts as one the server can't diff. + let qc = methodBody(responses, 'qc', 'Email/queryChanges'); + if (qc && stateWentBack(accountId, sinceQueryState, qc.newQueryState)) qc = null; + let ch = methodBody(responses, 'ch', 'Email/changes'); + if (ch && stateWentBack(accountId, sinceState, ch.newState)) ch = null; const got = methodBody(responses, 'get', 'Email/get'); const th = methodBody(responses, 'th', 'Thread/get'); return { @@ -768,7 +790,8 @@ export interface EmailChangesResult { } // Returns null when the server replies with `cannotCalculateChanges` or any -// other error response; caller should treat that as "rebuild from scratch". +// other error response, or with a state that went back; caller should treat +// that as "rebuild from scratch". export async function getEmailChanges( sinceState: string, maxChanges?: number, @@ -780,6 +803,7 @@ export async function getEmailChanges( const res = await jmapClient.request([['Email/changes', args, '0']]); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; + if (stateWentBack(accountId, sinceState, body.newState)) return null; return { oldState: body.oldState as string, newState: body.newState as string, diff --git a/src/api/stalwart-state.ts b/src/api/stalwart-state.ts new file mode 100644 index 00000000..70a0c35d --- /dev/null +++ b/src/api/stalwart-state.ts @@ -0,0 +1,104 @@ +/** + * Stalwart's JMAP state strings, read back into the change id they carry. + * Port of the webmail's lib/jmap/stalwart-state.ts. + * + * Stalwart writes a state as `n` (initial), `s` + the change id, or `r` + an + * intermediate position (a /changes call cut short by maxChanges). The change + * id is LEB128-encoded, then written in base32 with Stalwart's own alphabet + * (jmap-proto types/state.rs, utils codec/base32_custom.rs; the same from + * 0.16.0 through 1.0). Change ids only grow on a healthy server, which is what + * lets a client notice a state that went backwards. + */ + +const STALWART_BASE32 = 'abcdefghijklmnopqrstuvwxyz792013'; + +// A u64 needs at most ten LEB128 bytes. +const MAX_LEB128_BYTES = 10; + +function base32Encode(bytes: number[]): string { + let out = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += STALWART_BASE32[(buffer >> bits) & 31]; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) out += STALWART_BASE32[(buffer << (5 - bits)) & 31]; + return out; +} + +function base32Decode(text: string): number[] | null { + const bytes: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of text) { + const value = STALWART_BASE32.indexOf(ch); + if (value < 0) return null; + buffer = (buffer << 5) | value; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + buffer &= (1 << bits) - 1; + } + } + return bytes; +} + +function leb128Encode(value: bigint): number[] { + const out: number[] = []; + let rest = value; + const low7 = BigInt(0x7f); + const shift = BigInt(7); + while (rest > low7) { + out.push(Number(rest & low7) | 0x80); + rest >>= shift; + } + out.push(Number(rest)); + return out; +} + +function leb128Decode(bytes: number[]): bigint | null { + let value = BigInt(0); + for (let i = 0; i < bytes.length && i < MAX_LEB128_BYTES; i++) { + value |= BigInt(bytes[i] & 0x7f) << BigInt(7 * i); + // Stalwart reads into a u64 and drops what doesn't fit. + if ((bytes[i] & 0x80) === 0) return BigInt.asUintN(64, value); + } + return null; +} + +/** + * The change id of a Stalwart `s` state, or null for any other string: an + * initial or intermediate state, another server's format, or anything + * Stalwart would not have written for that id (a non-canonical encoding). + * A BigInt, because ids are u64. + */ +export function stalwartChangeId(state: string): bigint | null { + if (state.length < 2 || state[0] !== 's') return null; + const bytes = base32Decode(state.slice(1)); + const id = bytes ? leb128Decode(bytes) : null; + if (id === null) return null; + return `s${base32Encode(leb128Encode(id))}` === state ? id : null; +} + +/** + * Whether a /changes answer went back from `sinceState` to a lower + * `newState`. Stalwart's change ids only grow; a lower one means its counters + * went back - a restore, or an upgrade that kept the old change log but + * restarted the counters lower (1.0 counts per collection where 0.16 counted + * per account) - and it then answers an old state with an empty delta, + * skipping every change since. False for any state not in the exact form + * Stalwart writes, so it never trips on other servers or while states grow. + */ +export function stalwartStateWentBack(sinceState: string, newState: unknown): boolean { + if (typeof newState !== 'string') return false; + const since = stalwartChangeId(sinceState); + const next = stalwartChangeId(newState); + return since !== null && next !== null && next < since; +} diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 4cb89ebd..b62a325f 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -936,8 +936,7 @@ describe('resyncPushNotifications', () => { expect(result?.subscriptionId).toBe('existing'); expect(updateMock).toHaveBeenCalledTimes(1); - // The expiry the server reported is what a later resync measures against. - expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires); + expect(createMock).not.toHaveBeenCalled(); }); it('leaves an account alone after the user turned push off for it', async () => { @@ -970,7 +969,10 @@ describe('resyncPushNotifications', () => { expect(createMock).not.toHaveBeenCalled(); }); - it('does not re-register a subscription another device revoked', async () => { + it('re-creates a subscription the server lost before it was due to expire (reinstalled at the same address)', async () => { + // A server reinstalled for Stalwart 1.0, or an upgrade that doesn't carry + // the subscriptions over, looks exactly like a revocation from elsewhere. + // The expiry record is what older builds took for proof of revocation. await AsyncStorage.setItem(SUB_KEY, 'existing'); await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); @@ -978,11 +980,39 @@ describe('resyncPushNotifications', () => { const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); - expect(result).toBeNull(); + expect(result?.subscriptionId).toBe('new-server-id'); + expect(createMock).toHaveBeenCalledTimes(1); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); + expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + }); + + it('does not take over another device\'s subscription that reuses the recorded id', async () => { + // Stalwart's ids are short and sequential: after a reinstall another + // device's subscription can come back under the id this device recorded. + await AsyncStorage.setItem(SUB_KEY, 'b'); + listMock.mockResolvedValue([ + { id: 'b', deviceClientId: 'otherotherotherotherotherotherot', expires: inDays(3), types: ['Email'] }, + ]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result?.subscriptionId).toBe('new-server-id'); + expect(updateMock).not.toHaveBeenCalled(); + // The relay can't vouch for it either way, so it stays. + expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('b'); + expect(createMock).toHaveBeenCalledTimes(1); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); + }); + + it('still reuses a recorded subscription the server lists without a device client id', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([{ id: 'existing', expires: inDays(80), types: ['EmailDelivery'] }]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result?.subscriptionId).toBe('existing'); expect(createMock).not.toHaveBeenCalled(); - expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); - expect(await readPushAccountIds()).toEqual([]); - expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull(); }); it('re-creates a subscription that simply lapsed', async () => { @@ -1023,7 +1053,6 @@ describe('resyncPushNotifications', () => { await setupPushNotifications({ relayBaseUrl: RELAY }); expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); - expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull(); vi.clearAllMocks(); listMock.mockResolvedValue([ { id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] }, @@ -1057,6 +1086,30 @@ describe('teardownPushNotificationsForAccount', () => { expect(native.deleteToken).not.toHaveBeenCalled(); expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); }); + + it('leaves another device\'s subscription alone when it reuses the recorded id', async () => { + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + await AsyncStorage.setItem(SUB_KEY, 'b'); + listMock.mockResolvedValue([ + sub('b', 'ffffffffffffffffffffffffffffffff'), + sub('c', OUR_DCID), + ]); + + await teardownPushNotificationsForAccount(ACCOUNT_ID); + + expect(destroyMock.mock.calls.map((c) => c[0])).toEqual(['c']); + }); + + it('still destroys the recorded id when the server cannot list its subscriptions', async () => { + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + await AsyncStorage.setItem(SUB_KEY, 'recorded'); + listMock.mockRejectedValueOnce(new Error('network down')); + + await teardownPushNotificationsForAccount(ACCOUNT_ID); + + expect(destroyMock.mock.calls.map((c) => c[0])).toEqual(['recorded']); + expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); + }); }); describe('notificationTapJmapAccountId', () => { diff --git a/src/lib/__tests__/server-fingerprint.test.ts b/src/lib/__tests__/server-fingerprint.test.ts new file mode 100644 index 00000000..89813d67 --- /dev/null +++ b/src/lib/__tests__/server-fingerprint.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { JMAPSession } from '../../api/types'; +import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../server-fingerprint'; + +// The capability names userc's session listed on the stw10 test servers +// (2026-09-25). 1.0 adds urn:ietf:params:jmap:metadata to the session and the +// account, and urn:ietf:params:jmap:principals:owner to every account. +const SESSION_CAPS_0_16 = [ + 'urn:ietf:params:jmap:blob', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:calendars:parse', + 'urn:ietf:params:jmap:contacts', 'urn:ietf:params:jmap:contacts:parse', 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:emailpush', 'urn:ietf:params:jmap:filenode', 'urn:ietf:params:jmap:mail', + 'urn:ietf:params:jmap:principals', 'urn:ietf:params:jmap:principals:availability', 'urn:ietf:params:jmap:quota', + 'urn:ietf:params:jmap:sieve', 'urn:ietf:params:jmap:submission', 'urn:ietf:params:jmap:vacationresponse', + 'urn:ietf:params:jmap:webpush-vapid', 'urn:ietf:params:jmap:websocket', +]; +const ACCOUNT_CAPS_0_16 = [ + 'urn:ietf:params:jmap:blob', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:calendars:parse', + 'urn:ietf:params:jmap:contacts', 'urn:ietf:params:jmap:contacts:parse', 'urn:ietf:params:jmap:emailpush', + 'urn:ietf:params:jmap:filenode', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', + 'urn:ietf:params:jmap:principals', 'urn:ietf:params:jmap:principals:availability', 'urn:ietf:params:jmap:quota', + 'urn:ietf:params:jmap:sieve', 'urn:ietf:params:jmap:submission', 'urn:ietf:params:jmap:vacationresponse', + 'urn:ietf:params:jmap:websocket', 'urn:stalwart:jmap', +]; +const SESSION_CAPS_1_0 = [...SESSION_CAPS_0_16, 'urn:ietf:params:jmap:metadata']; +const ACCOUNT_CAPS_1_0 = [...ACCOUNT_CAPS_0_16, 'urn:ietf:params:jmap:metadata', 'urn:ietf:params:jmap:principals:owner']; + +const API_URL = 'https://mail.example.org/jmap/'; + +function session(opts: { + sessionCaps: string[]; + accountCaps: string[]; + apiUrl?: string; + primary?: string; + state?: string; + extraAccounts?: Record; + values?: (urn: string) => unknown; +}): JMAPSession { + const primary = opts.primary ?? 'f'; + const caps = (names: string[]) => + Object.fromEntries(names.map((urn) => [urn, opts.values?.(urn) ?? {}])); + const accounts: JMAPSession['accounts'] = { + [primary]: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: caps(opts.accountCaps) }, + }; + for (const [id, names] of Object.entries(opts.extraAccounts ?? {})) { + accounts[id] = { name: id, isPersonal: false, isReadOnly: false, accountCapabilities: caps(names) }; + } + return { + apiUrl: opts.apiUrl ?? API_URL, + downloadUrl: 'https://mail.example.org/jmap/download/{accountId}/{blobId}/{name}?accept={type}', + uploadUrl: 'https://mail.example.org/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.org/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}', + primaryAccounts: Object.fromEntries(opts.accountCaps.map((urn) => [urn, primary])), + accounts, + capabilities: caps(opts.sessionCaps), + state: opts.state ?? 'b3bed0bb', + username: 'userc@example.org', + }; +} + +const V0_16 = session({ sessionCaps: SESSION_CAPS_0_16, accountCaps: ACCOUNT_CAPS_0_16 }); +const V1_0 = session({ sessionCaps: SESSION_CAPS_1_0, accountCaps: ACCOUNT_CAPS_1_0 }); + +describe('sessionFingerprint', () => { + it('tells Stalwart 1.0 from 0.16.23 at the same address', () => { + expect(sessionFingerprint(V1_0)).not.toBe(sessionFingerprint(V0_16)); + expect(sessionFingerprint(V1_0)).toMatch(/^[0-9a-f]{16}$/); + }); + + it('stays the same for the same server across sessions', () => { + // The session state is an AHash re-keyed on every Stalwart restart. + const restarted = session({ sessionCaps: SESSION_CAPS_1_0, accountCaps: ACCOUNT_CAPS_1_0, state: 'fd995b2b' }); + expect(sessionFingerprint(restarted)).toBe(sessionFingerprint(V1_0)); + + // Order of the capability names doesn't matter. + const reordered = session({ + sessionCaps: [...SESSION_CAPS_1_0].reverse(), + accountCaps: [...ACCOUNT_CAPS_1_0].reverse(), + }); + expect(sessionFingerprint(reordered)).toBe(sessionFingerprint(V1_0)); + }); + + it('ignores capability values and shared accounts, which change with settings and shares', () => { + const otherLimits = session({ + sessionCaps: SESSION_CAPS_1_0, + accountCaps: ACCOUNT_CAPS_1_0, + values: (urn) => (urn === 'urn:ietf:params:jmap:core' ? { maxObjectsInGet: 1000 } : { changed: true }), + }); + const withShare = session({ + sessionCaps: SESSION_CAPS_1_0, + accountCaps: ACCOUNT_CAPS_1_0, + extraAccounts: { d: ['urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:principals:owner'] }, + }); + expect(sessionFingerprint(otherLimits)).toBe(sessionFingerprint(V1_0)); + expect(sessionFingerprint(withShare)).toBe(sessionFingerprint(V1_0)); + }); + + it('changes with the API URL, the primary account and the capability names', () => { + const base = sessionFingerprint(V1_0); + expect(sessionFingerprint(session({ sessionCaps: SESSION_CAPS_1_0, accountCaps: ACCOUNT_CAPS_1_0, apiUrl: 'https://other.example.org/jmap/' }))).not.toBe(base); + expect(sessionFingerprint(session({ sessionCaps: SESSION_CAPS_1_0, accountCaps: ACCOUNT_CAPS_1_0, primary: 'b' }))).not.toBe(base); + // An admin switching a feature off: conservative, the caches go too. + const noFiles = SESSION_CAPS_1_0.filter((urn) => urn !== 'urn:ietf:params:jmap:filenode'); + expect(sessionFingerprint(session({ sessionCaps: noFiles, accountCaps: ACCOUNT_CAPS_1_0 }))).not.toBe(base); + const noOwner = ACCOUNT_CAPS_1_0.filter((urn) => urn !== 'urn:ietf:params:jmap:principals:owner'); + expect(sessionFingerprint(session({ sessionCaps: SESSION_CAPS_1_0, accountCaps: noOwner }))).not.toBe(base); + }); + + it('copes with a minimal session', () => { + const bare = { apiUrl: API_URL } as unknown as JMAPSession; + expect(sessionFingerprint(bare)).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +describe('recorded fingerprints', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + it('are kept per local account', async () => { + expect(await readServerFingerprint('userc@mail.example.org')).toBeNull(); + await writeServerFingerprint('userc@mail.example.org', sessionFingerprint(V1_0)); + expect(await readServerFingerprint('userc@mail.example.org')).toBe(sessionFingerprint(V1_0)); + expect(await readServerFingerprint('usera@mail.example.org')).toBeNull(); + }); +}); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index de0171ca..e6392833 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -47,13 +47,12 @@ const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:'; // emailPushFallbacks). Left out of the map from then on, so a refusal isn't // re-provoked on every launch. const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:'; -// When the recorded subscription expires, as the server last reported it. Lets -// the launch-time resync tell a subscription that lapsed (re-create it) from -// one that was revoked (leave push off). +// The recorded subscription's expiry. Earlier builds took a subscription gone +// before it for revoked; nothing reads it now, and teardown still clears it. const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:'; // Set when the user turned push off for the account - the settings toggle, or -// revoking this device here or from elsewhere. The launch-time resync leaves -// such an account alone until push is enabled for it again. +// revoking this device here. The launch-time resync leaves such an account +// alone until push is enabled for it again. const OPTED_OUT_PREFIX = 'push:optedOut:v1:'; // Legacy single-account keys (pre-multi-account). Migrated lazily on the next @@ -227,10 +226,6 @@ const SUBSCRIPTION_EXPIRES_DAYS = 90; // When an existing subscription has less than this much lifetime left, push // expires forward on the next app start. const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7; -// A recorded subscription missing from the server was revoked only if it still -// had at least this long to live; closer to its expiry (or with the clock a -// little off) it may simply have lapsed, and is re-created. -const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000; function expiresFromNow(days: number): string { return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); @@ -1030,10 +1025,14 @@ async function setupPushNotificationsInner( jmapAccountId = null; } let replacedServerId: string | null = null; + // Ours only if it was made for this device: Stalwart's ids are short and + // sequential, so once the server is reinstalled at the same address the + // recorded id can name another device's subscription. + const match = storedServerId + ? existingSubs.find((s) => s.id === storedServerId && (!s.deviceClientId || s.deviceClientId === deviceClientId)) + : undefined; if (storedServerId) { - const match = existingSubs.find((s) => s.id === storedServerId); if (match) { - if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires); if (!params.forceRecreate) { let refreshed: Awaited>; try { @@ -1058,6 +1057,9 @@ async function setupPushNotificationsInner( // once it's verified. replacedServerId = storedServerId; } else { + // The server no longer has it: it expired, was revoked, or the server + // lost its subscriptions (a reinstall, an upgrade). Either way it is + // re-created below; push is never left off because of it. await AsyncStorage.removeItem(subKey); } } @@ -1077,7 +1079,7 @@ async function setupPushNotificationsInner( // vouch for (a different relay, a non-Bulwark client, a network blip) - // is left untouched. for (const s of existingSubs) { - if (s.id === storedServerId) continue; + if (s.id === match?.id) continue; if (s.deviceClientId === deviceClientId) { await destroyPushSubscription(s.id).catch(() => undefined); continue; @@ -1088,7 +1090,6 @@ async function setupPushNotificationsInner( } let serverAssignedId: string; - let serverExpires: string | null; try { const created = await writeWithEmailPush(emailPush, (filter) => createPushSubscription({ @@ -1100,7 +1101,6 @@ async function setupPushNotificationsInner( }), ); serverAssignedId = created.result.id; - serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { throw subscriptionError(err); @@ -1119,7 +1119,6 @@ async function setupPushNotificationsInner( } await AsyncStorage.setItem(subKey, serverAssignedId); - if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires); if (replacedServerId) { await destroyPushSubscription(replacedServerId).catch(() => undefined); } @@ -1207,6 +1206,21 @@ async function clearAccountPushKeys(accountId: string): Promise { await writePushJmapAccountId(accountId, null); } +/** + * Forget what push learned from the account's server: the ring of notified + * message ids (a different server hands the same short ids to new mail, which + * would then never notify) and the accounts it refused in the emailPush map. + * The registration is left to the next resync, which re-creates it when the + * server no longer has it (see lib/server-change). + */ +export async function forgetPushServerState(accountId: string): Promise { + await AsyncStorage.multiRemove([ + lastNotifiedKey(accountId), + notifiedIdsKey(accountId), + emailPushRefusedKey(accountId), + ]); +} + /** * Tear down push for a single account. Destroys every JMAP subscription the * server holds for this device (assumes the jmapClient is currently @@ -1231,13 +1245,17 @@ export async function teardownPushNotificationsForAccount( // Destroy every subscription the server holds for this device, not just the // id we happen to have recorded - a destroy that lost its round-trip or a // failed enable can leave a registration this client no longer tracks (#841). + // The recorded id alone is trusted only when the server can't be asked: on + // a server reinstalled at the same address it can name another device's. const idsToDestroy = new Set(); - if (storedSubId) idsToDestroy.add(storedSubId); - if (storedDcid) { - const existing = await listPushSubscriptions().catch(() => []); + const existing = storedDcid ? await listPushSubscriptions().catch(() => null) : null; + if (existing) { for (const s of existing) { - if (s.deviceClientId === storedDcid) idsToDestroy.add(s.id); + const recordedWithoutDevice = s.id === storedSubId && !s.deviceClientId; + if (s.deviceClientId === storedDcid || recordedWithoutDevice) idsToDestroy.add(s.id); } + } else if (storedSubId) { + idsToDestroy.add(storedSubId); } for (const id of idsToDestroy) { await destroyPushSubscription(id).catch(() => undefined); @@ -1254,38 +1272,23 @@ export async function teardownPushNotificationsForAccount( /** * Turn push off for one account because the user asked to: the settings - * toggle, or revoking this device (here, or from another device - see - * resyncPushNotifications). Tears the registration down and remembers the - * choice, so the launch-time resync doesn't quietly register the account - * again. Enabling push for it again clears the mark. + * toggle, or revoking this device here. Tears the registration down and + * remembers the choice, so the launch-time resync doesn't quietly register + * the account again. Enabling push for it again clears the mark. */ export async function disablePushForAccount(accountId: string): Promise { await teardownPushNotificationsForAccount(accountId); await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now())); } -/** - * True when the server no longer has this account's recorded subscription - * although it wasn't due to expire - someone revoked it. Without a known - * expiry (a registration made by an older build) nothing can be told apart, - * so it counts as not revoked. Throws when the server can't be asked. - */ -async function wasRevokedOnServer(accountId: string): Promise { - const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); - const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? ''); - if (!storedServerId || !Number.isFinite(expires)) return false; - if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false; - const subs = await listPushSubscriptions(); - return !subs.some((s) => s.id === storedServerId); -} - /** * Bring the loaded account's registration up to date without user action - * on launch and when the push token or endpoint rotates. Skips an account the - * user turned push off for, and honours a revocation: when the server dropped - * the subscription before it was due to expire, push is turned off for the - * account instead of being silently registered again. Resolves to null when - * it left push off. + * user turned push off for. A subscription the server no longer has counts + * as expired and is re-created: the server can lose every subscription (a + * reinstall at the same address, an upgrade that doesn't carry them over), + * which looks the same as a revocation from another device. Resolves to null + * when it left push off. */ export async function resyncPushNotifications( params: PushSetupParams, @@ -1295,11 +1298,6 @@ export async function resyncPushNotifications( if (!username || !serverUrl) return null; const accountId = generateAccountId(username, serverUrl); if (await AsyncStorage.getItem(optedOutKey(accountId))) return null; - if (await wasRevokedOnServer(accountId)) { - logPhase('revoked', 'subscription gone before it was due to expire; leaving push off'); - await disablePushForAccount(accountId); - return null; - } return setupPushNotifications(params); } diff --git a/src/lib/server-change.ts b/src/lib/server-change.ts new file mode 100644 index 00000000..2cc0d3f1 --- /dev/null +++ b/src/lib/server-change.ts @@ -0,0 +1,82 @@ +import type { JMAPSession } from '../api/types'; +import { useCalendarStore } from '../stores/calendar-store'; +import { useContactsStore } from '../stores/contacts-store'; +import { useEmailStore } from '../stores/email-store'; +import { useOfflineCacheStore } from '../stores/offline-cache-store'; +import { useOutboxStore } from '../stores/outbox-store'; +import { flushPersistedWrites } from '../stores/persist-storage'; +import { clearBodyHeights } from './body-heights'; +import { clearBodyDocuments } from './email-body-document'; +import { clearEmailDetailCache } from './email-detail-cache'; +import { forgetPushServerState } from './push-notifications'; +import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from './server-fingerprint'; + +// The app keeps a lot across launches that only means something on the +// server it came from: JMAP states and query states, message ids in the +// offline cache and the outbox, calendar occurrence ids. When the server +// behind an account is replaced - Stalwart 1.0 has no migration from 0.16, so +// the way there is a fresh install at the same address - its ids start over +// ("a", "b", ...) and name other objects, and old states may be accepted with +// changes skipped. So each time a session is established, its fingerprint is +// compared with the one recorded for the account, and on a change everything +// derived from the old server is dropped before anything reads it. + +/** + * Drop what the app derived from an account's server. Preferences, templates, + * tags and iCal subscriptions stay: they are the user's, not the server's. + * Every step runs; the first failure is thrown afterwards. + */ +export async function forgetServerDerivedState(accountId: string): Promise { + const failures: unknown[] = []; + const step = async (run: () => unknown): Promise => { + try { + await run(); + } catch (err) { + failures.push(err); + } + }; + + // Folders, lists, their JMAP states and query states. + await step(() => useEmailStore.getState().forgetServerState(accountId)); + // The calendar and contacts caches hold the account being signed in: they + // are reset whenever another account is switched to. + await step(() => useCalendarStore.getState().reset()); + await step(() => useContactsStore.getState().reset()); + await step(() => { + clearEmailDetailCache(); + clearBodyDocuments(); + clearBodyHeights(); + }); + // Offline bodies are keyed by message id. + await step(() => useOfflineCacheStore.getState().clearAccount(accountId)); + // Every queued op names a message by its server id, and a `destroy` + // replayed against a server that gave the id to another message deletes + // that one. So the whole queue goes, failed ops too. Offline edits still + // waiting are lost; a message the user wrote never is: the outbox holds no + // sends (sending needs a connection, and drafts are saved to the server). + await step(() => useOutboxStore.getState().clearAccount(accountId)); + await step(() => forgetPushServerState(accountId)); + // Get the emptied caches onto disk before the new fingerprint is. + await step(() => flushPersistedWrites()); + + if (failures.length > 0) throw failures[0]; +} + +/** + * Compare the session with the fingerprint recorded for the local account + * (username@host) and drop what was derived from the server when it changed. + * The first sight of an account only records it. The fingerprint is written + * after the caches are gone, so an interrupted drop is repeated next time. + * Resolves to true when caches were dropped. + */ +export async function reconcileServerFingerprint(accountId: string, session: JMAPSession): Promise { + const next = sessionFingerprint(session); + const recorded = await readServerFingerprint(accountId); + if (recorded === next) return false; + if (recorded !== null) { + console.warn(`[server-change] ${accountId}: the server changed, dropping what was read from the old one`); + await forgetServerDerivedState(accountId); + } + await writeServerFingerprint(accountId, next); + return recorded !== null; +} diff --git a/src/lib/server-fingerprint.ts b/src/lib/server-fingerprint.ts new file mode 100644 index 00000000..e26650a3 --- /dev/null +++ b/src/lib/server-fingerprint.ts @@ -0,0 +1,58 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { CAPABILITIES } from '../api/types'; +import type { JMAPSession } from '../api/types'; +import { sha256Hex } from './sha256'; + +// What the session says about the server behind an account, so the app can +// tell when the server that filled its caches has been replaced. Stalwart +// exposes no version, and the session `state` changes on every restart (an +// AHash with per-process keys), so the fingerprint is built from what is +// stable: the API URL, the primary account id, and the capability names the +// session and the primary account list. 1.0 adds +// `urn:ietf:params:jmap:metadata` (session and account) and +// `urn:ietf:params:jmap:principals:owner` (every account); 0.16.16 added +// emailpush. An admin switching a feature on or off changes it too, which +// only costs a re-download. + +const STORAGE_PREFIX = 'server-fingerprint:v1:'; + +function storageKey(accountId: string): string { + return STORAGE_PREFIX + accountId; +} + +/** The session's primary account, picked the way JMAPClient picks it. */ +function primaryAccountId(session: JMAPSession): string { + return session.primaryAccounts?.[CAPABILITIES.MAIL] + || session.primaryAccounts?.[CAPABILITIES.CORE] + || Object.keys(session.accounts ?? {})[0] + || ''; +} + +/** + * A short hash of what identifies the server in a session. Capability values + * (limits, sort options) and shared accounts are left out: they change with + * settings and shares, not with the server. + */ +export function sessionFingerprint(session: JMAPSession): string { + const primary = primaryAccountId(session); + const parts = [ + session.apiUrl ?? '', + primary, + Object.keys(session.capabilities ?? {}).sort(), + Object.keys(session.accounts?.[primary]?.accountCapabilities ?? {}).sort(), + ]; + return sha256Hex(new TextEncoder().encode(JSON.stringify(parts))).slice(0, 16); +} + +/** The fingerprint recorded for a local account (username@host), if any. */ +export async function readServerFingerprint(accountId: string): Promise { + try { + return await AsyncStorage.getItem(storageKey(accountId)); + } catch { + return null; + } +} + +export async function writeServerFingerprint(accountId: string, fingerprint: string): Promise { + await AsyncStorage.setItem(storageKey(accountId), fingerprint); +} diff --git a/src/stores/__tests__/auth-store-server-change.test.ts b/src/stores/__tests__/auth-store-server-change.test.ts new file mode 100644 index 00000000..20a43da8 --- /dev/null +++ b/src/stores/__tests__/auth-store-server-change.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Restoring the session after the server at the account's address was +// replaced (Stalwart 1.0 installed fresh where 0.16.23 ran): the states the +// app persisted from the old server must never reach the new one. + +const { client } = vi.hoisted(() => { + const accountCaps = (version: string): Record => ({ + 'urn:ietf:params:jmap:mail': {}, + 'urn:stalwart:jmap': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:principals:owner': {}, 'urn:ietf:params:jmap:metadata': {} } : {}), + }); + const session = (version: string) => ({ + apiUrl: 'https://mail.example.com/jmap/', + downloadUrl: '', + uploadUrl: '', + eventSourceUrl: '', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'f' }, + accounts: { f: { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps(version) } }, + capabilities: { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:mail': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:metadata': {} } : {}), + }, + state: 'b3bed0bb', + }); + return { + client: { + session, + requests: [] as Array, string]>>, + }, + }; +}); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + loadAccount: vi.fn(async () => true), + consumeLegacyCredentials: vi.fn(async () => null), + clearAccountCredentials: vi.fn(async () => undefined), + reset: vi.fn(), + snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })), + restoreSnapshot: vi.fn(), + onAuthFailure: vi.fn(() => () => undefined), + onTokenRefresh: vi.fn(() => () => undefined), + hasAccountCapability: vi.fn(() => true), + hasCapability: vi.fn(() => false), + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [], + getAccountName: () => 'user', + request: vi.fn(async (calls: Array<[string, Record, string]>) => { + client.requests.push(calls); + // The 1.0 server knows nothing of the 0.16.23 states. + return { + methodResponses: calls.map(([name, args, id]) => (name.endsWith('/changes') + ? ['error', { type: 'cannotCalculateChanges' }, id] + : [name, { accountId: args.accountId, state: 'sfy', list: [], notFound: [] }, id])), + }; + }), + getStoredOAuthTokens: vi.fn(async () => null), + accountId: 'f', + isConnected: true, + currentSession: client.session('1.0'), + username: 'user', + serverUrl: 'https://mail.example.com', + }, + AuthenticationError: class AuthenticationError extends Error {}, + NetworkError: class NetworkError extends Error {}, +})); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), + forgetPushServerState: vi.fn(async () => undefined), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; +import { useEmailStore } from '../email-store'; +import { useOutboxStore } from '../outbox-store'; +import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; +import type { JMAPSession } from '../../api/types'; + +const ACCOUNT = 'user@mail.example.com'; +const session = (version: string) => client.session(version) as unknown as JMAPSession; + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await AsyncStorage.clear(); + client.requests.length = 0; + useAccountStore.setState({ + accounts: [{ + id: ACCOUNT, serverUrl: 'https://mail.example.com', username: 'user', displayName: 'user', email: 'user', + avatarColor: '#000', lastLoginAt: 0, isConnected: false, hasError: false, isDefault: true, + }], + activeAccountId: ACCOUNT, + defaultAccountId: ACCOUNT, + }); + // The cached view the 0.16.23 server left, as a cold start rehydrates it. + useEmailStore.setState({ + activeAccountId: ACCOUNT, + accountSnapshots: {}, + mailboxes: [{ id: 'a', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: {} as never }], + mailboxState: 's1iaq', + emailStates: { a: 's1iaq' }, + currentMailboxId: 'a', + mailboxSnapshots: { a: { emails: [{ id: 'b' } as never], total: 1, queryState: 's1iaq' } }, + emails: [{ id: 'b' } as never], + totalEmails: 1, + queryState: 's1iaq', + }); + useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, client: null }); +}); + +describe('restoring the session on a replaced server', () => { + it('drops the persisted 0.16.23 states before anything is sent to Stalwart 1.0', async () => { + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); + + expect(await useAuthStore.getState().restoreSession()).toBe(true); + // Let the fire-and-forget refetch run. + await new Promise((r) => setTimeout(r, 20)); + + const sent = client.requests.flat(); + expect(sent.length).toBeGreaterThan(0); + expect(JSON.stringify(sent)).not.toContain('s1iaq'); + expect(sent.some(([name]) => name === 'Mailbox/changes')).toBe(false); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + expect(useOutboxStore.getState().entries).toEqual([]); + }); + + it('keeps the cached view and syncs from it when the server is the same (Stalwart 1.0)', async () => { + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('1.0'))); + + expect(await useAuthStore.getState().restoreSession()).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + + const changes = client.requests.flat().filter(([name]) => name === 'Mailbox/changes'); + expect(changes.map(([, args]) => args.sinceState)).toEqual(['s1iaq']); + }); +}); diff --git a/src/stores/__tests__/server-change.test.ts b/src/stores/__tests__/server-change.test.ts new file mode 100644 index 00000000..cfd7d871 --- /dev/null +++ b/src/stores/__tests__/server-change.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Not connected: nothing here may reach a server, and the outbox must not +// flush what the tests queue. +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { isConnected: false, accountId: 'f' }, +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Email, JMAPSession } from '../../api/types'; +import { reconcileServerFingerprint } from '../../lib/server-change'; +import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; +import { useCalendarStore } from '../calendar-store'; +import { useContactsStore } from '../contacts-store'; +import { useEmailStore, type AccountSnapshot } from '../email-store'; +import { useOfflineCacheStore } from '../offline-cache-store'; +import { useOutboxStore } from '../outbox-store'; + +// Stalwart 1.0 has no migration from 0.16: the way there is a fresh install +// at the same address, where ids start over ("a", "b", ...) and the account's +// persisted states, ids and queued ops mean other things. +const ACCOUNT = 'userc@mail.example.org'; +const OTHER = 'usera@other.example.org'; + +function session(version: '0.16.23' | '1.0'): JMAPSession { + const accountCaps: Record = { + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:emailpush': {}, + 'urn:stalwart:jmap': {}, + }; + const capabilities: Record = { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:emailpush': {}, + }; + if (version === '1.0') { + accountCaps['urn:ietf:params:jmap:metadata'] = {}; + accountCaps['urn:ietf:params:jmap:principals:owner'] = { accountIdForPrincipal: 'f', principalId: 'f' }; + capabilities['urn:ietf:params:jmap:metadata'] = {}; + } + return { + apiUrl: 'https://mail.example.org/jmap/', + downloadUrl: '', + uploadUrl: '', + eventSourceUrl: '', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'f' }, + accounts: { f: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps } }, + capabilities, + state: 'b3bed0bb', + }; +} + +const email = (id: string): Email => ({ + id, + threadId: `t-${id}`, + mailboxIds: { a: true }, + keywords: { $seen: true }, + size: 100, + receivedAt: '2026-09-20T10:00:00Z', + subject: `Message ${id}`, +} as unknown as Email); + +const INBOX = { + id: 'a', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, + myRights: {} as never, accountId: 'f', isShared: false, +}; + +const otherSnapshot: AccountSnapshot = { + mailboxes: [{ ...INBOX, accountId: 'b' }], + mailboxState: 'sae', + emailStates: { a: 'sae' }, + currentMailboxId: 'a', + mailboxSnapshots: { a: { emails: [email('z')], total: 1, queryState: 'sae' } }, +}; + +async function seed(active: string): Promise { + await AsyncStorage.clear(); + // What the 0.16.23 server left behind, for ACCOUNT and for another account. + useEmailStore.setState({ + accountSnapshots: { [OTHER]: otherSnapshot, [ACCOUNT]: { + mailboxes: [INBOX], mailboxState: 's1iaq', emailStates: { a: 's1iaq' }, currentMailboxId: 'a', + mailboxSnapshots: { a: { emails: [email('b')], total: 1, queryState: 's1iaq' } }, + } }, + activeAccountId: null, + }); + useEmailStore.getState().setActiveAccount(active); + + for (const account of [OTHER, ACCOUNT]) { + await useOfflineCacheStore.getState().setAccount(account); + await useOfflineCacheStore.getState().put({ ...email('b'), bodyValues: {} } as Email, 500); + await useOutboxStore.getState().setAccount(account); + useOutboxStore.getState().enqueue({ kind: 'destroy', emailId: 'b' }); + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'c', patch: { $seen: true } }); + await AsyncStorage.setItem(`webmail:outbox:v1:${account}:failed`, JSON.stringify([ + { id: 'x', op: { kind: 'mailboxes', emailId: 'd', mailboxIds: { e: true } }, createdAt: 1, attempts: 5 }, + ])); + await AsyncStorage.setItem(`push:notifiedIds:v1:${account}`, JSON.stringify(['b', 'a'])); + await AsyncStorage.setItem(`push:lastNotifiedEmailId:v2:${account}`, 'b'); + await AsyncStorage.setItem(`push:emailPushRefused:v1:${account}`, JSON.stringify(['d'])); + await AsyncStorage.setItem(`push:subscriptionId:v2:${account}`, 'a'); + } + await useOfflineCacheStore.getState().setAccount(active); + await useOutboxStore.getState().setAccount(active); + + useCalendarStore.setState({ + calendars: [{ id: 'a', name: 'Calendar' } as never], + events: [{ id: 'b', title: 'Standup', calendarIds: { a: true } } as never], + loadedRange: { start: '2026-09-01', end: '2026-10-01' } as never, + }); + useContactsStore.setState({ + addressBooks: [{ id: 'a', name: 'Personal' } as never], + contacts: [{ id: 'b', name: { full: 'Ann' } } as never], + }); + + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); +} + +const storedKeys = async () => (await AsyncStorage.getAllKeys()) as string[]; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +describe('reconcileServerFingerprint', () => { + it('drops what Stalwart 0.16.23 left behind when 1.0 answers at the same address', async () => { + await seed(ACCOUNT); + + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(true); + + // Mail: folders, lists, JMAP states and query states of the account. + const mail = useEmailStore.getState(); + expect(mail.activeAccountId).toBe(ACCOUNT); + expect(mail.mailboxes).toEqual([]); + expect(mail.mailboxState).toBeUndefined(); + expect(mail.emailStates).toEqual({}); + expect(mail.mailboxSnapshots).toEqual({}); + expect(mail.emails).toEqual([]); + expect(mail.queryState).toBeUndefined(); + expect(mail.currentMailboxId).toBeNull(); + expect(mail.accountSnapshots[ACCOUNT]).toBeUndefined(); + // ... and on disk, not just in memory. + expect(await AsyncStorage.getItem('email-cache')).not.toContain('s1iaq'); + + // Offline bodies, queued and failed ops, push de-dupe state. + const keys = await storedKeys(); + expect(keys.filter((k) => k.includes(`offline-cache:entry:v2:${ACCOUNT}:`))).toEqual([]); + expect(keys).not.toContain(`webmail:offline-cache:index:v2:${ACCOUNT}`); + expect(useOfflineCacheStore.getState().totalCount()).toBe(0); + expect(keys).not.toContain(`webmail:outbox:v1:${ACCOUNT}`); + expect(keys).not.toContain(`webmail:outbox:v1:${ACCOUNT}:failed`); + expect(useOutboxStore.getState().entries).toEqual([]); + expect(useOutboxStore.getState().failed).toEqual([]); + for (const key of ['push:notifiedIds:v1:', 'push:lastNotifiedEmailId:v2:', 'push:emailPushRefused:v1:']) { + expect(keys).not.toContain(key + ACCOUNT); + } + // The registration stays for the resync to check against the server. + expect(await AsyncStorage.getItem(`push:subscriptionId:v2:${ACCOUNT}`)).toBe('a'); + + // Calendar and contacts caches (they hold the account being signed in). + expect(useCalendarStore.getState().events).toEqual([]); + expect(useCalendarStore.getState().loadedRange).toBeNull(); + expect(useContactsStore.getState().contacts).toEqual([]); + + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + }); + + it('leaves every other account alone', async () => { + await seed(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + expect(useEmailStore.getState().accountSnapshots[OTHER]).toEqual(otherSnapshot); + const keys = await storedKeys(); + expect(keys).toContain(`webmail:offline-cache:index:v2:${OTHER}`); + expect(keys).toContain(`webmail:offline-cache:entry:v2:${OTHER}:b`); + expect(JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${OTHER}`))!)).toHaveLength(2); + expect(keys).toContain(`webmail:outbox:v1:${OTHER}:failed`); + expect(keys).toContain(`push:notifiedIds:v1:${OTHER}`); + expect(keys).toContain(`push:emailPushRefused:v1:${OTHER}`); + }); + + it('drops a changed account that is not on screen from its tucked-away snapshot', async () => { + // Signing in to ACCOUNT while OTHER is shown: the check runs before the + // email store switches over. + await seed(OTHER); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + const mail = useEmailStore.getState(); + expect(mail.accountSnapshots[ACCOUNT]).toBeUndefined(); + expect(mail.activeAccountId).toBe(OTHER); + expect(mail.mailboxState).toBe('sae'); + expect(mail.emails.map((e) => e.id)).toEqual(['z']); + const keys = await storedKeys(); + expect(keys).not.toContain(`webmail:offline-cache:entry:v2:${ACCOUNT}:b`); + expect(keys).not.toContain(`webmail:outbox:v1:${ACCOUNT}`); + expect(useOutboxStore.getState().entries).toHaveLength(2); // OTHER's queue, loaded + }); + + it('changes nothing while the server stays the same (Stalwart 0.16.23)', async () => { + await seed(ACCOUNT); + + expect(await reconcileServerFingerprint(ACCOUNT, session('0.16.23'))).toBe(false); + + expect(useEmailStore.getState().mailboxState).toBe('s1iaq'); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['b']); + expect(useOutboxStore.getState().entries).toHaveLength(2); + expect(useCalendarStore.getState().events).toHaveLength(1); + expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).not.toBeNull(); + }); + + it('only records the first server it sees for an account', async () => { + await seed(ACCOUNT); + await AsyncStorage.removeItem(`server-fingerprint:v1:${ACCOUNT}`); + + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(false); + + expect(useEmailStore.getState().mailboxState).toBe('s1iaq'); + expect(useOutboxStore.getState().entries).toHaveLength(2); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + }); + + it('tries again with the next session when dropping failed part-way', async () => { + await seed(ACCOUNT); + const multiRemove = AsyncStorage.multiRemove as ReturnType; + multiRemove.mockRejectedValueOnce(new Error('disk full')); + + await expect(reconcileServerFingerprint(ACCOUNT, session('1.0'))).rejects.toThrow('disk full'); + + // The rest went anyway, but the old fingerprint stays so the next check + // repeats the drop. + expect(useEmailStore.getState().mailboxState).toBeUndefined(); + expect(useOutboxStore.getState().entries).toEqual([]); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('0.16.23'))); + + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(true); + expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).toBeNull(); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 67bf018a..0436df48 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -19,6 +19,7 @@ import { teardownPushNotifications, teardownPushNotificationsForAccount, } from '../lib/push-notifications'; +import { reconcileServerFingerprint } from '../lib/server-change'; // Persist middleware hydrates asynchronously on cold start. Without this // guard, restoreSession() can read the account-store before AsyncStorage has @@ -133,6 +134,18 @@ function clearAccountFeatureStores(accountId: string | null): void { void flushPersistedWrites(); } +// Once a session is up and before anything reads the account's caches: when +// the server behind it is not the one that filled them, they go (see +// lib/server-change). A failed check never blocks signing in; it runs again +// with the next session. +async function checkServerChange(accountId: string, session: JMAPSession): Promise { + try { + await reconcileServerFingerprint(accountId, session); + } catch (err) { + console.warn('[auth-store] server change check failed:', err); + } +} + function refetchFeatureStores(): void { // Fire-and-forget: each store handles its own errors. const emailStore = useEmailStore.getState(); @@ -226,6 +239,7 @@ async function completeOAuthHandoff( useContactsStore.getState().reset(); useCalendarStore.getState().reset(); } + await checkServerChange(accountId, session); const accountStore = useAccountStore.getState(); accountStore.addAccount({ @@ -301,6 +315,7 @@ export const useAuthStore = create((set, get) => ({ useCalendarStore.getState().reset(); } const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, '')); + await checkServerChange(accountId, session); const accountStore = useAccountStore.getState(); accountStore.addAccount({ @@ -616,6 +631,7 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: false, error: 'Failed to load session' }); return; } + await checkServerChange(accountId, session); applyConnectedState(set, session, target.serverUrl, target.username, accountId); refetchFeatureStores(); @@ -738,6 +754,7 @@ export const useAuthStore = create((set, get) => ({ }); const session = jmapClient.currentSession!; + await checkServerChange(target.id, session); applyConnectedState(set, session, target.serverUrl, target.username, target.id); // Refresh the cached mailbox list + current folder now that the session // is live. Feature stores show persisted data immediately; this swaps @@ -771,6 +788,7 @@ export const useAuthStore = create((set, get) => ({ errorMessage: undefined, }); const fresh = jmapClient.currentSession!; + await checkServerChange(activeAccountId, fresh); applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId); refetchFeatureStores(); return true; diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 2b8a452a..906c3077 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -375,6 +375,13 @@ export interface EmailState { // ── Actions ──────────────────────────────────────────────────── setActiveAccount: (accountId: string | null) => void; removeAccount: (accountId: string) => void; + /** + * Forget what was read from an account's server - folders, lists, JMAP + * states and query states - whether the account is on screen or tucked + * away. It stays signed in and selected; the next fetch starts over. For a + * server that may no longer be the one these came from (lib/server-change). + */ + forgetServerState: (accountId: string) => void; clearAllAccounts: () => void; fetchMailboxes: () => Promise; /** @@ -937,6 +944,32 @@ export const useEmailStore = create()( } }, + forgetServerState: (accountId) => { + const state = get(); + const { [accountId]: _drop, ...rest } = state.accountSnapshots; + if (state.activeAccountId !== accountId) { + set({ accountSnapshots: rest }); + return; + } + set({ + accountSnapshots: rest, + mailboxes: [], + mailboxState: undefined, + emailStates: {}, + // Folder ids may name other folders now; the list opens the Inbox. + currentMailboxId: null, + mailboxSnapshots: {}, + emails: [], + totalEmails: 0, + queryState: undefined, + pendingUndo: null, + retainedIds: [], + threadCounts: {}, + accountErrors: {}, + error: null, + }); + }, + clearAllAccounts: () => { set({ accountSnapshots: {}, diff --git a/src/stores/offline-cache-store.ts b/src/stores/offline-cache-store.ts index e15a0955..e776b2ae 100644 --- a/src/stores/offline-cache-store.ts +++ b/src/stores/offline-cache-store.ts @@ -93,6 +93,10 @@ interface OfflineCacheState { // the persisted cache from growing without bound on a noisy account. evictToFit: (maxBytes: number) => Promise; clearAll: () => Promise; + // Drop every cached body of `accountId`, whether or not its bucket is the + // loaded one: bodies are keyed by message id, and a different server can + // hand the same ids to other messages (see lib/server-change). + clearAccount: (accountId: string) => Promise; // Returns cached emails whose `mailboxIds` include the given mailbox, // sorted by `receivedAt` descending. Used by selectMailbox to seed the @@ -302,6 +306,19 @@ export const useOfflineCacheStore = create((set, get) => ({ persistIndex(accountId, index); }, + clearAccount: async (accountId) => { + const loaded = get().activeAccountId === accountId; + const keys = Object.keys((loaded ? get().index : await loadIndex(accountId)).entries); + if (loaded) { + // A sync still filling the bucket stops at its next chunk. + set({ index: { entries: {} }, sync: { ...IDLE }, abortRequested: true }); + } + await Promise.all( + keys.map((key) => AsyncStorage.removeItem(entryKey(accountId, key)).catch(() => undefined)), + ); + await AsyncStorage.removeItem(indexKey(accountId)).catch(() => undefined); + }, + getEmailsInMailbox: async (mailboxId, limit = 200, jmapAccountId) => { const state = get(); const accountId = state.activeAccountId; diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 7b19a11f..4b9c85d5 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -99,6 +99,12 @@ interface OutboxState { retryFailed: () => Promise; discardFailed: () => void; clear: () => Promise; + /** + * Drop an account's queued and failed ops, loaded or not. Every op names a + * message by its server id, which a different server can have given to + * another message (see lib/server-change). + */ + clearAccount: (accountId: string) => Promise; } function persist(accountId: string, entries: OutboxEntry[]): void { @@ -388,6 +394,15 @@ export const useOutboxStore = create((set, get) => ({ await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); } }, + + clearAccount: async (accountId) => { + if (get().activeAccountId === accountId) { + clearRetry(); + set({ entries: [], failed: [] }); + } + await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); + await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); + }, })); // ── Internal helpers that mutate+persist the live list ──────────────────────