feat: blobs fetch
PHP Unit Tests / test (pull_request) Failing after 11m59s
JS Unit Tests / test (pull_request) Failing after 12m1s
Build Test / test (pull_request) Failing after 12m3s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-18 19:38:11 -04:00
parent 105f69c3a5
commit bb349646c3
4 changed files with 98 additions and 1 deletions
+39
View File
@@ -162,6 +162,7 @@ class DefaultController extends ControllerAbstract {
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.submit' => $this->entitySubmit($tenantId, $userId, $data), 'entity.submit' => $this->entitySubmit($tenantId, $userId, $data),
'entity.download' => $this->entityDownload($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) default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
}; };
@@ -895,5 +896,43 @@ class DefaultController extends ControllerAbstract {
'Cache-Control' => 'no-store', '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;
}
} }
+33
View File
@@ -28,6 +28,9 @@ import type {
EntityPatchResponse, EntityPatchResponse,
EntityPatchRequest, EntityPatchRequest,
EntityDownloadRequest, EntityDownloadRequest,
EntityBlobsRequest,
EntityBlobsResponse,
EntityBlobsWireResponse,
} from '../types/entity'; } from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models'; import { EntityObject } from '../models';
@@ -227,6 +230,36 @@ export const entityService = {
download(request: EntityDownloadRequest): { transaction: string } { download(request: EntityDownloadRequest): { transaction: string } {
return transceiveDownload<EntityDownloadRequest>('entity.download', request); return transceiveDownload<EntityDownloadRequest>('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<EntityBlobsResponse> {
const wire = await transceivePost<EntityBlobsRequest, EntityBlobsWireResponse>(
'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; export default entityService;
+17
View File
@@ -8,6 +8,7 @@ import { entityService } from '../services'
import { EntityObject, MessageObject } from '../models' import { EntityObject, MessageObject } from '../models'
import type { import type {
EntityBlobSelector, EntityBlobSelector,
EntityBlobsResponse,
EntityDownloadRequest, EntityDownloadRequest,
EntityTransmitRequest, EntityTransmitRequest,
EntityTransmitResponse, 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<EntityBlobsResponse> {
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 public API
return { return {
// State (readonly) // State (readonly)
@@ -530,5 +546,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
move, move,
transmit, transmit,
download, download,
blobs,
} }
}) })
+9 -1
View File
@@ -227,9 +227,17 @@ export interface EntityBlobsRequest {
parts: EntityBlobSelector[]; parts: EntityBlobSelector[];
} }
export interface EntityBlobsResult { export interface EntityBlobsWireResult {
source: EntityIdentifier; source: EntityIdentifier;
part: EntityBlobSelector; part: EntityBlobSelector;
mime: string;
filename: string;
bytes: string;
}
export interface EntityBlobsWireResponse extends Array<EntityBlobsWireResult> {}
export interface EntityBlobsResult extends Omit<EntityBlobsWireResult, 'bytes'> {
blob: Blob; blob: Blob;
} }