mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
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<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(/\/+$/, '');
|
||||
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<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> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<boolean> {
|
||||
// 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<boolean> {
|
||||
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<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
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
@@ -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<void> {
|
||||
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<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 ─────────────────────────────────
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
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<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 ───────────────────────────────────────
|
||||
|
||||
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<ArrayBuffer> {
|
||||
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<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 ─────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
|
||||
+32
-11
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user