diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts new file mode 100644 index 00000000..abfb5bbf --- /dev/null +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('expo-secure-store', () => ({ + setItemAsync: vi.fn(), + getItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), +})); + +const { mockRefresh } = vi.hoisted(() => ({ mockRefresh: vi.fn() })); +vi.mock('../../lib/oauth', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, refreshOAuthAccessToken: mockRefresh }; +}); + +import { + JMAPClient, + AuthenticationError, + NetworkError, + RateLimitError, + RequestTimeoutError, + TotpRequiredError, + JMAPMethodError, + requireMethodResult, + assertSetResult, + rewriteSessionUrl, + parseRetryAfter, + isConcurrentRequestRefusal, + batched, +} from '../jmap-client'; +import { TransientRefreshError } from '../../lib/oauth'; +import { FirstTouchGate, gateKeysFor } from '../first-touch-gate'; +import type { JMAPSession } from '../types'; + +const SESSION: JMAPSession = { + apiUrl: 'https://mail.example.com/jmap/', + downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://mail.example.com/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1' }, + accounts: { + 'acc-1': { + name: 'user@example.com', + isPersonal: true, + isReadOnly: false, + accountCapabilities: { 'urn:stalwart:jmap': {} }, + }, + }, + capabilities: { + 'urn:ietf:params:jmap:core': { maxObjectsInSet: 50, maxSizeUpload: 1024 }, + 'urn:ietf:params:jmap:mail': {}, + }, + state: 's1', + username: 'user@example.com', +}; + +type Resp = { + status: number; + json?: unknown; + text?: string; + headers?: Record; + redirected?: boolean; + url?: string; + delayMs?: number; + throw?: Error; +}; + +function makeResponse(resp: Resp) { + const text = resp.text ?? (resp.json === undefined ? '' : JSON.stringify(resp.json)); + return { + ok: resp.status >= 200 && resp.status < 300, + status: resp.status, + statusText: 'x', + redirected: resp.redirected ?? false, + url: resp.url ?? '', + headers: { get: (n: string) => resp.headers?.[n] ?? null }, + json: async () => (resp.json !== undefined ? resp.json : JSON.parse(text)), + text: async () => text, + arrayBuffer: async () => new ArrayBuffer(0), + }; +} + +function mockFetch(responses: Resp[]) { + let i = 0; + const fn = vi.fn(async (_url: string, init?: RequestInit) => { + const resp = responses[i++] ?? responses[responses.length - 1]; + if (resp.throw) throw resp.throw; + if (resp.delayMs) { + await new Promise((resolve, reject) => { + const t = setTimeout(resolve, resp.delayMs); + init?.signal?.addEventListener('abort', () => { + clearTimeout(t); + const e = new Error('Aborted'); + e.name = 'AbortError'; + reject(e); + }); + }); + } + return makeResponse(resp); + }); + global.fetch = fn as unknown as typeof fetch; + return fn; +} + +async function connected(): Promise { + mockFetch([{ status: 200, json: SESSION }]); + const client = new JMAPClient(); + await client.connect('https://mail.example.com', 'user', 'pass'); + return client; +} + +describe('pure helpers', () => { + it('rewriteSessionUrl rebases relative and foreign-origin URLs, keeps templates', () => { + const origin = 'https://mail.example.com'; + expect(rewriteSessionUrl('/jmap/', origin)).toBe('https://mail.example.com/jmap/'); + expect(rewriteSessionUrl('jmap/', origin)).toBe('https://mail.example.com/jmap/'); + expect(rewriteSessionUrl('http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}', origin)) + .toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}'); + expect(rewriteSessionUrl('https://mail.example.com/x', origin)).toBe('https://mail.example.com/x'); + expect(rewriteSessionUrl(undefined, origin)).toBeUndefined(); + }); + + it('parseRetryAfter handles seconds, HTTP-date, garbage', () => { + expect(parseRetryAfter('10')).toBe(10_000); + expect(parseRetryAfter('9999')).toBe(300_000); + const future = new Date(Date.now() + 30_000).toUTCString(); + const ms = parseRetryAfter(future); + expect(ms).toBeGreaterThan(25_000); + expect(ms).toBeLessThanOrEqual(31_000); + expect(parseRetryAfter('bogus')).toBe(60_000); + expect(parseRetryAfter(null)).toBe(60_000); + }); + + it('isConcurrentRequestRefusal matches only the limit error', () => { + expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' }))).toBe(true); + expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxSizeRequest' }))).toBe(false); + expect(isConcurrentRequestRefusal(500, 'nope')).toBe(false); + }); + + it('requireMethodResult throws JMAPMethodError on the error envelope', () => { + const res = { methodResponses: [['error', { type: 'forbidden', description: 'no' }, '0']] as Array<[string, Record, string]> }; + expect(() => requireMethodResult(res, '0')).toThrow(JMAPMethodError); + expect(() => requireMethodResult(res)).toThrow('no'); + const ok = { methodResponses: [['Mailbox/get', { list: [1] }, 'a']] as Array<[string, Record, string]> }; + expect(requireMethodResult(ok, 'a', 'Mailbox/get')).toEqual({ list: [1] }); + expect(() => requireMethodResult(ok, 'a', 'Email/get')).toThrow('Expected Email/get'); + expect(() => requireMethodResult(ok, 'zz')).toThrow('Missing JMAP response'); + }); + + it('assertSetResult throws for notUpdated / notDestroyed / notCreated', () => { + expect(() => assertSetResult({ updated: { a: null } }, ['a'])).not.toThrow(); + expect(() => assertSetResult({ notUpdated: { a: { type: 'forbidden' } } }, ['a'])).toThrow('update'); + expect(() => assertSetResult({ notUpdated: { b: { type: 'forbidden' } } }, ['a'])).not.toThrow(); + expect(() => assertSetResult({ notDestroyed: { a: { type: 'notFound', description: 'gone' } } })).toThrow('gone'); + expect(() => assertSetResult({ notCreated: { x: { type: 'invalidProperties', properties: ['uid'] } } }, undefined, 'contact')).toThrow('properties=[uid]'); + }); + + it('batched splits lists', () => { + expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(batched([], 2)).toEqual([]); + }); +}); + +describe('FirstTouchGate', () => { + it('serialises the first calendar/contacts request per account', async () => { + const gate = new FirstTouchGate(); + const order: string[] = []; + let release!: () => void; + const first = gate.run([['Calendar/get', { accountId: 'a' }, '0']], () => new Promise((resolve) => { + release = () => { order.push('first'); resolve('first'); }; + })); + const second = gate.run([['CalendarEvent/query', { accountId: 'a' }, '0']], async () => { order.push('second'); return 'second'; }); + const other = gate.run([['Mailbox/get', { accountId: 'a' }, '0']], async () => { order.push('mail'); return 'mail'; }); + await other; + expect(order).toEqual(['mail']); + release(); + await Promise.all([first, second]); + expect(order).toEqual(['mail', 'first', 'second']); + expect(gateKeysFor([['AddressBook/get', { accountId: 'b' }, '0'], ['Email/get', { accountId: 'b' }, '1']])).toEqual(['contacts:b']); + }); +}); + +describe('JMAPClient hardening', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockRefresh.mockReset(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves relative session URLs against the login origin', async () => { + mockFetch([{ status: 200, json: { ...SESSION, apiUrl: '/jmap/', downloadUrl: '/download/{accountId}/{blobId}/{name}?type={type}' } }]); + const client = new JMAPClient(); + const session = await client.connect('https://mail.example.com', 'user', 'pass'); + expect(session.apiUrl).toBe('https://mail.example.com/jmap/'); + expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream'); + }); + + it('refetches the final URL when a redirect dropped the Authorization header', async () => { + const fetch = mockFetch([ + { status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, redirected: true, url: 'https://mail.example.com/jmap/session' }, + { status: 200, json: SESSION }, + ]); + const client = new JMAPClient(); + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session'); + expect(client.accountId).toBe('acc-1'); + }); + + it('throws TotpRequiredError on a 402 MFA challenge', async () => { + mockFetch([{ status: 402, json: { title: 'MFA code required' } }]); + const client = new JMAPClient(); + const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e); + expect(err).toBeInstanceOf(TotpRequiredError); + expect(err).toBeInstanceOf(AuthenticationError); + }); + + it('times out a stalled request with RequestTimeoutError and does not retry it', async () => { + vi.useFakeTimers(); + const client = await connected(); + const fetch = mockFetch([{ status: 200, json: { methodResponses: [] }, delayMs: 60_000 }]); + const p = client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]); + const settled = p.catch((e) => e); + await vi.advanceTimersByTimeAsync(31_000); + const err = await settled; + expect(err).toBeInstanceOf(RequestTimeoutError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('retries an idempotent request once after a transient network error', async () => { + vi.useFakeTimers(); + const client = await connected(); + const fetch = mockFetch([ + { status: 200, throw: new TypeError('Network request failed') }, + { status: 200, json: { methodResponses: [['Mailbox/get', { list: [] }, '0']] } }, + ]); + const p = client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']]); + await vi.advanceTimersByTimeAsync(1100); + const res = await p; + expect(res.methodResponses[0][0]).toBe('Mailbox/get'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not retry a submission after a network error and wraps it as NetworkError', async () => { + const client = await connected(); + const fetch = mockFetch([{ status: 200, throw: new TypeError('Network request failed') }]); + const err = await client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]).catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('replays a maxConcurrentRequests refusal with back-off', async () => { + vi.useFakeTimers(); + const client = await connected(); + const refusal = { type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' }; + const fetch = mockFetch([ + { status: 400, json: refusal }, + { status: 400, json: refusal }, + { status: 200, json: { methodResponses: [['Email/set', { updated: {} }, '0']] } }, + ]); + const p = client.request([['Email/set', { accountId: 'acc-1' }, '0']]); + await vi.advanceTimersByTimeAsync(3000); + await p; + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('surfaces the response body on a failed request and guards JSON parsing', async () => { + const client = await connected(); + mockFetch([{ status: 500, text: 'boom happened' }]); + await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('JMAP request failed: 500 - boom happened'); + mockFetch([{ status: 200, text: 'not json' }]); + await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('Invalid JSON'); + }); + + it('enters a rate-limit window on 429 (HTTP-date) and short-circuits until it ends', async () => { + const client = await connected(); + const onRateLimit = vi.fn(); + client.onRateLimit(onRateLimit); + const fetch = mockFetch([{ status: 429, headers: { 'Retry-After': new Date(Date.now() + 20_000).toUTCString() } }]); + const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e); + expect(err).toBeInstanceOf(RateLimitError); + expect(Number.isFinite((err as RateLimitError).retryAfterMs)).toBe(true); + expect((err as RateLimitError).retryAfterMs).toBeGreaterThan(10_000); + expect(onRateLimit).toHaveBeenCalledTimes(1); + expect(client.isRateLimited()).toBe(true); + const err2 = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e); + expect(err2).toBeInstanceOf(RateLimitError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('notifies auth-failure listeners on a 401 during a request', async () => { + const client = await connected(); + const onAuthFailure = vi.fn(); + client.onAuthFailure(onAuthFailure); + mockFetch([{ status: 401 }]); + await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow(AuthenticationError); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + + it('keeps the account when the token endpoint is transiently down', async () => { + const client = new JMAPClient(); + mockFetch([{ status: 200, json: SESSION }]); + await client.connectWithOAuth('https://mail.example.com', { + accessToken: 'old', + refreshToken: 'r', + expiresAt: Date.now() + 3_600_000, + tokenEndpoint: 'https://mail.example.com/auth/token', + clientId: 'c', + }); + mockRefresh.mockRejectedValue(new TransientRefreshError('503')); + mockFetch([{ status: 401 }]); + const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + expect(err).not.toBeInstanceOf(AuthenticationError); + }); + + it('fires token-refresh listeners and retries with the new bearer after a 401', async () => { + const client = new JMAPClient(); + mockFetch([{ status: 200, json: SESSION }]); + await client.connectWithOAuth('https://mail.example.com', { + accessToken: 'old', + refreshToken: 'r', + expiresAt: Date.now() + 3_600_000, + tokenEndpoint: 'https://mail.example.com/auth/token', + clientId: 'c', + }); + const onRefresh = vi.fn(); + client.onTokenRefresh(onRefresh); + mockRefresh.mockResolvedValue({ + accessToken: 'new', + refreshToken: 'r2', + expiresAt: Date.now() + 3_600_000, + tokenEndpoint: 'https://mail.example.com/auth/token', + clientId: 'c', + }); + const fetch = mockFetch([ + { status: 401 }, + { status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } }, + ]); + await client.request([['Mailbox/get', {}, '0']]); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer new' }); + expect(client.authHeader).toBe('Bearer new'); + }); + + it('updatePassword rewrites the stored credential', async () => { + const SecureStore = await import('expo-secure-store'); + const client = await connected(); + (SecureStore.setItemAsync as ReturnType).mockClear(); + await client.updatePassword('new-pass'); + expect(client.authHeader).toBe(`Basic ${btoa('user:new-pass')}`); + expect(SecureStore.setItemAsync).toHaveBeenCalledTimes(1); + const saved = JSON.parse((SecureStore.setItemAsync as ReturnType).mock.calls[0][1] as string); + expect(saved.password).toBe('new-pass'); + }); + + it('exposes account-level capabilities and set/upload limits', async () => { + const client = await connected(); + expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true); + expect(client.hasCapability('urn:stalwart:jmap')).toBe(false); + expect(client.getMaxObjectsInSet()).toBe(50); + expect(client.getMaxSizeUpload()).toBe(1024); + }); + + it('snapshot/restoreSnapshot round-trips the live connection', async () => { + const client = await connected(); + const snap = client.snapshot(); + client.reset(); + expect(client.isConnected).toBe(false); + client.restoreSnapshot(snap); + expect(client.isConnected).toBe(true); + expect(client.accountId).toBe('acc-1'); + }); +}); diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index c1784b9b..085232ac 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -42,6 +42,7 @@ function mockFetch(responses: Array<{ status: number; json?: any; headers?: Reco get: (name: string) => resp.headers?.[name] ?? null, }, json: async () => resp.json, + text: async () => (resp.json === undefined ? '' : JSON.stringify(resp.json)), }; }); } diff --git a/src/api/first-touch-gate.ts b/src/api/first-touch-gate.ts new file mode 100644 index 00000000..0385073b --- /dev/null +++ b/src/api/first-touch-gate.ts @@ -0,0 +1,101 @@ +/** + * Serialises the FIRST request that touches a lazily-initialised server + * collection, per account. Port of the webmail's lib/jmap/first-touch-gate.ts. + * + * Stalwart creates an account's default calendar (and default address book) on + * the first access to an account that has none. On a single node that creation + * is guarded by the node's in-memory resource cache, but in clustered / multi- + * node deployments each node builds its own cache, so concurrent first-touch + * requests that land on different nodes each see "no calendars" and each create + * a default - leaving the user with two identical "Personal" calendars, one of + * which is flagged `isDefault` and can't be removed from the UI (#907). + * + * The app's login fans out `Calendar/get`, `CalendarEvent/query` (and + * `AddressBook/get`, `ContactCard/query`) within a few milliseconds of each + * other, which makes that race trivially winnable. This gate holds every later + * request on a (collection, account) pair until the first one has settled - + * success or failure - after which requests flow with no further coordination. + * Method calls outside the gated collections are never delayed. + */ + +type MethodCall = [string, Record, string]; + +export type GatedCollection = 'calendar' | 'contacts'; + +const COLLECTION_PREFIXES: ReadonlyArray = [ + ['calendar', 'Calendar/'], + ['calendar', 'CalendarEvent/'], + ['calendar', 'CalendarEventNotification/'], + ['contacts', 'AddressBook/'], + ['contacts', 'ContactCard/'], +]; + +function collectionOf(method: string): GatedCollection | null { + for (const [collection, prefix] of COLLECTION_PREFIXES) { + if (method.startsWith(prefix)) return collection; + } + return null; +} + +/** + * The `collection:accountId` keys a request touches. Calls without a string + * accountId are skipped - they'd be rejected by the server anyway. + */ +export function gateKeysFor(methodCalls: ReadonlyArray): string[] { + const keys = new Set(); + for (const call of methodCalls) { + const collection = collectionOf(call[0]); + const accountId = call[1]?.accountId; + if (collection && typeof accountId === 'string' && accountId) { + keys.add(`${collection}:${accountId}`); + } + } + return Array.from(keys); +} + +export class FirstTouchGate { + private settled = new Set(); + private inFlight = new Map>(); + + /** + * Run `send` for `methodCalls`, delaying it while another request is the + * first to touch one of the collections it uses. Resolves/rejects with + * whatever `send` does. + */ + async run(methodCalls: ReadonlyArray, send: () => Promise): Promise { + const pending = gateKeysFor(methodCalls).filter((key) => !this.settled.has(key)); + if (pending.length === 0) return send(); + + const waits = pending + .map((key) => this.inFlight.get(key)) + .filter((p): p is Promise => p !== undefined); + if (waits.length > 0) { + await Promise.all(waits); + // Those keys are settled now; anything still pending belongs to another + // collection/account this request happens to be first on. + return this.run(methodCalls, send); + } + + // First touch for every pending key: run it and let the others queue. + const result = send(); + const done = result.then( + () => undefined, + () => undefined, + ); + for (const key of pending) this.inFlight.set(key, done); + try { + return await result; + } finally { + for (const key of pending) { + this.settled.add(key); + this.inFlight.delete(key); + } + } + } + + /** Forget everything - e.g. after reconnecting as a different account. */ + reset(): void { + this.settled.clear(); + this.inFlight.clear(); + } +} diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 8ee01333..b854b9af 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -8,12 +8,32 @@ import type { import { CAPABILITIES } from './types'; import { generateAccountId } from '../lib/account-utils'; import { secureFetch } from '../lib/client-cert'; -import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth'; +import { + refreshOAuthAccessToken, + TransientRefreshError, + type OAuthTokens, +} from '../lib/oauth'; +import { FirstTouchGate } from './first-touch-gate'; // Refresh OAuth access tokens this many ms before they actually expire so // in-flight requests don't race the expiry window. const TOKEN_REFRESH_LEEWAY_MS = 60_000; +// Deadline on response headers for an ordinary JMAP call, and for blob +// transfers (uploads/downloads can legitimately take minutes on cellular). +// Mirrors the webmail's REQUEST_TIMEOUT_MS / blob timeout (#702): iOS suspends +// sockets in the background and hands back dead pooled connections on resume; +// without a deadline a send or save hangs forever. +export const REQUEST_TIMEOUT_MS = 30_000; +export const BLOB_TIMEOUT_MS = 300_000; + +// Base back-off per attempt when the server refuses a request for being one +// too many in parallel (maxConcurrentRequests, #780). +const CONCURRENT_REQUEST_RETRY_DELAYS_MS = [200, 400, 800]; + +// Delay before the single transient-network retry of an idempotent request. +const TRANSIENT_RETRY_DELAY_MS = 1000; + const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const CREDENTIALS_PREFIX = 'jmap_credentials__'; @@ -36,10 +56,152 @@ export interface StoredCredentials { clientId?: string; } +/** + * RFC 8620 §3.6.1 limit error naming the server's parallel-request ceiling. + * Stalwart refuses the surplus of a burst with 400 jmap:error:limit / + * maxConcurrentRequests BEFORE any method runs, so the request is safe to + * replay - even a /set. + */ +export function isConcurrentRequestRefusal(status: number, body: string): boolean { + if (status !== 400) return false; + try { + const parsed = JSON.parse(body) as { type?: unknown; limit?: unknown } | null; + return ( + parsed?.type === 'urn:ietf:params:jmap:error:limit' && + parsed?.limit === 'maxConcurrentRequests' + ); + } catch { + return false; + } +} + +/** + * Seconds or HTTP-date `Retry-After`, capped at 5 minutes, defaulting to 60 s + * when missing or unparseable (an HTTP-date used to turn into `NaN` ms). + */ +export function parseRetryAfter(header: string | null): number { + if (!header) return 60_000; + const seconds = Number(header); + if (!Number.isNaN(seconds) && seconds > 0) { + return Math.min(seconds * 1000, 300_000); + } + const date = Date.parse(header); + if (!Number.isNaN(date)) { + const ms = date - Date.now(); + return ms > 0 ? Math.min(ms, 300_000) : 60_000; + } + return 60_000; +} + +// Methods whose bodies must never be replayed automatically: a retried +// EmailSubmission/set would send the mail twice. +function hasNonIdempotentMethod(methodCalls: ReadonlyArray): boolean { + return methodCalls.some( + ([name]) => + name === 'EmailSubmission/set' || + name === 'Email/import' || + name === 'Blob/upload', + ); +} + +function fetchSessionDescribes(session: unknown): session is JMAPSession { + if (!session || typeof session !== 'object') return false; + const s = session as Partial; + return typeof s.apiUrl === 'string'; +} + +// ── Shared response helpers ────────────────────────────── +// Most RN api functions used to index `methodResponses[0][1]` blindly, so a +// method-level `['error', {...}]` reply produced a TypeError on reads and was +// silently treated as success on writes. Route every response through these. + +type MethodResponse = [string, Record, string]; + +export class JMAPMethodError extends Error { + type: string; + constructor(type: string, description?: string) { + super(description || type || 'JMAP method error'); + this.name = 'JMAPMethodError'; + this.type = type; + } +} + +/** + * Pull the response body for `callId` (or the first response when omitted) + * and throw a `JMAPMethodError` when the server answered with the error + * envelope. Also verifies the method name when `expectMethod` is given. + */ +export function requireMethodResult>( + res: JMAPResponseBody, + callId?: string, + expectMethod?: string, +): T { + const responses = (res.methodResponses ?? []) as MethodResponse[]; + const match = callId === undefined + ? responses[0] + : responses.find((r) => r[2] === callId); + if (!match) { + throw new JMAPMethodError('missingResponse', `Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`); + } + if (match[0] === 'error') { + const err = match[1] as { type?: string; description?: string }; + throw new JMAPMethodError(err.type ?? 'serverFail', err.description); + } + if (expectMethod && match[0] !== expectMethod) { + throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`); + } + return match[1] as T; +} + +/** + * Throw when a /set response reports the given ids (or any id when `ids` is + * omitted) under `notCreated` / `notUpdated` / `notDestroyed`. + */ +export function assertSetResult( + body: Record, + ids?: string[], + what = 'object', +): void { + const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const; + for (const bucket of buckets) { + const failures = body?.[bucket] as + | Record + | undefined; + if (!failures) continue; + const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id)); + if (entries.length === 0) continue; + const [id, err] = entries[0]; + const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy'; + const detail = [err?.type ?? 'unknown']; + if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`); + if (err?.description) detail.push(err.description); + const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : ''; + throw new JMAPMethodError( + err?.type ?? 'setError', + `Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`, + ); + } +} + +/** Split `items` into consecutive batches of at most `size` entries. */ +export function batched(items: T[], size: number): T[][] { + const step = Math.max(1, Math.floor(size)); + const result: T[][] = []; + for (let i = 0; i < items.length; i += step) { + result.push(items.slice(i, i + step)); + } + return result; +} + export class JMAPClient { private session: JMAPSession | null = null; private credentials: StoredCredentials | null = null; private _accountId: string | null = null; + private firstTouchGate = new FirstTouchGate(); + private rateLimitedUntil = 0; + private authFailureListeners = new Set<(err: AuthenticationError) => void>(); + private rateLimitListeners = new Set<(retryAfterMs: number) => void>(); + private tokenRefreshListeners = new Set<() => void>(); get accountId(): string { if (!this._accountId) { @@ -71,6 +233,48 @@ export class JMAPClient { return this.session !== null && this._accountId !== null; } + // ── Lifecycle hooks ─────────────────────────────────── + // The auth store subscribes to auth failures so a revoked token/password + // leads back to the login screen instead of a dead session; the offline + // banner subscribes to rate-limit windows; the live-update layer recreates + // the SSE stream whenever the bearer token rotates. + + onAuthFailure(listener: (err: AuthenticationError) => void): () => void { + this.authFailureListeners.add(listener); + return () => this.authFailureListeners.delete(listener); + } + + onRateLimit(listener: (retryAfterMs: number) => void): () => void { + this.rateLimitListeners.add(listener); + return () => this.rateLimitListeners.delete(listener); + } + + onTokenRefresh(listener: () => void): () => void { + this.tokenRefreshListeners.add(listener); + return () => this.tokenRefreshListeners.delete(listener); + } + + private notifyAuthFailure(err: AuthenticationError): void { + for (const l of this.authFailureListeners) { + try { l(err); } catch { /* listener errors must not mask the request */ } + } + } + + isRateLimited(): boolean { + return this.rateLimitedUntil > Date.now(); + } + + rateLimitRemainingMs(): number { + return Math.max(0, this.rateLimitedUntil - Date.now()); + } + + private setRateLimited(retryAfterMs: number): void { + this.rateLimitedUntil = Date.now() + retryAfterMs; + for (const l of this.rateLimitListeners) { + try { l(retryAfterMs); } catch { /* ignore */ } + } + } + // ── Authentication ──────────────────────────────────── // Public so blob upload/download helpers (which can't go through the JMAP @@ -85,13 +289,28 @@ export class JMAPClient { return `Basic ${encoded}`; } - async connect(serverUrl: string, username: string, password: string): Promise { - // Normalise URL + /** + * Password login. When the server answers 402 "MFA code required" and no + * `totp` was supplied, throws `TotpRequiredError` so the UI can ask for a + * code. With a code, Stalwart 0.16+ no longer accepts the legacy + * `password$totp` Basic convention, so the structured login endpoint is used + * to obtain OAuth tokens (see `connectWithTotp`). + */ + async connect( + serverUrl: string, + username: string, + password: string, + totp?: string, + ): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); + if (totp) { + return this.connectWithTotp(baseUrl, username, password, totp); + } this.credentials = { serverUrl: baseUrl, username, password }; this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this._accountId = this.resolveAccountId(this.session); + this.firstTouchGate.reset(); const accountId = generateAccountId(username, baseUrl); await SecureStore.setItemAsync( @@ -102,12 +321,41 @@ export class JMAPClient { return this.session; } + /** + * TOTP login through Stalwart's structured auth endpoint (`/api/auth` with a + * separate `mfaToken`, then `/auth/token` with PKCE). The resulting bundle is + * persisted as an OAuth account so the normal refresh path keeps it alive. + * Falls back to the legacy `password$totp` Basic convention when the server + * predates the structured endpoint (404). + */ + private async connectWithTotp( + baseUrl: string, + username: string, + password: string, + totp: string, + ): Promise { + const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login'); + let tokens: OAuthTokens; + try { + tokens = await exchangePasswordForTokens(baseUrl, username, password, totp); + } catch (err) { + if (err instanceof LoginEndpointMissingError) { + // Pre-0.16 Stalwart: TOTP rides along in the Basic password. + return this.connect(baseUrl, username, `${password}$${totp}`); + } + throw err; + } + const { session } = await this.connectWithOAuth(baseUrl, tokens, username); + return session; + } + async connectWithToken(serverUrl: string, accessToken: string): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken }; this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this._accountId = this.resolveAccountId(this.session); + this.firstTouchGate.reset(); return this.session; } @@ -115,9 +363,12 @@ export class JMAPClient { // OAuth login via webmail handoff. The webmail did the OAuth dance against // Stalwart and handed us a complete token bundle. We persist the bundle // so subsequent launches can refresh without prompting the user again. + // `preferredUsername` (the address the user typed) wins over the session's + // `username`, which for OIDC logins may be a bare `preferred_username`. async connectWithOAuth( serverUrl: string, tokens: OAuthTokens, + preferredUsername?: string, ): Promise<{ session: JMAPSession; username: string; accountId: string }> { const baseUrl = serverUrl.replace(/\/+$/, ''); this.credentials = { @@ -133,11 +384,13 @@ export class JMAPClient { this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this._accountId = this.resolveAccountId(this.session); + this.firstTouchGate.reset(); // The JMAP session document carries the authenticated user's identifier // — use it as the username so per-account storage keys are stable across // restarts and OAuth re-logins. - const username = this.session.username || tokens.accessToken.slice(0, 8); + const username = + preferredUsername || this.session.username || tokens.accessToken.slice(0, 8); this.credentials.username = username; const accountId = generateAccountId(username, baseUrl); @@ -170,6 +423,9 @@ export class JMAPClient { credentialsKey(accountId), JSON.stringify(this.credentials), ); + for (const l of this.tokenRefreshListeners) { + try { l(); } catch { /* ignore */ } + } } private currentOAuthTokens(): OAuthTokens | null { @@ -191,8 +447,10 @@ export class JMAPClient { } // Proactive refresh: when the access token is about to expire, swap it for - // a fresh one. Quiet no-op for password credentials. - private async ensureFreshToken(): Promise { + // a fresh one. Quiet no-op for password credentials. Public so long-lived + // consumers of `authHeader` (SSE stream, file downloads) can make sure the + // header they capture is not about to expire. + async ensureFreshToken(): Promise { const tokens = this.currentOAuthTokens(); if (!tokens) return; if (tokens.expiresAt == null) return; @@ -207,19 +465,43 @@ export class JMAPClient { } // Reactive refresh after a 401. Returns true if a fresh token was obtained - // so the caller can retry the original request. - private async forceRefreshToken(): Promise { + // so the caller can retry the original request. A transient token-endpoint + // failure (offline, 5xx, 429) is rethrown as a NetworkError so callers keep + // the account instead of evicting it (webmail 1.7.6 "keep the session when + // the auth server is briefly unreachable"). + async forceRefreshToken(): Promise { const tokens = this.currentOAuthTokens(); if (!tokens) return false; try { const next = await refreshOAuthAccessToken(tokens); await this.persistRefreshedTokens(next); return true; - } catch { + } catch (err) { + if (err instanceof TransientRefreshError) { + throw new NetworkError(err.message); + } return false; } } + /** + * After a successful password change the stored credential must follow, + * otherwise the next request gets a 401 and the next launch evicts the + * account. No-op for bearer sessions. + */ + async updatePassword(newPassword: string): Promise { + if (!this.credentials || this.credentials.accessToken) return; + this.credentials = { ...this.credentials, password: newPassword }; + const accountId = generateAccountId( + this.credentials.username, + this.credentials.serverUrl, + ); + await SecureStore.setItemAsync( + credentialsKey(accountId), + JSON.stringify(this.credentials), + ); + } + // Legacy single-slot restore - kept for backward-compat tests. New code // should use loadAccount(accountId) driven by the account registry. async restoreSession(): Promise { @@ -234,6 +516,7 @@ export class JMAPClient { creds.serverUrl, ); this._accountId = this.resolveAccountId(this.session); + this.firstTouchGate.reset(); return true; } catch { await this.logout(); @@ -257,6 +540,7 @@ export class JMAPClient { // unrecoverable (AuthenticationError) from transient (NetworkError) and // avoid clearing credentials when the server is just unreachable. this.credentials = creds; + this.firstTouchGate.reset(); try { this.session = this.rewriteSessionUrls( await this.fetchSession(creds.serverUrl), @@ -269,6 +553,7 @@ export class JMAPClient { this.session = null; this._accountId = null; if (err instanceof AuthenticationError) throw err; + if (err instanceof NetworkError) throw err; // Anything else is treated as transport-level. Wrapping rather than // rethrowing the raw fetch error gives callers a single check. throw new NetworkError( @@ -279,6 +564,25 @@ export class JMAPClient { return true; } + /** + * Snapshot of the live connection so a failed "add account" / switch can put + * the previous account back without a network round-trip. + */ + snapshot(): ClientSnapshot { + return { + session: this.session, + credentials: this.credentials, + accountId: this._accountId, + }; + } + + restoreSnapshot(snap: ClientSnapshot): void { + this.session = snap.session; + this.credentials = snap.credentials; + this._accountId = snap.accountId; + this.firstTouchGate.reset(); + } + async clearAccountCredentials(accountId: string): Promise { await SecureStore.deleteItemAsync(credentialsKey(accountId)); } @@ -311,20 +615,17 @@ export class JMAPClient { // Rewrite session URLs to share the origin the client connected with. // JMAP servers often self-report localhost/container-internal hostnames in // apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g. - // the Android emulator can't resolve the host's "localhost"). + // the Android emulator can't resolve the host's "localhost"), and some + // deployments advertise relative URLs (`/jmap/`) which `fetch` can't use. // // Splits via plain string indexing rather than `new URL()` because RN's // URL polyfill mutates inputs (appends trailing slashes, normalises // characters) and would corrupt the RFC 6570 templates {accountId}/{blobId} // before the caller has a chance to substitute values into them. private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession { - const serverOrigin = this.extractOrigin(serverUrl); - const rewrite = (url: string | undefined): string | undefined => { - if (!url) return url; - const origin = this.extractOrigin(url); - if (!origin || !serverOrigin || origin === serverOrigin) return url; - return serverOrigin + url.slice(origin.length); - }; + const serverOrigin = extractOrigin(serverUrl); + const rewrite = (url: string | undefined): string | undefined => + rewriteSessionUrl(url, serverOrigin); return { ...session, apiUrl: rewrite(session.apiUrl) ?? session.apiUrl, @@ -334,17 +635,14 @@ export class JMAPClient { }; } - private extractOrigin(url: string): string | null { - const m = url.match(/^(https?:\/\/[^/?#]+)/i); - return m ? m[1] : null; - } - // Reset in-memory state without touching persisted credentials (used on // account switch so the active account's stored creds remain available). reset(): void { this.session = null; this.credentials = null; this._accountId = null; + this.firstTouchGate.reset(); + this.rateLimitedUntil = 0; } async logout(): Promise { @@ -358,31 +656,153 @@ export class JMAPClient { } } + // ── Transport ───────────────────────────────────────── + + /** + * `secureFetch` with a deadline on the response headers. The abort reason is + * tracked separately so a caller-supplied signal (cancelled upload) can be + * told apart from "we gave up". + */ + private async timedFetch( + url: string, + init: RequestInit | undefined, + timeoutMs: number, + ): Promise { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const external = init?.signal ?? undefined; + if (controller && external) { + if (external.aborted) controller.abort(); + else external.addEventListener('abort', () => controller.abort(), { once: true }); + } + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller?.abort(); + }, timeoutMs); + try { + return await secureFetch(url, controller ? { ...init, signal: controller.signal } : init); + } catch (error) { + if (timedOut) throw new RequestTimeoutError(timeoutMs); + throw error; + } finally { + clearTimeout(timer); + } + } + + /** + * Authenticated fetch shared by the API, session and blob paths: proactive + * token refresh, deadline, one transient retry for idempotent requests, + * reactive refresh on 401, rate-limit bookkeeping on 429. Returns the + * response for the caller to interpret; throws `NetworkError` / + * `RequestTimeoutError` / `RateLimitError`. + */ + async authenticatedFetch( + url: string, + init?: RequestInit, + opts?: { timeoutMs?: number; idempotent?: boolean }, + ): Promise { + if (this.isRateLimited()) { + throw new RateLimitError(this.rateLimitRemainingMs()); + } + const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT_MS; + const idempotent = opts?.idempotent ?? true; + + await this.ensureFreshToken(); + const withAuth = (): RequestInit => ({ + ...init, + headers: { ...(init?.headers as Record | undefined), Authorization: this.authHeader }, + }); + + let response: Response; + try { + response = await this.timedFetch(url, withAuth(), timeoutMs); + } catch (error) { + if (error instanceof RequestTimeoutError) throw error; + if (!idempotent || (error as Error)?.name === 'AbortError') { + throw toNetworkError(error); + } + // Transient proxy/connection blip: one retry after a short pause. + await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS)); + try { + response = await this.timedFetch(url, withAuth(), timeoutMs); + } catch (retryError) { + if (retryError instanceof RequestTimeoutError) throw retryError; + throw toNetworkError(retryError); + } + } + + if (response.status === 401 && (await this.forceRefreshToken())) { + try { + response = await this.timedFetch(url, withAuth(), timeoutMs); + } catch (error) { + if (error instanceof RequestTimeoutError) throw error; + throw toNetworkError(error); + } + } + + if (response.status === 429) { + const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); + this.setRateLimited(retryAfterMs); + throw new RateLimitError(retryAfterMs); + } + + return response; + } + // ── Session Discovery ───────────────────────────────── private async fetchSession(baseUrl: string): Promise { const url = `${baseUrl}/.well-known/jmap`; - await this.ensureFreshToken(); - const doFetch = () => - secureFetch(url, { - headers: { - Authorization: this.authHeader, - Accept: 'application/json', - }, - }); - let response = await doFetch(); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); - } + let response = await this.authenticatedFetch(url, { + headers: { Accept: 'application/json' }, + }); if (response.status === 401) { throw new AuthenticationError('Invalid credentials'); } + if (response.status === 402) { + // Stalwart answers 402 with a problem+json whose title names the + // missing factor ("TOTP code required" / "MFA code required"). + let title = ''; + try { + const body = (await response.json()) as { title?: string; detail?: string }; + title = `${body?.title ?? ''} ${body?.detail ?? ''}`.toLowerCase(); + } catch { + // fall through - treat any 402 as an MFA challenge + } + if (!title || title.includes('totp') || title.includes('mfa') || title.includes('factor')) { + throw new TotpRequiredError(); + } + } if (!response.ok) { throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`); } - return response.json(); + let session = (await response.json()) as JMAPSession; + + // Stalwart 307-redirects /.well-known/jmap to /jmap/session. iOS + // NSURLSession (and some auth proxies) drop the Authorization header on + // the redirect, which yields a 200 with an empty session. Refetch the + // final URL with the header explicitly. + const redirectedEmpty = + (response as { redirected?: boolean }).redirected && + (!session?.accounts || Object.keys(session.accounts).length === 0) && + !session?.username; + if (redirectedEmpty && (response as { url?: string }).url) { + response = await this.authenticatedFetch((response as { url: string }).url, { + headers: { Accept: 'application/json' }, + }); + if (response.status === 401) throw new AuthenticationError('Invalid credentials'); + if (!response.ok) { + throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`); + } + session = (await response.json()) as JMAPSession; + } + + if (!fetchSessionDescribes(session)) { + throw new Error('Session discovery failed: response is not a JMAP session'); + } + return session; } private resolveAccountId(session: JMAPSession): string { @@ -400,6 +820,34 @@ export class JMAPClient { throw new Error('No account found in JMAP session'); } + /** + * Re-read the session document (capabilities, shared accounts, + * eventSourceUrl) without disturbing credentials. Used by the keep-alive + * and after a basic-auth 401 that may just be a stale session. + */ + async refreshSession(): Promise { + if (!this.credentials) throw new Error('No credentials'); + const baseUrl = this.credentials.serverUrl; + this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); + this._accountId = this.resolveAccountId(this.session); + return this.session; + } + + /** + * Cheap liveness probe (`Core/echo`). Resolves true when the server answered, + * false on transport failure. Used by the foreground keep-alive. + */ + async ping(): Promise { + if (!this.session) return false; + try { + await this.request([['Core/echo', { ping: Date.now() }, 'ping']], [CAPABILITIES.CORE]); + return true; + } catch (err) { + if (err instanceof AuthenticationError) throw err; + return false; + } + } + // ── API Request ─────────────────────────────────────── async request( @@ -412,38 +860,54 @@ export class JMAPClient { using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls, }; + const serialized = JSON.stringify(body); + const apiUrl = this.session.apiUrl; + const idempotent = !hasNonIdempotentMethod(methodCalls); - await this.ensureFreshToken(); - const doFetch = () => - secureFetch(this.session!.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - }, - body: JSON.stringify(body), - }); + for (let attempt = 0; ; attempt++) { + const response = await this.firstTouchGate.run(methodCalls, () => + this.authenticatedFetch( + apiUrl, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: serialized, + }, + { idempotent }, + ), + ); - let response = await doFetch(); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); + if (response.status === 401) { + const err = new AuthenticationError('Session expired'); + this.notifyAuthFailure(err); + throw err; + } + + const responseText = await readBodyText(response); + + if (!response.ok) { + // Stalwart caps parallel API requests per user (maxConcurrentRequests, + // default 4). One push event fans out into several refreshes at once, + // so the ceiling is reached in ordinary use. The refusal comes before + // any method runs, so back off briefly and replay (#780). + if ( + attempt < CONCURRENT_REQUEST_RETRY_DELAYS_MS.length && + isConcurrentRequestRefusal(response.status, responseText) + ) { + const base = CONCURRENT_REQUEST_RETRY_DELAYS_MS[attempt]; + await new Promise((r) => setTimeout(r, base + Math.random() * base)); + continue; + } + const detail = responseText ? ` - ${responseText.substring(0, 200)}` : ''; + throw new Error(`JMAP request failed: ${response.status}${detail}`); + } + + try { + return JSON.parse(responseText) as JMAPResponseBody; + } catch { + throw new Error('Invalid JSON response from server'); + } } - - if (response.status === 401) { - throw new AuthenticationError('Session expired'); - } - - if (response.status === 429) { - const retryAfter = response.headers.get('Retry-After'); - const ms = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000; - throw new RateLimitError(ms); - } - - if (!response.ok) { - throw new Error(`JMAP request failed: ${response.status}`); - } - - return response.json(); } // ── Capability Check ────────────────────────────────── @@ -452,18 +916,53 @@ export class JMAPClient { return Boolean(this.session?.capabilities?.[urn]); } - getMaxObjectsInGet(): number { - const core = this.session?.capabilities?.[CAPABILITIES.CORE] as - | { maxObjectsInGet?: number } + /** + * True when `urn` is advertised for the account (RFC 8620 §2 + * `accountCapabilities`) or, as a fallback, at session level. Stalwart puts + * its extension capabilities (`urn:stalwart:jmap`, filenode, …) only under + * the account, so a session-level check alone is wrong for those. + */ + hasAccountCapability(urn: string, accountId?: string): boolean { + const id = accountId ?? this._accountId; + if (!id) return this.hasCapability(urn); + const account = this.session?.accounts?.[id]; + if (account?.accountCapabilities && urn in account.accountCapabilities) return true; + return this.hasCapability(urn); + } + + private get coreCapability(): + | { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number } + | undefined { + return this.session?.capabilities?.[CAPABILITIES.CORE] as + | { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number } | undefined; - return core?.maxObjectsInGet || 500; + } + + getMaxObjectsInGet(): number { + return this.coreCapability?.maxObjectsInGet || 500; + } + + getMaxObjectsInSet(): number { + return this.coreCapability?.maxObjectsInSet || 500; } getMaxCallsInRequest(): number { - const core = this.session?.capabilities?.[CAPABILITIES.CORE] as - | { maxCallsInRequest?: number } + return this.coreCapability?.maxCallsInRequest || 16; + } + + /** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */ + getMaxSizeUpload(): number { + const max = this.coreCapability?.maxSizeUpload; + return typeof max === 'number' && max > 0 ? max : 0; + } + + /** Per-message attachment total ceiling in bytes (0 = unknown). */ + getMaxSizeAttachmentsPerEmail(): number { + const mail = this.session?.capabilities?.[CAPABILITIES.MAIL] as + | { maxSizeAttachmentsPerEmail?: number } | undefined; - return core?.maxCallsInRequest || 16; + const max = mail?.maxSizeAttachmentsPerEmail; + return typeof max === 'number' && max > 0 ? max : 0; } // ── Shared / group accounts ─────────────────────────── @@ -496,6 +995,16 @@ export class JMAPClient { return this.session?.accounts?.[accountId]?.name; } + /** + * Primary account for a capability (RFC 8620 `primaryAccounts`), falling + * back to the mail primary. Contacts/calendars can live in a different + * account than mail on some servers. + */ + getPrimaryAccountId(capability: string): string { + const id = this.session?.primaryAccounts?.[capability]; + return id || this.accountId; + } + // ── Scheduled send (FUTURERELEASE) ──────────────────── // The JMAP submission capability advertises `maxDelayedSend` (max hold in // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what @@ -572,18 +1081,68 @@ export class JMAPClient { accountId?: string, ): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); - await this.ensureFreshToken(); - const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } }); - let response = await doFetch(); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); + const response = await this.authenticatedFetch(url, undefined, { + timeoutMs: BLOB_TIMEOUT_MS, + }); + if (response.status === 401) { + const err = new AuthenticationError('Session expired'); + this.notifyAuthFailure(err); + throw err; } - if (response.status === 401) throw new AuthenticationError('Session expired'); if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`); return response.arrayBuffer(); } } +export interface ClientSnapshot { + session: JMAPSession | null; + credentials: StoredCredentials | null; + accountId: string | null; +} + +// ── URL helpers ────────────────────────────────────────── + +export function extractOrigin(url: string): string | null { + const m = url.match(/^(https?:\/\/[^/?#]+)/i); + return m ? m[1] : null; +} + +/** + * Rebase a session URL onto the origin the client connected with. Relative + * URLs (`/jmap/`) get the origin prefixed; absolute ones have their origin + * swapped. Pure string work so `{accountId}`/`{blobId}` templates survive. + */ +export function rewriteSessionUrl( + url: string | undefined, + serverOrigin: string | null, +): string | undefined { + if (!url || !serverOrigin) return url; + const origin = extractOrigin(url); + if (!origin) { + if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // non-http scheme: leave it + return serverOrigin + (url.startsWith('/') ? url : '/' + url); + } + if (origin === serverOrigin) return url; + return serverOrigin + url.slice(origin.length); +} + +async function readBodyText(response: Response): Promise { + try { + if (typeof response.text === 'function') return await response.text(); + if (typeof response.json === 'function') return JSON.stringify(await response.json()); + } catch { + // fall through + } + return ''; +} + +function toNetworkError(error: unknown): NetworkError { + if (error instanceof NetworkError) return error; + return new NetworkError( + error instanceof Error && error.message ? error.message : 'Network request failed', + ); +} + // ── Error Classes ───────────────────────────────────────── export class AuthenticationError extends Error { @@ -593,6 +1152,14 @@ export class AuthenticationError extends Error { } } +// The server wants a second factor before it will hand out a session. +export class TotpRequiredError extends AuthenticationError { + constructor() { + super('TOTP_REQUIRED'); + this.name = 'TotpRequiredError'; + } +} + // Server unreachable / transient transport failure. Callers should keep // stored credentials and surface an offline state rather than logging out. export class NetworkError extends Error { @@ -602,6 +1169,15 @@ export class NetworkError extends Error { } } +// No response headers within the deadline. The request may have reached the +// server (a send may have gone out), so callers must not blindly retry. +export class RequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); + this.name = 'RequestTimeoutError'; + } +} + export class RateLimitError extends Error { retryAfterMs: number; constructor(retryAfterMs: number) { diff --git a/src/lib/network-error.ts b/src/lib/network-error.ts index 1364cde2..a961f502 100644 --- a/src/lib/network-error.ts +++ b/src/lib/network-error.ts @@ -1,19 +1,30 @@ // Classify an error thrown by a JMAP call as a *transient* connectivity -// failure (server unreachable, offline, session not yet up) versus a +// failure (server unreachable, offline, timed out, rate limited) versus a // *permanent* one (the server received the request and rejected it, e.g. a -// 4xx). The offline outbox uses this to decide whether to keep an operation -// queued for a later retry or to give up on it. +// method error or a 4xx). The offline outbox uses this to decide whether to +// keep an operation queued for a later retry or to give up on it. // -// We err on the side of "transient" only for signals we recognise as -// connectivity-related; anything else (including bare 4xx/5xx responses) is -// treated as permanent so a poison operation can't wedge the queue forever. +// Classification is by error class first. A bare `TypeError` used to count as +// transient because RN's fetch throws `TypeError("Network request failed")`, +// but the JMAP client now wraps transport failures in `NetworkError`; a +// TypeError reaching here is far more likely a programming error (e.g. an +// unchecked method-error response) and must not wedge the queue forever. +// `AuthenticationError` is terminal: the credentials are gone, retrying won't +// help until the user signs in again. import { useNetworkStore } from '../stores/network-store'; const TRANSIENT_NAMES = new Set([ - 'NetworkError', // jmap-client transport wrapper - 'AbortError', // request aborted (e.g. app backgrounded) - 'TypeError', // RN fetch throws TypeError("Network request failed") + 'NetworkError', // jmap-client transport wrapper + 'RequestTimeoutError', // no response headers within the deadline + 'RateLimitError', // 429 - retry after the window + 'AbortError', // request aborted (e.g. app backgrounded) +]); + +const TERMINAL_NAMES = new Set([ + 'AuthenticationError', + 'TotpRequiredError', + 'JMAPMethodError', ]); const TRANSIENT_MESSAGE_HINTS = [ @@ -21,19 +32,29 @@ const TRANSIENT_MESSAGE_HINTS = [ 'network error', 'failed to fetch', 'not connected', - 'session expired', // token refresh will happen on the next live request 'timeout', 'timed out', 'connection', ]; export function isTransientNetworkError(err: unknown): boolean { - // If the device itself reports offline, treat any failure as transient. + if (err instanceof Error && TERMINAL_NAMES.has(err.name)) return false; + + // If the device itself reports offline, treat any other failure as transient. if (!useNetworkStore.getState().online) return true; if (!(err instanceof Error)) return false; if (TRANSIENT_NAMES.has(err.name)) return true; + // A raw fetch TypeError only counts when its message says "network". const msg = err.message?.toLowerCase() ?? ''; + if (err.name === 'TypeError') { + return msg.includes('network') || msg.includes('failed to fetch') || msg.includes('securefetch failed'); + } return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint)); } + +/** True when the failure means the session/credentials are unusable. */ +export function isAuthError(err: unknown): boolean { + return err instanceof Error && (err.name === 'AuthenticationError' || err.name === 'TotpRequiredError'); +} diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts new file mode 100644 index 00000000..52fe0571 --- /dev/null +++ b/src/lib/totp-login.ts @@ -0,0 +1,166 @@ +// Password + TOTP login against Stalwart's structured auth endpoint. +// +// Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over +// HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None`, so a TOTP +// appended to the password is verified verbatim against the password hash and +// fails. The MFA token must be supplied as a distinct field through +// `POST {server}/api/auth`, which answers with a short-lived authorization +// code that `POST {server}/auth/token` exchanges (with PKCE) for OAuth +// tokens. The webmail drives the same two steps server-side in +// app/api/auth/totp-token-exchange/route.ts; a native client has no CORS +// constraint and talks to the mail server directly. +// +// Token auth also survives TOTP rotation, unlike Basic auth, which would have +// to embed the ~30 s code in every request. + +import { secureFetch } from './client-cert'; +import { sha256Hex } from './sha256'; +import { base64UrlEncode, randomBytes } from './random'; +import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; +import { NetworkError } from '../api/jmap-client'; + +// Stalwart's built-in OAuth accepts the webmail's public client id; using the +// same one keeps refresh behaviour identical to the browser-handoff bundle. +export const DEFAULT_CLIENT_ID = 'bulwark-webmail'; + +export class LoginEndpointMissingError extends Error { + constructor() { + super('Structured login endpoint not available'); + this.name = 'LoginEndpointMissingError'; + } +} + +export class TotpLoginError extends Error { + code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed'; + constructor(code: TotpLoginError['code'], message?: string) { + super(message ?? code); + this.name = 'TotpLoginError'; + this.code = code; + } +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export function generateCodeVerifier(): string { + // 32 random bytes → 43 base64url chars, inside RFC 7636's 43..128 window. + return base64UrlEncode(randomBytes(32)); +} + +export function generateCodeChallenge(verifier: string): string { + const digest = sha256Hex(new TextEncoder().encode(verifier)); + return base64UrlEncode(hexToBytes(digest)); +} + +interface LoginResult { + type?: string; + // The response keeps snake_case: only the LoginResponse variant *tags* are + // camelCased server-side, not the struct fields (the request fields are). + client_code?: string; +} + +/** + * Exchange username + password (+ TOTP) for an OAuth token bundle. Throws + * `LoginEndpointMissingError` when the server predates the structured + * endpoint (404) so the caller can fall back to legacy Basic auth, + * `TotpLoginError` for rejected credentials, `NetworkError` when unreachable. + */ +export async function exchangePasswordForTokens( + serverUrl: string, + username: string, + password: string, + totp?: string, + opts?: { clientId?: string; redirectUri?: string }, +): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID; + const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI; + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + + let loginResponse: Response; + try { + loginResponse = await secureFetch(`${base}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + type: 'authCode', + accountName: username, + accountSecret: password, + ...(totp ? { mfaToken: totp } : {}), + clientId, + redirectUri, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }), + }); + } catch (err) { + throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); + } + + if (loginResponse.status === 404) throw new LoginEndpointMissingError(); + if (!loginResponse.ok) { + throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`); + } + + let login: LoginResult; + try { + login = (await loginResponse.json()) as LoginResult; + } catch { + throw new TotpLoginError('login_failed', 'Login response was not JSON'); + } + + switch (login.type) { + case 'authenticated': + break; + case 'mfaRequired': + throw new TotpLoginError('totp_required', 'TOTP_REQUIRED'); + case 'failure': + default: + throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code'); + } + if (!login.client_code) { + throw new TotpLoginError('login_failed', 'Login response missing authorization code'); + } + + const tokenParams = new URLSearchParams({ + grant_type: 'authorization_code', + code: login.client_code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + + let tokenResponse: Response; + try { + tokenResponse = await secureFetch(`${base}/auth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: tokenParams.toString(), + }); + } catch (err) { + throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); + } + if (!tokenResponse.ok) { + throw new TotpLoginError('token_exchange_failed', `Token exchange failed: ${tokenResponse.status}`); + } + const tokens = (await tokenResponse.json()) as { + access_token?: string; + expires_in?: number; + refresh_token?: string; + }; + if (!tokens.access_token) { + throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token'); + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, + tokenEndpoint: `${base}/auth/token`, + clientId, + }; +}