fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907)

This commit is contained in:
Linus Rath
2026-08-29 17:42:32 +02:00
parent 2c0dbd15d1
commit 0b1c240802
6 changed files with 1328 additions and 88 deletions
@@ -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<typeof import('../../lib/oauth')>();
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<string, string>;
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<void>((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<JMAPClient> {
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, any>, 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, any>, 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<string>((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: '<html>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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
+1
View File
@@ -42,6 +42,7 @@ function mockFetch(responses: Array<{ status: number; json?: any; headers?: Reco
get: (name: string) => resp.headers?.[name] ?? null, get: (name: string) => resp.headers?.[name] ?? null,
}, },
json: async () => resp.json, json: async () => resp.json,
text: async () => (resp.json === undefined ? '' : JSON.stringify(resp.json)),
}; };
}); });
} }
+101
View File
@@ -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, unknown>, string];
export type GatedCollection = 'calendar' | 'contacts';
const COLLECTION_PREFIXES: ReadonlyArray<readonly [GatedCollection, string]> = [
['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<MethodCall>): string[] {
const keys = new Set<string>();
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<string>();
private inFlight = new Map<string, Promise<void>>();
/**
* 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<T>(methodCalls: ReadonlyArray<MethodCall>, send: () => Promise<T>): Promise<T> {
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<void> => 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();
}
}
+653 -77
View File
@@ -8,12 +8,32 @@ import type {
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils'; import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert'; 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 // Refresh OAuth access tokens this many ms before they actually expire so
// in-flight requests don't race the expiry window. // in-flight requests don't race the expiry window.
const TOKEN_REFRESH_LEEWAY_MS = 60_000; 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 LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__'; const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -36,10 +56,152 @@ export interface StoredCredentials {
clientId?: string; 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<JMAPMethodCall>): 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<JMAPSession>;
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, any>, 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<T = Record<string, any>>(
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<string, any>,
ids?: string[],
what = 'object',
): void {
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
for (const bucket of buckets) {
const failures = body?.[bucket] as
| Record<string, { type?: string; description?: string; properties?: string[] }>
| 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<T>(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 { export class JMAPClient {
private session: JMAPSession | null = null; private session: JMAPSession | null = null;
private credentials: StoredCredentials | null = null; private credentials: StoredCredentials | null = null;
private _accountId: string | 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 { get accountId(): string {
if (!this._accountId) { if (!this._accountId) {
@@ -71,6 +233,48 @@ export class JMAPClient {
return this.session !== null && this._accountId !== null; 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 ──────────────────────────────────── // ── Authentication ────────────────────────────────────
// Public so blob upload/download helpers (which can't go through the JMAP // Public so blob upload/download helpers (which can't go through the JMAP
@@ -85,13 +289,28 @@ export class JMAPClient {
return `Basic ${encoded}`; return `Basic ${encoded}`;
} }
async connect(serverUrl: string, username: string, password: string): Promise<JMAPSession> { /**
// 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<JMAPSession> {
const baseUrl = serverUrl.replace(/\/+$/, ''); const baseUrl = serverUrl.replace(/\/+$/, '');
if (totp) {
return this.connectWithTotp(baseUrl, username, password, totp);
}
this.credentials = { serverUrl: baseUrl, username, password }; this.credentials = { serverUrl: baseUrl, username, password };
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
const accountId = generateAccountId(username, baseUrl); const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync( await SecureStore.setItemAsync(
@@ -102,12 +321,41 @@ export class JMAPClient {
return this.session; 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<JMAPSession> {
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<JMAPSession> { async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
const baseUrl = serverUrl.replace(/\/+$/, ''); const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken }; this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
return this.session; return this.session;
} }
@@ -115,9 +363,12 @@ export class JMAPClient {
// OAuth login via webmail handoff. The webmail did the OAuth dance against // OAuth login via webmail handoff. The webmail did the OAuth dance against
// Stalwart and handed us a complete token bundle. We persist the bundle // Stalwart and handed us a complete token bundle. We persist the bundle
// so subsequent launches can refresh without prompting the user again. // 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( async connectWithOAuth(
serverUrl: string, serverUrl: string,
tokens: OAuthTokens, tokens: OAuthTokens,
preferredUsername?: string,
): Promise<{ session: JMAPSession; username: string; accountId: string }> { ): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, ''); const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { this.credentials = {
@@ -133,11 +384,13 @@ export class JMAPClient {
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
// The JMAP session document carries the authenticated user's identifier // The JMAP session document carries the authenticated user's identifier
// — use it as the username so per-account storage keys are stable across // — use it as the username so per-account storage keys are stable across
// restarts and OAuth re-logins. // 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; this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl); const accountId = generateAccountId(username, baseUrl);
@@ -170,6 +423,9 @@ export class JMAPClient {
credentialsKey(accountId), credentialsKey(accountId),
JSON.stringify(this.credentials), JSON.stringify(this.credentials),
); );
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
} }
private currentOAuthTokens(): OAuthTokens | null { private currentOAuthTokens(): OAuthTokens | null {
@@ -191,8 +447,10 @@ export class JMAPClient {
} }
// Proactive refresh: when the access token is about to expire, swap it for // Proactive refresh: when the access token is about to expire, swap it for
// a fresh one. Quiet no-op for password credentials. // a fresh one. Quiet no-op for password credentials. Public so long-lived
private async ensureFreshToken(): Promise<void> { // consumers of `authHeader` (SSE stream, file downloads) can make sure the
// header they capture is not about to expire.
async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens(); const tokens = this.currentOAuthTokens();
if (!tokens) return; if (!tokens) return;
if (tokens.expiresAt == null) 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 // Reactive refresh after a 401. Returns true if a fresh token was obtained
// so the caller can retry the original request. // so the caller can retry the original request. A transient token-endpoint
private async forceRefreshToken(): Promise<boolean> { // 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<boolean> {
const tokens = this.currentOAuthTokens(); const tokens = this.currentOAuthTokens();
if (!tokens) return false; if (!tokens) return false;
try { try {
const next = await refreshOAuthAccessToken(tokens); const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next); await this.persistRefreshedTokens(next);
return true; return true;
} catch { } catch (err) {
if (err instanceof TransientRefreshError) {
throw new NetworkError(err.message);
}
return false; 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<void> {
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 // Legacy single-slot restore - kept for backward-compat tests. New code
// should use loadAccount(accountId) driven by the account registry. // should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> { async restoreSession(): Promise<boolean> {
@@ -234,6 +516,7 @@ export class JMAPClient {
creds.serverUrl, creds.serverUrl,
); );
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
return true; return true;
} catch { } catch {
await this.logout(); await this.logout();
@@ -257,6 +540,7 @@ export class JMAPClient {
// unrecoverable (AuthenticationError) from transient (NetworkError) and // unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable. // avoid clearing credentials when the server is just unreachable.
this.credentials = creds; this.credentials = creds;
this.firstTouchGate.reset();
try { try {
this.session = this.rewriteSessionUrls( this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl), await this.fetchSession(creds.serverUrl),
@@ -269,6 +553,7 @@ export class JMAPClient {
this.session = null; this.session = null;
this._accountId = null; this._accountId = null;
if (err instanceof AuthenticationError) throw err; if (err instanceof AuthenticationError) throw err;
if (err instanceof NetworkError) throw err;
// Anything else is treated as transport-level. Wrapping rather than // Anything else is treated as transport-level. Wrapping rather than
// rethrowing the raw fetch error gives callers a single check. // rethrowing the raw fetch error gives callers a single check.
throw new NetworkError( throw new NetworkError(
@@ -279,6 +564,25 @@ export class JMAPClient {
return true; 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<void> { async clearAccountCredentials(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId)); await SecureStore.deleteItemAsync(credentialsKey(accountId));
} }
@@ -311,20 +615,17 @@ export class JMAPClient {
// Rewrite session URLs to share the origin the client connected with. // Rewrite session URLs to share the origin the client connected with.
// JMAP servers often self-report localhost/container-internal hostnames in // JMAP servers often self-report localhost/container-internal hostnames in
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g. // 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 // Splits via plain string indexing rather than `new URL()` because RN's
// URL polyfill mutates inputs (appends trailing slashes, normalises // URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId} // characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them. // before the caller has a chance to substitute values into them.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession { private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = this.extractOrigin(serverUrl); const serverOrigin = extractOrigin(serverUrl);
const rewrite = (url: string | undefined): string | undefined => { const rewrite = (url: string | undefined): string | undefined =>
if (!url) return url; rewriteSessionUrl(url, serverOrigin);
const origin = this.extractOrigin(url);
if (!origin || !serverOrigin || origin === serverOrigin) return url;
return serverOrigin + url.slice(origin.length);
};
return { return {
...session, ...session,
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl, 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 // Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available). // account switch so the active account's stored creds remain available).
reset(): void { reset(): void {
this.session = null; this.session = null;
this.credentials = null; this.credentials = null;
this._accountId = null; this._accountId = null;
this.firstTouchGate.reset();
this.rateLimitedUntil = 0;
} }
async logout(): Promise<void> { async logout(): Promise<void> {
@@ -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<Response> {
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<Response> {
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<string, string> | 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 ───────────────────────────────── // ── Session Discovery ─────────────────────────────────
private async fetchSession(baseUrl: string): Promise<JMAPSession> { private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`; const url = `${baseUrl}/.well-known/jmap`;
await this.ensureFreshToken(); let response = await this.authenticatedFetch(url, {
const doFetch = () => headers: { Accept: 'application/json' },
secureFetch(url, { });
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) { if (response.status === 401) {
throw new AuthenticationError('Invalid credentials'); 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) { if (!response.ok) {
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`); 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 { private resolveAccountId(session: JMAPSession): string {
@@ -400,6 +820,34 @@ export class JMAPClient {
throw new Error('No account found in JMAP session'); 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<JMAPSession> {
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<boolean> {
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 ─────────────────────────────────────── // ── API Request ───────────────────────────────────────
async request( async request(
@@ -412,38 +860,54 @@ export class JMAPClient {
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL], using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
methodCalls, methodCalls,
}; };
const serialized = JSON.stringify(body);
const apiUrl = this.session.apiUrl;
const idempotent = !hasNonIdempotentMethod(methodCalls);
await this.ensureFreshToken(); for (let attempt = 0; ; attempt++) {
const doFetch = () => const response = await this.firstTouchGate.run(methodCalls, () =>
secureFetch(this.session!.apiUrl, { this.authenticatedFetch(
method: 'POST', apiUrl,
headers: { {
'Content-Type': 'application/json', method: 'POST',
Authorization: this.authHeader, headers: { 'Content-Type': 'application/json' },
}, body: serialized,
body: JSON.stringify(body), },
}); { idempotent },
),
);
let response = await doFetch(); if (response.status === 401) {
if (response.status === 401 && (await this.forceRefreshToken())) { const err = new AuthenticationError('Session expired');
response = await doFetch(); 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 ────────────────────────────────── // ── Capability Check ──────────────────────────────────
@@ -452,18 +916,53 @@ export class JMAPClient {
return Boolean(this.session?.capabilities?.[urn]); return Boolean(this.session?.capabilities?.[urn]);
} }
getMaxObjectsInGet(): number { /**
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as * True when `urn` is advertised for the account (RFC 8620 §2
| { maxObjectsInGet?: number } * `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; | undefined;
return core?.maxObjectsInGet || 500; }
getMaxObjectsInGet(): number {
return this.coreCapability?.maxObjectsInGet || 500;
}
getMaxObjectsInSet(): number {
return this.coreCapability?.maxObjectsInSet || 500;
} }
getMaxCallsInRequest(): number { getMaxCallsInRequest(): number {
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as return this.coreCapability?.maxCallsInRequest || 16;
| { maxCallsInRequest?: number } }
/** 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; | undefined;
return core?.maxCallsInRequest || 16; const max = mail?.maxSizeAttachmentsPerEmail;
return typeof max === 'number' && max > 0 ? max : 0;
} }
// ── Shared / group accounts ─────────────────────────── // ── Shared / group accounts ───────────────────────────
@@ -496,6 +995,16 @@ export class JMAPClient {
return this.session?.accounts?.[accountId]?.name; 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) ──────────────────── // ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in // The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
@@ -572,18 +1081,68 @@ export class JMAPClient {
accountId?: string, accountId?: string,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId); const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
await this.ensureFreshToken(); const response = await this.authenticatedFetch(url, undefined, {
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } }); timeoutMs: BLOB_TIMEOUT_MS,
let response = await doFetch(); });
if (response.status === 401 && (await this.forceRefreshToken())) { if (response.status === 401) {
response = await doFetch(); 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}`); if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer(); 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<string> {
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 ───────────────────────────────────────── // ── Error Classes ─────────────────────────────────────────
export class AuthenticationError extends Error { 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 // Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out. // stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error { 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 { export class RateLimitError extends Error {
retryAfterMs: number; retryAfterMs: number;
constructor(retryAfterMs: number) { constructor(retryAfterMs: number) {
+32 -11
View File
@@ -1,19 +1,30 @@
// Classify an error thrown by a JMAP call as a *transient* connectivity // 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 // *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 // method error or a 4xx). The offline outbox uses this to decide whether to
// queued for a later retry or to give up on it. // 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 // Classification is by error class first. A bare `TypeError` used to count as
// connectivity-related; anything else (including bare 4xx/5xx responses) is // transient because RN's fetch throws `TypeError("Network request failed")`,
// treated as permanent so a poison operation can't wedge the queue forever. // 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'; import { useNetworkStore } from '../stores/network-store';
const TRANSIENT_NAMES = new Set([ const TRANSIENT_NAMES = new Set([
'NetworkError', // jmap-client transport wrapper 'NetworkError', // jmap-client transport wrapper
'AbortError', // request aborted (e.g. app backgrounded) 'RequestTimeoutError', // no response headers within the deadline
'TypeError', // RN fetch throws TypeError("Network request failed") '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 = [ const TRANSIENT_MESSAGE_HINTS = [
@@ -21,19 +32,29 @@ const TRANSIENT_MESSAGE_HINTS = [
'network error', 'network error',
'failed to fetch', 'failed to fetch',
'not connected', 'not connected',
'session expired', // token refresh will happen on the next live request
'timeout', 'timeout',
'timed out', 'timed out',
'connection', 'connection',
]; ];
export function isTransientNetworkError(err: unknown): boolean { 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 (!useNetworkStore.getState().online) return true;
if (!(err instanceof Error)) return false; if (!(err instanceof Error)) return false;
if (TRANSIENT_NAMES.has(err.name)) return true; if (TRANSIENT_NAMES.has(err.name)) return true;
// A raw fetch TypeError only counts when its message says "network".
const msg = err.message?.toLowerCase() ?? ''; 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)); 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');
}
+166
View File
@@ -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<OAuthTokens> {
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,
};
}