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

376 lines
13 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 } from '../lib/client-cert';
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
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;
}
export class JMAPClient {
private session: JMAPSession | null = null;
private credentials: StoredCredentials | null = null;
private _accountId: string | null = null;
get accountId(): string {
if (!this._accountId) {
throw new Error('Not authenticated - call connect() first');
}
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;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
// ── 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}`;
}
async connect(serverUrl: string, username: string, password: string): Promise<JMAPSession> {
// Normalise URL
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { serverUrl: baseUrl, username, password };
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return this.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);
return this.session;
}
// 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);
return true;
} catch {
await this.logout();
return false;
}
}
async loadAccount(accountId: string): Promise<boolean> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
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;
try {
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
} catch (err) {
// 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;
// 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._accountId = this.resolveAccountId(this.session);
return true;
}
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 SecureStore.setItemAsync(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").
//
// 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);
};
return {
...session,
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
};
}
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;
}
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));
}
}
// ── Session Discovery ─────────────────────────────────
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
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}`);
}
return response.json();
}
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');
}
// ── API Request ───────────────────────────────────────
async request(
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 response = await secureFetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
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 ──────────────────────────────────
hasCapability(urn: string): boolean {
return Boolean(this.session?.capabilities?.[urn]);
}
getMaxObjectsInGet(): number {
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
| { maxObjectsInGet?: number }
| undefined;
return core?.maxObjectsInGet || 500;
}
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.session?.downloadUrl) {
throw new Error('Download URL not available - not connected');
}
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name || 'download'))
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
}
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await secureFetch(url, {
headers: { Authorization: this.authHeader },
});
if (response.status === 401) throw new AuthenticationError('Session expired');
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer();
}
}
// ── Error Classes ─────────────────────────────────────────
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
// 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';
}
}
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();