* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\MailManager\Controllers; use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedNdJsonResponse; use KTXC\Http\Response\StreamedResponse; use KTXC\Context\IdentityContextInterface; use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Json\JsonSerializable; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ServiceIdentifier; use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\MailManager\Manager; use Psr\Log\LoggerInterface; use Throwable; class DefaultController extends ControllerAbstract { private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; private const ERR_MISSING_SERVICE = 'Missing parameter: service'; private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; private const ERR_MISSING_TARGET = 'Missing parameter: target'; private const ERR_MISSING_TARGETS = 'Missing parameter: targets'; private const ERR_MISSING_SENDER = 'Missing parameter: sender'; private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string'; private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array'; private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_SENDER = 'Invalid parameter: sender must be a string'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; public function __construct( private readonly TenantContextInterface $tenantContext, private readonly IdentityContextInterface $identityContext, private Manager $manager, private readonly LoggerInterface $logger ) {} /** * Main API endpoint for mail operations * * Single operation: * { * "version": 1, * "transaction": "tx-1", * "operation": "entity.create", * "data": {...} * } * * @return Response */ #[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])] public function index( int $version, string $transaction, string|null $operation = null, array|null $data = null, string|null $user = null ): Response { // authorize request $tenantId = $this->tenantContext->identifier(); $userId = $this->identityContext->identifier(); // acting-user override: only the reserved system context is permitted, // gated on the system mail management permission if ($user !== null && $user !== $userId) { if ($user !== ProviderBaseInterface::USER_SYSTEM || !$this->identityContext->hasPermission('mail_manager.system')) { return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'operation' => $operation, 'status' => 'error', 'data' => [ 'code' => JsonResponse::HTTP_FORBIDDEN, 'message' => 'Not permitted to act as user: ' . $user ] ], JsonResponse::HTTP_FORBIDDEN); } $userId = $user; } try { if ($operation !== null) { $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction); if ($result instanceof Response) { return $result; } return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'operation' => $operation, 'status' => 'success', 'data' => $result ], JsonResponse::HTTP_OK); } throw new InvalidArgumentException('Operation must be provided'); } catch (Throwable $t) { $this->logger->error('Error processing request', ['exception' => $t]); return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'operation' => $operation, 'status' => 'error', 'data' => [ 'code' => $t->getCode(), 'message' => $t->getMessage() ] ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); } } /** * Process a single operation */ private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed { return match ($operation) { // Provider operations 'provider.list' => $this->providerList($tenantId, $userId, $data), 'provider.fetch' => $this->providerFetch($tenantId, $userId, $data), 'provider.extant' => $this->providerExtant($tenantId, $userId, $data), // Service operations 'service.list' => $this->serviceList($tenantId, $userId, $data), 'service.fetch' => $this->serviceFetch($tenantId, $userId, $data), 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), 'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), 'service.discover' => $this->serviceDiscover($tenantId, $userId, $data, $version, $transaction), 'service.test' => $this->serviceTest($tenantId, $userId, $data), // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), 'collection.delta' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), 'collection.move' => $this->collectionMove($tenantId, $userId, $data), // Entity operations 'entity.listBulk' => $this->entityListBulk($tenantId, $userId, $data), 'entity.listStream' => $this->entityListStream($tenantId, $userId, $data, $version, $transaction), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), 'entity.patch' => $this->entityPatch($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), '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) }; } // ==================== Provider Operations ==================== private function providerList(string $tenantId, string $userId, array $data): mixed { if (isset($data['targets'])) { if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } foreach ($data['targets'] as $target) { if (!is_string($target)) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } } } return $this->manager->providerList($tenantId, $userId, $data['targets'] ?? []); } private function providerFetch(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_TARGET); } return $this->manager->providerFetch($tenantId, $userId, $data['target']); } private function providerExtant(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } foreach ($data['targets'] as $target) { if (!is_string($target)) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } } return $this->manager->providerExtant($tenantId, $userId, $data['targets']); } // ==================== Service Operations ===================== private function serviceList(string $tenantId, string $userId, array $data): mixed { $targets = null; if (isset($data['targets']) && is_array($data['targets'])) { $targets = ResourceIdentifiers::fromArray($data['targets']); foreach ($targets as $target) { if (!$target instanceof CollectionIdentifier && !$target instanceof ServiceIdentifier) { throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); } } } return $this->manager->serviceList($tenantId, $userId, $targets); } private function serviceFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } return $this->manager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); } private function serviceExtant(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $targets = ResourceIdentifiers::fromArray($data['targets']); foreach ($targets as $target) { if (!$target instanceof ServiceIdentifier) { throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service identifiers'); } } return $this->manager->serviceExtant($tenantId, $userId, $targets); } private function serviceCreate(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['data'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['data'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } return $this->manager->serviceCreate( $tenantId, $userId, $data['provider'], $data['data'] ); } private function serviceUpdate(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } if (!isset($data['data'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['data'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } if (isset($data['delta']) && !is_bool($data['delta'])) { throw new InvalidArgumentException('Invalid parameter: delta must be a boolean'); } return $this->manager->serviceUpdate( $tenantId, $userId, $data['provider'], $data['identifier'], $data['data'], $data['delta'] ?? false, ); } private function serviceDelete(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } return $this->manager->serviceDelete( $tenantId, $userId, $data['provider'], $data['identifier'] ); } private function serviceTest(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) { throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test'); } return $this->manager->serviceTest( $tenantId, $userId, $data['provider'], $data['identifier'] ?? null, $data['location'] ?? null, $data['identity'] ?? null, ); } private function serviceDiscover(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } $provider = $data['provider'] ?? null; $identity = $data['identity']; $location = $data['location'] ?? null; $secret = $data['secret'] ?? null; $discoverGenerator = $this->manager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret); $logger = $this->logger; $response = (function () use ($discoverGenerator, $version, $transaction, $logger): \Generator { yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; $total = 0; try { foreach ($discoverGenerator as $providerId => $serviceLocation) { if (!$serviceLocation instanceof ResourceServiceLocationInterface) { continue; } yield [ 'type' => 'data', 'data' => [ 'provider' => $providerId, 'location' => $serviceLocation->jsonSerialize() ] ]; $total++; } } catch (\Throwable $t) { $logger->error('Error streaming service discovery', ['exception' => $t]); yield ['type' => 'error', 'message' => $t->getMessage()]; return; } yield ['type' => 'control', 'status' => 'end', 'total' => $total]; })(); return new StreamedNdJsonResponse($response, 1, 200, ['Content-Type' => 'application/json']); } // ==================== Collection Operations ==================== private function collectionList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { $sources = ResourceIdentifiers::fromArray($data['sources']); foreach ($sources as $source) { if (!$source instanceof CollectionIdentifier && !$source instanceof ServiceIdentifier) { throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); } } } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort); } private function collectionFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']); foreach ($targetIdentifiers as $targetIdentifier) { if (!$targetIdentifier instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } } $list = $this->manager->collectionFetch( $tenantId, $userId, $targetIdentifier ); return $list; } private function collectionExtant(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $sources = ResourceIdentifiers::fromArray($data['targets']); foreach ($sources as $source) { if (!$source instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); } } return $this->manager->collectionExtant($tenantId, $userId, $sources); } private function collectionCreate(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } if (!is_string($data['provider'])) { throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['service'])) { throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); } if (!is_string($data['service'])) { throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); } if (isset($data['target']) && !is_string($data['target']) && !is_int($data['target'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } if (isset($data['target'])) { $targetIdentifier = ResourceIdentifier::fromString($data['target']); if (!$targetIdentifier instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } } return $this->manager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], $targetIdentifier ?? null, $data['properties'] ); } private function collectionUpdate(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_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } $targetIdentifier = ResourceIdentifier::fromString($data['target']); if (!$targetIdentifier instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } return $this->manager->collectionUpdate( $tenantId, $userId, $targetIdentifier, $data['properties'] ); } private function collectionDelete(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_TARGET); } $targetIdentifier = ResourceIdentifier::fromString($data['target']); if (!$targetIdentifier instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } $result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] ); if (is_bool($result)) { return [ 'disposition' => 'deleted' ]; } if ($result instanceof JsonSerializable) { return [ 'disposition' => 'moved', 'mutation' => $result ]; } return $result; } private function collectionMove(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_TARGET); } if (!isset($data['source'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } if (!is_string($data['source'])) { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } $target = ResourceIdentifier::fromString($data['target']); if (!$target instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } $source = ResourceIdentifier::fromString($data['source']); if (!$source instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: source must be provider:service:collection'); } return $this->manager->collectionMove($tenantId, $userId, $target, $source); } // ==================== Entity Operations ==================== private function entityListBulk(string $tenantId, string $userId, array $data): mixed { if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } if (!is_array($data['sources'])) { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } $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'); } } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; return $this->manager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range); } private function entityListStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } if (!is_array($data['sources'])) { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } $sources = ResourceIdentifiers::fromArray($data['sources']); foreach ($sources as $source) { if (!$source instanceof ServiceIdentifier && !$source instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service or provider:service:collection identifiers'); } } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; $entityGenerator = $this->manager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range); $logger = $this->logger; $responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator { yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; $total = 0; try { foreach ($entityGenerator as $entity) { if (!$entity instanceof JsonSerializable) { continue; } yield [ 'type' => 'data', 'data' => $entity->jsonSerialize() ]; $total++; } } catch (\Throwable $t) { $logger->error('Error streaming entities', ['exception' => $t]); yield ['type' => 'error', 'message' => $t->getMessage()]; return; } yield ['type' => 'control', 'status' => 'end', 'total' => $total]; })(); return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']); } private function entityFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $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->manager->entityFetchBulk( $tenantId, $userId, ...$targets->all() ); } private function entityExtant(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $targets = ResourceIdentifiers::fromArray($data['targets']); foreach ($targets as $target) { if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:entity identifiers'); } } return $this->manager->entityExtant($tenantId, $userId, $targets); } private function entityDelta(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $targets = ResourceIdentifiers::fromArray($data['targets']); foreach ($targets as $target) { if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:signature identifiers'); } } return $this->manager->entityDelta($tenantId, $userId, $targets); } private function entityPatch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } $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->manager->entityPatch($tenantId, $userId, $data['properties'], ...$targets->all()); } private function entityDelete(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } $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->manager->entityDelete($tenantId, $userId, ...$targets->all()); } private function entityMove(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_TARGET); } if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } if (!is_array($data['sources'])) { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } $target = ResourceIdentifier::fromString($data['target']); if (!$target instanceof CollectionIdentifier) { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } $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'); } } return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all()); } private function entitySubmit(string $tenantId, string $userId, array $data): mixed { if (!isset($data['sender'])) { throw new InvalidArgumentException(self::ERR_MISSING_SENDER); } if (!is_string($data['sender'])) { throw new InvalidArgumentException(self::ERR_INVALID_SENDER); } return $this->manager->entitySubmit( $tenantId, $userId, $data['sender'], $data['source'] ?? null, $data['message'] ?? null, ); } 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->manager->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', ]); } 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->manager->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; } }