diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 46e8373..94ee34f 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedNdJsonResponse; +use KTXC\Http\Response\StreamedResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; @@ -31,12 +32,6 @@ use KTXM\MailManager\Manager; use Psr\Log\LoggerInterface; use Throwable; -/** - * Default Controller - Unified Mail API - * - * Handles all mail operations in JMAP-style API pattern. - * Supports both single operations and batches with result references. - */ class DefaultController extends ControllerAbstract { private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; @@ -168,6 +163,7 @@ class DefaultController extends ControllerAbstract { 'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), + 'entity.download' => $this->entityDownload($tenantId, $userId, $data), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; @@ -558,14 +554,14 @@ class DefaultController extends ControllerAbstract { if (is_bool($result)) { return [ - 'outcome' => 'deleted' + 'disposition' => 'deleted' ]; } if ($result instanceof JsonSerializable) { return [ - 'outcome' => 'moved', - 'data' => $result + 'disposition' => 'moved', + 'mutation' => $result ]; } @@ -773,21 +769,21 @@ class DefaultController extends ControllerAbstract { } private function entityDelete(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - $sources = ResourceIdentifiers::fromArray($data['sources']); - foreach ($sources as $source) { - if (!$source instanceof EntityIdentifier) { - throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); } } - return $this->mailManager->entityDelete($tenantId, $userId, ...$sources->all()); + return $this->mailManager->entityDelete($tenantId, $userId, ...$targets->all()); } private function entityPatch(string $tenantId, string $userId, array $data): mixed { @@ -868,4 +864,46 @@ class DefaultController extends ControllerAbstract { return ['jobId' => $jobId]; } + private function entityDownload(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); + } + + // 'part' is optional — null means full RFC 822 message download + $part = isset($data['part']) && is_array($data['part']) ? $data['part'] : null; + + $target = ResourceIdentifier::fromString($data['target']); + $logger = $this->logger; + + $result = $this->mailManager->entityDownload($tenantId, $userId, $target, $part); + + $filename = $result->filename(); + $asciiFilename = preg_replace('/[^\x20-\x7E]|[\\\\"]/', '_', $filename); + $encodedFilename = rawurlencode($filename); + $disposition = sprintf( + 'attachment; filename="%s"; filename*=UTF-8\'\'%s', + $asciiFilename, + $encodedFilename, + ); + + $responseGenerator = (static function () use ($result, $logger): \Generator { + try { + yield from $result->stream(); + } catch (\Throwable $t) { + $logger->error('Error streaming entity download', ['exception' => $t]); + // Headers already sent — cannot change status code; stop output cleanly + } + })(); + + return new StreamedResponse($responseGenerator, 200, [ + 'Content-Disposition' => $disposition, + 'Content-Type' => $result->mimeType(), + 'Content-Transfer-Encoding' => 'binary', + 'Cache-Control' => 'no-store', + ]); + } + } diff --git a/lib/Manager.php b/lib/Manager.php index 7e87034..d14b094 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -20,6 +20,7 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Resource\BinaryResource; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; @@ -951,6 +952,13 @@ class Manager { } } + public function entityDownload(string $tenantId, string $userId, EntityIdentifier $targetEntity, array|null $targetPart): BinaryResource { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $targetEntity->provider(), $targetEntity->service()); + // download entity + return $service->entityDownload($targetEntity, $targetPart); + } + /** * Check if messages exist * @@ -1294,5 +1302,4 @@ class Manager { return $operationOutcome; } - } diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 77aaa39..be8d211 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -2,7 +2,7 @@ * Entity management service */ -import { transceivePost, transceiveStream } from './transceive'; +import { transceivePost, transceiveStream, transceiveDownload } from './transceive'; import type { EntityFetchRequest, EntityFetchResponse, @@ -27,6 +27,7 @@ import type { EntityListBulkRequest, EntityPatchResponse, EntityPatchRequest, + EntityDownloadRequest, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { EntityObject } from '../models'; @@ -110,7 +111,7 @@ export const entityService = { // Convert response to EntityObject instances const list: Record = {}; - Object.entries(response).forEach(([identifier, entity]) => { + Object.entries(response).forEach(([, entity]) => { list[entity.identifier] = createEntityObject(entity); }); @@ -216,6 +217,16 @@ export const entityService = { async transmit(request: EntityTransmitRequest): Promise { return await transceivePost('entity.transmit', request); }, + + /** + * Submit a browser-native attachment download request. + * + * The backend download endpoint is expected to honor the supplied selector + * and respond with an attachment payload rather than JSON. + */ + download(request: EntityDownloadRequest): { transaction: string } { + return transceiveDownload('entity.download', request); + }, }; export default entityService; diff --git a/src/services/transceive.ts b/src/services/transceive.ts index 7b72413..4a795e0 100644 --- a/src/services/transceive.ts +++ b/src/services/transceive.ts @@ -141,3 +141,84 @@ export async function transceiveStream( return { total }; } + +/** + * Submit a browser-native file download request via a top-level form POST. + * + * This avoids buffering the response body in application-managed JavaScript + * memory. The backend is expected to accept form fields where `data` contains + * the serialized operation payload and to return an attachment response. + */ +export function transceiveDownload( + operation: string, + data: TRequest, + user?: string, +): { transaction: string } { + if (typeof document === 'undefined' || typeof window === 'undefined') { + throw new Error('Browser window is not available for download submission'); + } + + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user, + }; + + const form = document.createElement('form'); + form.method = 'POST'; + form.action = API_URL; + form.target = '_blank'; + form.style.display = 'none'; + + appendHiddenField(form, 'version', String(request.version)); + appendHiddenField(form, 'transaction', request.transaction); + appendHiddenField(form, 'operation', request.operation); + appendFormValue(form, 'data', request.data); + + if (request.user) { + appendHiddenField(form, 'user', request.user); + } + + document.body.appendChild(form); + form.submit(); + form.remove(); + + return { transaction: request.transaction }; +} + +function appendHiddenField(form: HTMLFormElement, name: string, value: string): void { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); +} + +function appendFormValue(form: HTMLFormElement, name: string, value: unknown): void { + if (value === undefined) { + return; + } + + if (value === null) { + appendHiddenField(form, name, ''); + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + appendFormValue(form, `${name}[${index}]`, item); + }); + return; + } + + if (typeof value === 'object') { + Object.entries(value as Record).forEach(([key, nestedValue]) => { + appendFormValue(form, `${name}[${key}]`, nestedValue); + }); + return; + } + + appendHiddenField(form, name, String(value)); +} diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index e838031..c192395 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -7,7 +7,8 @@ import { defineStore } from 'pinia' import { entityService } from '../services' import { EntityObject, MessageObject } from '../models' import type { - EntityListStreamRequest, + EntityBlobSelector, + EntityDownloadRequest, EntityTransmitRequest, EntityTransmitResponse, } from '../types/entity' @@ -18,7 +19,7 @@ import type { ListRange, ListSort, } from '../types/common' -import type { MessageInterface } from '@/types/message' +import type { MessageInterface, MessagePartInterface } from '@/types/message' export const useEntitiesStore = defineStore('mailEntitiesStore', () => { // State @@ -184,15 +185,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { const response = await entityService.delta({ sources }) // Process delta and update store - Object.entries(response).forEach(([provider, providerData]) => { + Object.entries(response).forEach(([, providerData]) => { // Skip if no changes for provider if (providerData === false) return - Object.entries(providerData).forEach(([service, serviceData]) => { + Object.entries(providerData).forEach(([, serviceData]) => { // Skip if no changes for service if (serviceData === false) return - Object.entries(serviceData).forEach(([collection, collectionData]) => { + Object.entries(serviceData).forEach(([, collectionData]) => { // Skip if no changes for collection if (collectionData === false) return @@ -474,6 +475,39 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } + async function download(target: EntityIdentifier, part?: Partial) { + let targetPart: EntityBlobSelector | undefined = undefined + if (part && (part.blobId || part.partId || part.cid)) { + targetPart = { + blobId: part.blobId ?? undefined, + partId: part.partId ?? undefined, + cid: part.cid ?? undefined, + } + } + + let filename: string + if (part && part.name && part.name.trim().length > 0) { + filename = part.name.trim() + } else if (part) { + filename = `attachment-${part.partId || part.blobId || part.cid || 'unknown'}` + } else { + filename = 'message.eml' + } + + const request: EntityDownloadRequest = { + target, + part: targetPart, + filename, + } + + try { + entityService.download(request) + } catch (error: any) { + console.error('[Mail Manager][Store] - Failed to submit attachment download:', error) + throw error + } + } + // Return public API return { // State (readonly) @@ -495,5 +529,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { delta, move, transmit, + download, } }) diff --git a/src/types/entity.ts b/src/types/entity.ts index 123ca77..1a306eb 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -205,4 +205,32 @@ export interface EntityTransmitRequest { export interface EntityTransmitResponse { id: string; status: 'queued' | 'sent'; -} \ No newline at end of file +} + +/** + * Entity Blob Fetch + */ +export interface EntityBlobSelector { + blobId?: string; + partId?: string; + cid?: string; +} + +export interface EntityDownloadRequest { + target: EntityIdentifier; + part?: EntityBlobSelector; + filename?: string | null; +} + +export interface EntityBlobsRequest { + target: EntityIdentifier; + parts: EntityBlobSelector[]; +} + +export interface EntityBlobsResult { + source: EntityIdentifier; + part: EntityBlobSelector; + blob: Blob; +} + +export interface EntityBlobsResponse extends Array {} \ No newline at end of file