diff --git a/src/api/blob.ts b/src/api/blob.ts index 1d6926a4..4bda205d 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -13,17 +13,20 @@ export async function uploadBlob( encodeURIComponent(jmapClient.accountId), ); - // Read the source via the file-system API so this works for both `file://` - // (image picker, iOS document picker) and `content://` (Android SAF) URIs. + // Read via the file-system API so this works for both `file://` (image + // picker, iOS document picker) and `content://` (Android SAF) URIs. We pass + // the underlying ArrayBuffer rather than the Uint8Array view because some + // RN transports stringify typed-array bodies, which silently produces an + // empty/garbage upload. const bytes = await new File(uri).bytes(); const response = await fetch(uploadUrl, { method: 'POST', headers: { - 'Content-Type': type, - Authorization: (jmapClient as any).authHeader, + 'Content-Type': type || 'application/octet-stream', + Authorization: jmapClient.authHeader, }, - body: bytes as unknown as BodyInit, + body: bytes.buffer as ArrayBuffer, }); if (!response.ok) { @@ -66,8 +69,8 @@ export function getDownloadUrl( if (!session) throw new Error('Not connected'); return session.downloadUrl - .replace('{accountId}', jmapClient.accountId) - .replace('{blobId}', blobId) + .replace('{accountId}', encodeURIComponent(jmapClient.accountId)) + .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent(name ?? 'download')) .replace('{type}', encodeURIComponent(type ?? 'application/octet-stream')); } diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index eac21ca7..befa8e05 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -53,7 +53,10 @@ export class JMAPClient { // ── Authentication ──────────────────────────────────── - private get authHeader(): string { + // 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}`; @@ -161,17 +164,18 @@ export class JMAPClient { // 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; - try { - const parsed = new URL(url); - const server = new URL(serverUrl); - if (parsed.origin === server.origin) return url; - return server.origin + parsed.pathname + parsed.search + parsed.hash; - } catch { - return url; - } + const origin = this.extractOrigin(url); + if (!origin || !serverOrigin || origin === serverOrigin) return url; + return serverOrigin + url.slice(origin.length); }; return { ...session, @@ -182,6 +186,11 @@ 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 { diff --git a/src/api/push.ts b/src/api/push.ts index 65d294ed..29f9249d 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -134,7 +134,7 @@ export function connectEventSource( : require('react-native-sse').default; const es = new EventSourceImpl(url, { - headers: { Authorization: (jmapClient as any).authHeader }, + headers: { Authorization: jmapClient.authHeader }, }); const handler = (event: any) => { diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 594ee65a..8d09e314 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -7,9 +7,7 @@ import { getDownloadUrl } from '../api/blob'; const RFC822 = 'message/rfc822'; function authHeader(): string { - const h = (jmapClient as unknown as { authHeader?: string }).authHeader; - if (!h) throw new Error('Not connected'); - return h; + return jmapClient.authHeader; } function safeAttachmentName(name: string | undefined, type: string | undefined): string {