Files
native/src/api/jmap-client.ts
T

1493 lines
58 KiB
TypeScript

import * as SecureStore from 'expo-secure-store';
import type {
JMAPSession,
JMAPMethodCall,
JMAPRequestBody,
JMAPResponseBody,
} from './types';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch, mayFollowRedirect } from '../lib/client-cert';
import {
refreshOAuthAccessToken,
TransientRefreshError,
type OAuthTokens,
type OAuthTokenSource,
} from '../lib/oauth';
import { FirstTouchGate } from './first-touch-gate';
import { downloadNameSegment } from './download-name';
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
// 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__';
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
export function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
export interface StoredCredentials {
serverUrl: string;
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
clientId?: string;
tokenSource?: OAuthTokenSource;
}
/**
* 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;
}
const READ_ONLY_METHOD = /\/(?:get|query|changes|queryChanges|parse)$|^Core\/echo$/;
// Whether a batch must not be sent again after the connection failed without
// an answer. The server may already have acted on it, so only batches made of
// read methods are replayed (webmail isReplaySafeRequest): a retried /set,
// /copy, /import or submission would be done twice - one Send delivering two
// messages, one move or delete applied on top of its own result.
function hasNonIdempotentMethod(methodCalls: ReadonlyArray<JMAPMethodCall>): boolean {
return methodCalls.length === 0 || methodCalls.some(([name]) => !READ_ONLY_METHOD.test(name));
}
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';
}
/**
* `url` with the scheme and host lowercased and a default port dropped, so a
* response's final URL can be compared with the requested one.
*/
function canonicalUrl(url: string): string {
const m = /^(https?):\/\/([^/?#]+)(.*)$/i.exec(url);
if (!m) return url;
const scheme = m[1].toLowerCase();
const host = m[2].toLowerCase().replace(scheme === 'https' ? /:443$/ : /:80$/, '');
return `${scheme}://${host}${m[3]}`;
}
// Shared response helpers live in ./jmap-result (dependency-free) and are
// re-exported here for convenience.
export {
JMAPMethodError, requireMethodResult, assertSetResult, batched, ScheduleTooLateError, SubmissionQuotaError,
parseHoldLimit,
} from './jmap-result';
/**
* Stalwart advertises `maxDelayedSend` as a fixed 30 days, while its MTA
* rejects any hold beyond the `futureRelease` limit, 7 days by default.
*/
const STALWART_ADVERTISED_MAX_DELAYED_SEND = 30 * 24 * 60 * 60;
const STALWART_DEFAULT_MAX_HOLD = 7 * 24 * 60 * 60;
export class JMAPClient {
private session: JMAPSession | null = null;
private credentials: StoredCredentials | null = null;
private _accountId: string | null = null;
/**
* Bumped whenever the client is handed to another account (a load, a
* sign-in, a snapshot put back, a reset). Account changes can overlap, and
* a load or sign-in whose session arrives after a newer one began must not
* install it (see SessionSupersededError).
*/
private sessionEpoch = 0;
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>();
/** Hold limits learned from rejected scheduled sends, per server (seconds). */
private learnedHoldLimits = new Map<string, number>();
get accountId(): string {
if (!this._accountId) {
throw new Error('Not authenticated - call connect() first');
}
return this._accountId;
}
/**
* The primary account id while a session is up; null before one is (a
* cold start shows the mail view persisted from the last session first)
* and while another account loads. For code that runs at render or in
* selectors; API calls use `accountId`, which throws.
*/
get connectedAccountId(): string | null {
return this._accountId;
}
get currentSession(): JMAPSession | null {
return this.session;
}
get username(): string | null {
return this.credentials?.username ?? null;
}
get serverUrl(): string | null {
return this.credentials?.serverUrl ?? null;
}
// True when the session authenticates with a Bearer token (OAuth handoff or
// token login) rather than a username/password. Used by the security screen
// to hide password/TOTP management, which only applies to password accounts.
get usesBearerAuth(): boolean {
return !!this.credentials?.accessToken;
}
// A pasted access token, as opposed to an OAuth bundle (which always names
// the token endpoint it renews at).
get usesPastedToken(): boolean {
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
}
/** The OAuth client the session's tokens were issued to (null without a bundle). */
get oauthClientId(): string | null {
return (this.credentials?.accessToken && this.credentials.clientId) || null;
}
get isConnected(): boolean {
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
// request body) can fetch the same Authorization header the rest of the
// client uses. The value is recomputed each access and never cached.
get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
}
const encoded = btoa(`${this.credentials.username}:${this.credentials.password}`);
return `Basic ${encoded}`;
}
/**
* 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 epoch = this.detachSession();
if (totp) {
return this.connectWithTotp(baseUrl, username, password, totp, epoch);
}
const credentials: StoredCredentials = { serverUrl: baseUrl, username, password };
this.credentials = credentials;
const fetched = await this.fetchSession(baseUrl);
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, baseUrl);
this.session = session;
this._accountId = this.resolveAccountId(session);
this.firstTouchGate.reset();
const accountId = generateAccountId(username, baseUrl);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return 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,
epoch: number,
): 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) {
this.assertCurrentSession(epoch);
// Pre-0.16 Stalwart: TOTP rides along in the Basic password.
return this.connect(baseUrl, username, `${password}$${totp}`);
}
throw err;
}
// Signing in again would take the client back from a newer account change.
this.assertCurrentSession(epoch);
const { session } = await this.connectWithOAuth(baseUrl, tokens, username);
return session;
}
/**
* Access-token login (an API token the user pasted, e.g. from Fastmail).
* The token is the whole credential: it names the account, so the username
* comes from the session, and with no refresh token it lasts until the
* provider expires or revokes it.
*/
async connectWithToken(
serverUrl: string,
accessToken: string,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
const epoch = this.detachSession();
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
let fetched: JMAPSession;
try {
fetched = await this.fetchSession(baseUrl);
} catch (err) {
// A newer account change owns the credentials now.
this.assertCurrentSession(epoch);
this.credentials = null;
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
throw new InvalidTokenError();
}
throw err;
}
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, baseUrl);
this.session = session;
this._accountId = this.resolveAccountId(session);
this.firstTouchGate.reset();
const username = session.username;
if (!username) {
this.reset();
throw new Error('The server did not name the account');
}
const credentials: StoredCredentials = { ...this.credentials!, username };
this.credentials = credentials;
const accountId = generateAccountId(username, baseUrl);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return { session, username, accountId };
}
// 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(/\/+$/, '');
const epoch = this.detachSession();
this.credentials = {
serverUrl: baseUrl,
username: '',
password: '',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
tokenSource: tokens.source,
};
const fetched = await this.fetchSession(baseUrl);
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, baseUrl);
this.session = session;
this._accountId = this.resolveAccountId(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 =
preferredUsername || session.username || tokens.accessToken.slice(0, 8);
// Still this sign-in's, possibly with tokens refreshed during the fetch.
const credentials: StoredCredentials = { ...this.credentials!, username };
this.credentials = credentials;
const accountId = generateAccountId(username, baseUrl);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return { session, username, accountId };
}
/**
* Keep refreshed tokens with the account they were refreshed for
* (`refreshed`). The client may serve another account by the time the
* token endpoint answers (a switch while the account being left
* refreshed): the tokens then go to that account's stored credentials, if
* it still has any, and never into the ones in use. They used to, and were
* saved under the other account, which then signed in as this one.
*/
private async persistRefreshedTokens(refreshed: StoredCredentials, next: OAuthTokens): Promise<void> {
const accountId = generateAccountId(refreshed.username, refreshed.serverUrl);
const current = this.credentials;
const inUse = !!current
&& generateAccountId(current.username, current.serverUrl) === accountId;
let base: StoredCredentials;
if (inUse && current) {
base = current;
} else {
// Signed out meanwhile: nothing to keep.
const stored = await this.getStoredCredentials(accountId);
if (!stored) return;
base = stored;
}
if (base.accessToken === next.accessToken) return;
const updated: StoredCredentials = {
...base,
accessToken: next.accessToken,
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
// existing one when the response omits it.
refreshToken: next.refreshToken ?? base.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
if (inUse) this.credentials = updated;
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
if (!inUse) return;
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
}
/**
* Take the stored OAuth tokens when they are newer than the ones in memory:
* on iOS the notification extension refreshes them too and writes the new
* bundle back. True when the in-memory tokens changed.
*/
private async adoptStoredTokens(): Promise<boolean> {
const current = this.credentials;
if (!current?.accessToken || !current.refreshToken) return false;
const accountId = generateAccountId(current.username, current.serverUrl);
const stored = await this.getStoredCredentials(accountId).catch(() => null);
// Another account may have been opened meanwhile.
if (this.credentials !== current) return false;
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
this.credentials = {
...current,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken ?? current.refreshToken,
expiresAt: stored.expiresAt,
};
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
return true;
}
private currentOAuthTokens(): { credentials: StoredCredentials; tokens: OAuthTokens } | null {
const credentials = this.credentials;
if (
!credentials?.accessToken ||
!credentials.refreshToken ||
!credentials.tokenEndpoint ||
!credentials.clientId
) {
return null;
}
return {
credentials,
tokens: {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
expiresAt: credentials.expiresAt,
tokenEndpoint: credentials.tokenEndpoint,
clientId: credentials.clientId,
source: credentials.tokenSource,
},
};
}
/** OAuth bundle stored for `accountId` (null for password accounts). */
async getStoredOAuthTokens(accountId: string): Promise<OAuthTokens | null> {
const creds = await this.getStoredCredentials(accountId);
if (!creds?.accessToken || !creds.refreshToken || !creds.tokenEndpoint || !creds.clientId) return null;
return {
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
tokenEndpoint: creds.tokenEndpoint,
clientId: creds.clientId,
source: creds.tokenSource,
};
}
// Proactive refresh: when the access token is about to expire, swap it for
// 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> {
let current = this.currentOAuthTokens();
if (!current) return;
const { expiresAt } = current.tokens;
if (expiresAt == null) return;
if (expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
// Another process may have refreshed already (the iOS notification
// extension); use its tokens rather than spend a refresh token the
// server has replaced.
if (await this.adoptStoredTokens()) {
current = this.currentOAuthTokens();
if (!current || (current.tokens.expiresAt != null && current.tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
}
try {
const next = await refreshOAuthAccessToken(current.tokens);
await this.persistRefreshedTokens(current.credentials, next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and the reactive retry path catches it.
}
}
// Reactive refresh after a 401. Returns true if a fresh token was obtained
// 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> {
if (!this.currentOAuthTokens()) return false;
// A newer token in storage (refreshed by the iOS notification extension)
// is worth a retry before refreshing again.
if (await this.adoptStoredTokens()) return true;
const current = this.currentOAuthTokens();
if (!current) return false;
try {
const next = await refreshOAuthAccessToken(current.tokens);
await this.persistRefreshedTokens(current.credentials, next);
return true;
} 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 writeSharedSecret(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> {
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
if (!stored) return false;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
return true;
} catch {
await this.logout();
return false;
}
}
async loadAccount(accountId: string): Promise<boolean> {
const epoch = this.detachSession();
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
// A newer load may have put its credentials in by now.
this.assertCurrentSession(epoch);
if (!stored) return false;
// iOS: credentials saved before the notification extension existed move
// to the keychain group it can read (a no-op elsewhere and afterwards).
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
let creds: StoredCredentials;
try {
creds = JSON.parse(stored);
} catch {
// Corrupt entry — credentials can't be used. Caller should evict.
return false;
}
// Errors past this point propagate so callers can distinguish
// unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable.
this.credentials = creds;
this.firstTouchGate.reset();
let fetched: JMAPSession;
try {
fetched = await this.fetchSession(creds.serverUrl);
} catch (err) {
// The client belongs to a newer account change, which may be up.
this.assertCurrentSession(epoch);
// Don't leave a half-populated client behind; the caller needs to know
// the session is unavailable. Credentials stay in memory so a retry
// after the network comes back doesn't need a re-login.
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(
err instanceof Error ? err.message : 'Server unreachable',
);
}
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, creds.serverUrl);
this.session = session;
this._accountId = this.resolveAccountId(session);
return true;
}
/**
* Serve no account until the next session is up. The app's stores switch
* to the account being signed in first, and the old session would carry
* the new credentials once they are swapped in: a request in between (an
* outbox replay, a refresh) would reach the previous account. Callers that
* switch keep a snapshot() to put it back on failure. Resolves to the new
* epoch, which the load or sign-in checks once its session arrives.
*/
private detachSession(): number {
this.session = null;
this._accountId = null;
this.sessionEpoch += 1;
return this.sessionEpoch;
}
/**
* Throw SessionSupersededError when the client was handed to another
* account since `epoch`. Account changes overlap (B tapped, then C before
* B's session is up): the older one's session must not replace the newer
* one's, nor sit under its credentials.
*/
private assertCurrentSession(epoch: number): void {
if (epoch !== this.sessionEpoch) throw new SessionSupersededError();
}
/**
* 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.sessionEpoch += 1;
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));
}
async clearAllCredentials(accountIds: string[]): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
]);
}
// One-time migration: if an old single-slot credential exists, return its
// contents so the caller can register it in the account registry.
async consumeLegacyCredentials(): Promise<StoredCredentials | null> {
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
if (!stored) return null;
try {
const creds: StoredCredentials = JSON.parse(stored);
// Re-save under the per-account key before dropping the legacy entry
const accountId = generateAccountId(creds.username, creds.serverUrl);
await writeSharedSecret(credentialsKey(accountId), stored);
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
return creds;
} catch {
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
return null;
}
}
// 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"), 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.
//
// A URL on another HTTPS origin than the server's own apiUrl is one it
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
// domain) and is kept; everything else moves onto the origin we connected to.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = extractOrigin(serverUrl);
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
const rewrite = (url: string | undefined): string | undefined =>
rewriteSessionUrl(url, serverOrigin);
const rewriteHosted = (url: string | undefined): string | undefined => {
const origin = url ? extractOrigin(url) : null;
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
return hostedElsewhere ? url : rewrite(url);
};
return {
...session,
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
};
}
// Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available).
reset(): void {
this.sessionEpoch += 1;
this.session = null;
this.credentials = null;
this._accountId = null;
this.firstTouchGate.reset();
this.rateLimitedUntil = 0;
}
async logout(): Promise<void> {
const currentAccountId = this.credentials
? generateAccountId(this.credentials.username, this.credentials.serverUrl)
: null;
this.reset();
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
if (currentAccountId) {
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
}
}
// ── 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, { ...init, ...(controller ? { signal: controller.signal } : {}), timeoutMs });
} 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> {
// Stalwart's discovery endpoint /.well-known/jmap 307-redirects to
// /jmap/session. On iOS, NSURLSession drops the Authorization header when
// it auto-follows that redirect, so the app receives an unauthenticated,
// empty-accounts session. Request the session endpoint directly so the
// header stays attached; fall back to the standard well-known path for
// servers that don't serve /jmap/session (#39).
const primary = `${baseUrl}/jmap/session`;
const fallback = `${baseUrl}/.well-known/jmap`;
let requested = primary;
const fetchSessionDoc = async () => {
const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } });
if (r.status !== 404) return r;
requested = fallback;
return this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } });
};
let response = await fetchSessionDoc();
// A redirect the platform followed may have dropped the Authorization
// header (iOS NSURLSession does; OkHttp does for another scheme, host or
// port). The server then answers 401, or - Stalwart - 200 with an empty
// session, although the credentials are fine. RN's fetch never sets
// `response.redirected`, so also compare the final URL with the one
// requested, and refetch it with the header (webmail #892).
const finalUrl = canonicalUrl((response as { url?: string }).url || requested);
const redirected =
Boolean((response as { redirected?: boolean }).redirected) || finalUrl !== canonicalUrl(requested);
let refetched = false;
const refetchRedirected = async (): Promise<Response> => {
// Credentials only follow a redirect within the same host (or its
// https upgrade), the rule secureFetch applies. Anything else fails as
// a discovery error, not an auth one, so a stored account survives it.
if (!mayFollowRedirect(canonicalUrl(requested), finalUrl)) {
throw new Error(`Session discovery failed: redirected to ${finalUrl}`);
}
refetched = true;
return this.authenticatedFetch(finalUrl, { headers: { Accept: 'application/json' } });
};
if (redirected && response.status === 401) {
response = await refetchRedirected();
}
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}`);
}
let session = (await response.json()) as JMAPSession;
// Stalwart 307-redirects /.well-known/jmap to /jmap/session and answers
// the header-less redirected request with a 200 empty session.
const redirectedEmpty =
redirected &&
!refetched &&
(!session?.accounts || Object.keys(session.accounts).length === 0) &&
!session?.username;
if (redirectedEmpty) {
response = await refetchRedirected();
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');
}
// Stalwart answers 200 with an empty session (no accounts) for missing or
// invalid credentials rather than a 401. Surface that as an auth failure
// so the user sees "Invalid credentials" instead of "No account found".
const hasAccount =
Object.keys(session.primaryAccounts ?? {}).length > 0 ||
Object.keys(session.accounts ?? {}).length > 0;
if (!hasAccount) {
throw new AuthenticationError('Invalid credentials');
}
return session;
}
private resolveAccountId(session: JMAPSession): string {
// Try mail account first, then any personal account
const mailAccountId = session.primaryAccounts?.[CAPABILITIES.MAIL];
if (mailAccountId) return mailAccountId;
const coreAccountId = session.primaryAccounts?.[CAPABILITIES.CORE];
if (coreAccountId) return coreAccountId;
// Fall back to first account
const accountIds = Object.keys(session.accounts ?? {});
if (accountIds.length > 0) return accountIds[0];
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 epoch = this.sessionEpoch;
const baseUrl = this.credentials.serverUrl;
const fetched = await this.fetchSession(baseUrl);
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, baseUrl);
this.session = session;
this._accountId = this.resolveAccountId(session);
return 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(
methodCalls: JMAPMethodCall[],
using?: string[],
): Promise<JMAPResponseBody> {
if (!this.session) throw new Error('Not connected');
// Log the states our own mail writes move between, so the mail store can
// recognise their push echo (see api/own-writes).
const settled = beginOwnWrite(methodCalls);
try {
const response = await this.postRequest(methodCalls, using);
recordOwnEmailWrites(this.serverUrl ?? '', methodCalls, response.methodResponses);
return response;
} finally {
settled();
}
}
private async postRequest(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponseBody> {
if (!this.session) throw new Error('Not connected');
const body: JMAPRequestBody = {
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
methodCalls,
};
const serialized = JSON.stringify(body);
const apiUrl = this.session.apiUrl;
const idempotent = !hasNonIdempotentMethod(methodCalls);
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 },
),
);
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');
}
}
}
// ── Capability Check ──────────────────────────────────
hasCapability(urn: string): boolean {
return Boolean(this.session?.capabilities?.[urn]);
}
/**
* 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);
}
/**
* The value of a capability for an account (RFC 8620 §2
* `accountCapabilities`), falling back to the session-level object. RFC
* 8621 puts the mail and submission limits (`maxSizeAttachmentsPerEmail`,
* `maxDelayedSend`, `submissionExtensions`) under the account; Stalwart
* advertises an empty `{}` for them at session level.
*/
getAccountCapability(urn: string, accountId?: string): unknown {
const id = accountId ?? this._accountId;
const accountCaps = id ? this.session?.accounts?.[id]?.accountCapabilities : undefined;
if (accountCaps && urn in accountCaps) return accountCaps[urn];
return this.session?.capabilities?.[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;
}
getMaxObjectsInGet(): number {
return this.coreCapability?.maxObjectsInGet || 500;
}
getMaxObjectsInSet(): number {
return this.coreCapability?.maxObjectsInSet || 500;
}
getMaxCallsInRequest(): 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(accountId?: string): number {
const mail = this.getAccountCapability(CAPABILITIES.MAIL, accountId) as
| { maxSizeAttachmentsPerEmail?: number }
| undefined;
const max = mail?.maxSizeAttachmentsPerEmail;
return typeof max === 'number' && max > 0 ? max : 0;
}
// ── Shared / group accounts ───────────────────────────
// A JMAP session lists every account the credentials can reach: the user's
// own, plus any shared/group accounts (Stalwart "group accounts") they are a
// member of. Non-personal accounts are the shared mailboxes.
/**
* Mail-capable accounts other than the primary one. Stalwart doesn't always
* populate `accountCapabilities` on shared accounts, so an account counts as
* mail-capable when it advertises the mail capability OR is non-personal —
* the same rule the webmail's getSharedAccounts() applies.
*/
getSharedMailAccounts(): { id: string; name: string }[] {
if (!this.session || !this._accountId) return [];
const primaryId = this._accountId;
const out: { id: string; name: string }[] = [];
for (const [id, info] of Object.entries(this.session.accounts ?? {})) {
if (id === primaryId) continue;
const advertisesMail = info.accountCapabilities
? CAPABILITIES.MAIL in info.accountCapabilities
: false;
if (!advertisesMail && info.isPersonal) continue;
out.push({ id, name: info.name || id });
}
return out;
}
getAccountName(accountId: string): string | undefined {
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
// lets us defer delivery via the SMTP HOLDFOR parameter. Both are account
// capabilities (RFC 8621 §1.3.2) - Stalwart only advertises them in
// `accountCapabilities`, so a session-level read found nothing and turned
// scheduled send, the undo-send delay and the Scheduled view off (#57).
// Mirrors the webmail implementation so behaviour stays in sync.
/**
* The account whose submission capability applies to a send from
* `accountId` (default: the primary). An account that doesn't advertise
* submission itself falls back to the session's primary submission account.
*/
private submissionAccountId(accountId?: string): string | undefined {
const id = accountId ?? this._accountId ?? undefined;
if (id && this.session?.accounts?.[id]?.accountCapabilities?.[CAPABILITIES.SUBMISSION]) return id;
return this.session?.primaryAccounts?.[CAPABILITIES.SUBMISSION] || id;
}
/**
* Every account whose scheduled sends this session can see: the primary
* submission account plus each shared/group account that advertises
* submission itself. A send from a shared identity is held in that
* account, so a lookup in the primary one alone never found it (webmail
* #874).
*/
getSubmissionAccountIds(): string[] {
const ids: string[] = [];
const primary = this.submissionAccountId();
if (primary) ids.push(primary);
for (const [id, account] of Object.entries(this.session?.accounts ?? {})) {
if (!ids.includes(id) && account?.accountCapabilities?.[CAPABILITIES.SUBMISSION]) ids.push(id);
}
return ids;
}
private submissionCapability(accountId?: string):
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined {
return this.getAccountCapability(CAPABILITIES.SUBMISSION, this.submissionAccountId(accountId)) as
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined;
}
/**
* The longest hold (seconds) a send from `accountId` may ask for. On
* Stalwart the advertised 30 days counts as the 7 its MTA accepts, and a
* limit learned from a rejection wins over both (webmail parity).
*/
getMaxDelayedSend(accountId?: string): number {
const max = this.submissionCapability(accountId)?.maxDelayedSend;
if (typeof max !== 'number' || max <= 0) return 0;
const learned = this.serverUrl ? this.learnedHoldLimits.get(this.serverUrl) : undefined;
if (learned !== undefined) return Math.min(max, learned);
if (
max === STALWART_ADVERTISED_MAX_DELAYED_SEND &&
this.hasAccountCapability('urn:stalwart:jmap', this.submissionAccountId(accountId))
) {
return STALWART_DEFAULT_MAX_HOLD;
}
return max;
}
/**
* The latest time a send from `accountId` can be held until, or undefined
* when it can't be held. Caps the "Pick date & time" pickers.
*/
latestHoldDate(accountId?: string, now = Date.now()): Date | undefined {
if (!this.hasDelayedSend(accountId)) return undefined;
return new Date(now + this.getMaxDelayedSend(accountId) * 1000);
}
/**
* Remember the hold limit a rejected submission named, so the pickers only
* offer times the server accepts for the rest of the session.
*/
learnHoldLimit(seconds: number): void {
if (this.serverUrl && seconds > 0) this.learnedHoldLimits.set(this.serverUrl, seconds);
}
/**
* The HOLDFOR for the undo-send delay: none when the account can't hold
* mail, and never longer than the server allows.
*/
undoSendHold(delaySeconds: number, accountId?: string): number | undefined {
if (delaySeconds <= 0 || !this.hasDelayedSend(accountId)) return undefined;
return Math.min(delaySeconds, this.getMaxDelayedSend(accountId));
}
hasDelayedSend(accountId?: string): boolean {
const cap = this.submissionCapability(accountId);
if (!cap) return false;
const ext = cap.submissionExtensions;
// submissionExtensions is a map of extension name → params. FUTURERELEASE
// (RFC 4865) is the SMTP extension that backs deferred delivery.
const hasFutureRelease =
!!ext &&
typeof ext === 'object' &&
Object.keys(ext as Record<string, unknown>).some(
(k) => k.toUpperCase() === 'FUTURERELEASE',
);
return hasFutureRelease && this.getMaxDelayedSend(accountId) > 0;
}
// ── Stored credentials (per-account) ──────────────────
// Exposed so the unified-inbox aggregator can read another account's
// credentials without disturbing this client's live session, and persist a
// refreshed OAuth token back to secure storage.
async getStoredCredentials(accountId: string): Promise<StoredCredentials | null> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return null;
try {
return JSON.parse(stored) as StoredCredentials;
} catch {
return null;
}
}
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
// `accountId` targets a shared/group account, whose blobs aren't reachable
// through the user's own account id.
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
if (!this.session?.downloadUrl) {
throw new Error('Download URL not available - not connected');
}
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(accountId ?? this.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(downloadNameSegment(name)))
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
}
async fetchBlobArrayBuffer(
blobId: string,
name?: string,
type?: string,
accountId?: string,
): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
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.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 {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
// The server refused a pasted access token (wrong, expired or revoked).
export class InvalidTokenError extends Error {
constructor() {
super('Invalid access token');
this.name = 'InvalidTokenError';
}
}
// 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';
}
}
// A load or sign-in whose session arrived after a newer account change took
// the client over (see JMAPClient.assertCurrentSession). Its session was not
// installed; the caller leaves the client and the stores to the newer change.
export class SessionSupersededError extends Error {
constructor() {
super('Another account was opened before this one finished loading');
this.name = 'SessionSupersededError';
}
}
// Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
// 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) {
super('Rate limited by server');
this.name = 'RateLimitError';
this.retryAfterMs = retryAfterMs;
}
}
// Singleton instance
export const jmapClient = new JMAPClient();