From bb349646c3378a671aa8c3e4256aff9b07ebe8fd Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 18 Jun 2026 19:38:11 -0400 Subject: [PATCH] feat: blobs fetch Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 39 +++++++++++++++++++++++++++ src/services/entityService.ts | 33 +++++++++++++++++++++++ src/stores/entitiesStore.ts | 17 ++++++++++++ src/types/entity.ts | 10 ++++++- 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index a378694..0b4f076 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -162,6 +162,7 @@ class DefaultController extends ControllerAbstract { 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.submit' => $this->entitySubmit($tenantId, $userId, $data), 'entity.download' => $this->entityDownload($tenantId, $userId, $data), + 'entity.blobs' => $this->entityBlobs($tenantId, $userId, $data), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; @@ -895,5 +896,43 @@ class DefaultController extends ControllerAbstract { 'Cache-Control' => 'no-store', ]); } + + private function entityBlobs(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + if (!isset($data['parts']) || !is_array($data['parts']) || $data['parts'] === []) { + throw new InvalidArgumentException('At least one part selector is required'); + } + + $target = ResourceIdentifier::fromString($data['target']); + + $results = []; + foreach ($data['parts'] as $part) { + if (!is_array($part)) { + throw new InvalidArgumentException('Invalid part selector'); + } + + $resource = $this->mailManager->entityDownload($tenantId, $userId, $target, $part); + + $bytes = ''; + foreach ($resource->stream() as $chunk) { + $bytes .= $chunk; + } + + $results[] = [ + 'source' => $data['target'], + 'part' => $part, + 'mime' => $resource->mimeType(), + 'filename' => $resource->filename(), + 'bytes' => base64_encode($bytes), + ]; + } + + return $results; + } } diff --git a/src/services/entityService.ts b/src/services/entityService.ts index be8d211..c5c190d 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -28,6 +28,9 @@ import type { EntityPatchResponse, EntityPatchRequest, EntityDownloadRequest, + EntityBlobsRequest, + EntityBlobsResponse, + EntityBlobsWireResponse, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { EntityObject } from '../models'; @@ -227,6 +230,36 @@ export const entityService = { download(request: EntityDownloadRequest): { transaction: string } { return transceiveDownload('entity.download', request); }, + + /** + * Fetch one or more message parts (attachments) inline for rendering. + * + * Returns JSON with base64-encoded bytes; each result is decoded into a Blob + * so callers can create object URLs for preview. + */ + async blobs(request: EntityBlobsRequest): Promise { + const wire = await transceivePost( + 'entity.blobs', + request, + ); + return wire.map((result) => ({ + source: result.source, + part: result.part, + mime: result.mime, + filename: result.filename, + blob: base64ToBlob(result.bytes, result.mime), + })); + }, }; +/** Decode base64 content into a Blob of the given MIME type. */ +function base64ToBlob(base64: string, mime: string): Blob { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime }); +} + export default entityService; diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index c192395..887148f 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -8,6 +8,7 @@ import { entityService } from '../services' import { EntityObject, MessageObject } from '../models' import type { EntityBlobSelector, + EntityBlobsResponse, EntityDownloadRequest, EntityTransmitRequest, EntityTransmitResponse, @@ -508,6 +509,21 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } + /** + * Fetch one or more message parts (attachments) inline as Blobs, for preview. + */ + async function blobs( + target: EntityIdentifier, + parts: EntityBlobSelector[], + ): Promise { + try { + return await entityService.blobs({ target, parts }) + } catch (error: any) { + console.error('[Mail Manager][Store] - Failed to fetch attachment blobs:', error) + throw error + } + } + // Return public API return { // State (readonly) @@ -530,5 +546,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { move, transmit, download, + blobs, } }) diff --git a/src/types/entity.ts b/src/types/entity.ts index 1a306eb..94f4dfb 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -227,9 +227,17 @@ export interface EntityBlobsRequest { parts: EntityBlobSelector[]; } -export interface EntityBlobsResult { +export interface EntityBlobsWireResult { source: EntityIdentifier; part: EntityBlobSelector; + mime: string; + filename: string; + bytes: string; +} + +export interface EntityBlobsWireResponse extends Array {} + +export interface EntityBlobsResult extends Omit { blob: Blob; }