From a5a12e11bc584530c3bb89b1c502e071d762115b Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Sat, 15 Aug 2026 14:31:03 +0200 Subject: [PATCH] feat: allow privileged plugin to fetch only a range of blob --- lib/jmap/client-interface.ts | 2 +- lib/jmap/client.ts | 51 ++++++++++++++++++++++++++++++++-- lib/plugin-sandbox/host-api.ts | 6 ++-- lib/plugin-sandbox/runtime.tsx | 2 +- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0215b10b4..2c305bca2 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -273,7 +273,7 @@ export interface IJMAPClient { getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string; fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise; - fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise; + fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise; downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise; // ── Identities ──────────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 112fcd45a..83c87b166 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6751,13 +6751,58 @@ export class JMAPClient implements IJMAPClient { // ── S/MIME raw-email helpers ───────────────────────────────────── /** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */ - async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise { + async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); - const response = await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); + const response = await this.authenticatedFetch(url, { }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } - return response.arrayBuffer(); + + const maxBytes = rangeHeader; + + // If no Range header requested or stream is missing, fallback to standard arrayBuffer() + if (!maxBytes || !response.body) { + return response.arrayBuffer(); + } + // used to download only the first N bytes of a blob, e.g. for S/MIME /PGP encryption verification + // Currently, Stalwart server does not support Range requests for the download endpoint, + // so we have to read the stream and cancel it after reading the first N bytes. + // Stream reading with early termination via reader.cancel() + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytesRead = 0; + + try { + while (bytesRead < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + bytesRead += value.length; + } + } finally { + // Cancel the response body stream to close the network connection early + await reader.cancel().catch(() => {}); + } + + // Slice exact requested length and copy chunks into a contiguous ArrayBuffer + const totalLength = Math.min(bytesRead, maxBytes); + const result = new Uint8Array(totalLength); + let offset = 0; + + // Iterate over each chunk of binary data (Uint8Array) collected during the stream + for (const chunk of chunks) { + // Determine how many bytes to copy from the current chunk without exceeding totalLength + const bytesToCopy = Math.min(chunk.length, totalLength - offset); + // Copy the needed slice from the chunk into the target array starting at the current offset + result.set(chunk.subarray(0, bytesToCopy), offset); + // Move the offset forward by the number of bytes just written + offset += bytesToCopy; + // Stop processing further chunks if the target size limit has been reached + if (offset >= totalLength) break; + } + + return result.buffer; } /** diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 80c625ce6..6b856dc7b 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -421,11 +421,11 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi * exposes the byte-fetch primitive. Returns a Uint8Array (structured-cloneable * across the postMessage boundary). */ -async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string }): Promise { +async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string, rangeHeader?: number }): Promise { if (typeof blobId !== 'string' || !blobId) throw new Error('jmap.fetchBlob: blobId required'); const { client } = useAuthStore.getState(); if (!client) throw new Error('jmap.fetchBlob: no active session'); - const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type); + const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type, undefined, opts?.rangeHeader); return new Uint8Array(buf); } @@ -1159,7 +1159,7 @@ export async function dispatchApiCall( case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined); case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined); - case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined); + case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string, rangeHeader?: number } | undefined); case 'jmap.uploadBlob': return doJmapUploadBlob(args[0] as Uint8Array, args[1] as string, args[2] as string); case 'jmap.sendRaw': return doJmapSendRaw( args[0] as ArrayBuffer | ArrayBufferView, diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 12d7be36b..201752961 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -253,7 +253,7 @@ function buildPluginApi(manifest: PluginManifest) { removeKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('jmap.removeKeyword', [emailId, keyword, accountId]) as Promise, /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */ - fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) => + fetchBlob: (blobId: string, opts?: { name?: string; type?: string, rangeHeader?: number }) => callApi('jmap.fetchBlob', [blobId, opts]) as Promise, uploadBlob: (content: Uint8Array, name: string, type: string) => callApi('jmap.uploadBlob', [content, name, type]) as Promise<{ blobId: string; size: number; type: string; }>,