feat: allow privileged plugin to fetch only a range of blob

This commit is contained in:
Paulhenry Saux
2026-08-15 18:28:05 +02:00
parent 4d3ad6b009
commit a5a12e11bc
4 changed files with 53 additions and 8 deletions
+1 -1
View File
@@ -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<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
+48 -3
View File
@@ -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<ArrayBuffer> {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise<ArrayBuffer> {
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;
}
/**
+3 -3
View File
@@ -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<Uint8Array> {
async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string, rangeHeader?: number }): Promise<Uint8Array> {
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,
+1 -1
View File
@@ -253,7 +253,7 @@ function buildPluginApi(manifest: PluginManifest) {
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('jmap.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
/** 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<Uint8Array>,
uploadBlob: (content: Uint8Array, name: string, type: string) =>
callApi('jmap.uploadBlob', [content, name, type]) as Promise<{ blobId: string; size: number; type: string; }>,