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 ──────────────────────