* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\MailManager\Controllers; use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; use KTXF\Mail\Entity\Message; use KTXF\Mail\Queue\SendOptions; use KTXF\Resource\Selector\SourceSelector; use KTXF\Routing\Attributes\AuthenticatedRoute; 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 { // Error message constants 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_COLLECTION = 'Missing parameter: collection'; private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; private const ERR_MISSING_MESSAGE = 'Missing parameter: message'; 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_COLLECTION = 'Invalid parameter: collection must be a string or integer'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; private const ERR_INVALID_MESSAGE = 'Invalid parameter: message must be an array'; public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, private Manager $mailManager, private readonly LoggerInterface $logger ) {} /** * Main API endpoint for mail operations * * Single operation: * { "version": 1, "transaction": "tx-1", "operation": "message.send", "data": {...} } * * Batch operations: * { "version": 1, "transaction": "tx-1", "operations": [ * {"id": "op1", "operation": "message.send", "data": {...}}, * {"id": "op2", "operation": "message.destroy", "data": {"collection": "#op1.draftId"}} * ]} * * @return JsonResponse */ #[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])] public function index( int $version, string $transaction, string|null $operation = null, array|null $data = null, array|null $operations = null, string|null $user = null ): JsonResponse { // authorize request $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { // Single operation mode if ($operation !== null) { $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []); return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'operation' => $operation, 'status' => 'success', 'data' => $result ], JsonResponse::HTTP_OK); } // Batch operations mode if ($operations !== null && is_array($operations)) { $results = $this->processBatch($tenantId, $userId, $operations); return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'status' => 'success', 'operations' => $results ], JsonResponse::HTTP_OK); } throw new InvalidArgumentException('Either operation or operations must be provided'); } catch (Throwable $t) { $this->logger->error('Error processing mail manager 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 batch operations with result references */ private function processBatch(string $tenantId, string $userId, array $operations): array { $results = []; $resultMap = []; // Store results by operation ID for references foreach ($operations as $index => $op) { $opId = $op['id'] ?? "op{$index}"; $operation = $op['operation'] ?? null; $data = $op['data'] ?? []; if ($operation === null) { $results[] = [ 'id' => $opId, 'status' => 'error', 'data' => ['message' => 'Missing operation name'] ]; continue; } try { // Resolve result references in data (e.g., "#op1.id") $data = $this->resolveReferences($data, $resultMap); $result = $this->processOperation($tenantId, $userId, $operation, $data, $resultMap); $results[] = [ 'id' => $opId, 'operation' => $operation, 'status' => 'success', 'data' => $result ]; // Store result for future references $resultMap[$opId] = $result; } catch (Throwable $t) { $this->logger->warning('Batch operation failed', [ 'operation' => $operation, 'opId' => $opId, 'error' => $t->getMessage() ]); $results[] = [ 'id' => $opId, 'operation' => $operation, 'status' => 'error', 'data' => [ 'code' => $t->getCode(), 'message' => $t->getMessage() ] ]; } } return $results; } /** * Resolve result references in operation data * * Transforms "#op1.id" into the actual value from previous operation results */ private function resolveReferences(mixed $data, array $resultMap): mixed { if (is_string($data) && str_starts_with($data, '#')) { // Parse reference like "#op1.id" or "#op1.collection.id" $parts = explode('.', substr($data, 1)); $opId = array_shift($parts); if (!isset($resultMap[$opId])) { throw new InvalidArgumentException("Reference to undefined operation: #{$opId}"); } $value = $resultMap[$opId]; foreach ($parts as $key) { if (is_array($value) && isset($value[$key])) { $value = $value[$key]; } elseif (is_object($value) && isset($value->$key)) { $value = $value->$key; } else { throw new InvalidArgumentException("Invalid reference path: {$data}"); } } return $value; } if (is_array($data)) { return array_map(fn($item) => $this->resolveReferences($item, $resultMap), $data); } return $data; } /** * Process a single operation */ private function processOperation(string $tenantId, string $userId, string $operation, array $data, array $resultMap): mixed { return match ($operation) { // Provider operations 'provider.list' => $this->providerList($tenantId, $userId, $data), 'provider.extant' => $this->providerExtant($tenantId, $userId, $data), // Service operations 'service.list' => $this->serviceList($tenantId, $userId, $data), 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), 'service.fetch' => $this->serviceFetch($tenantId, $userId, $data), 'service.discover' => $this->serviceDiscover($tenantId, $userId, $data), 'service.test' => $this->serviceTest($tenantId, $userId, $data), 'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), 'collection.modify' => $this->collectionModify($tenantId, $userId, $data), 'collection.destroy' => $this->collectionDestroy($tenantId, $userId, $data), // Entity operations 'entity.list' => $this->entityList($tenantId, $userId, $data), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), 'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), default => throw new InvalidArgumentException('Unknown operation: ' . $operation) }; } // ==================== Provider Operations ==================== private function providerList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { $sources = new SourceSelector(); $sources->jsonDeserialize($data['sources']); } return $this->mailManager->providerList($tenantId, $userId, $sources); } private function providerExtant(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); return $this->mailManager->providerExtant($tenantId, $userId, $sources); } // ==================== Service Operations ===================== private function serviceList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { $sources = new SourceSelector(); $sources->jsonDeserialize($data['sources']); } return $this->mailManager->serviceList($tenantId, $userId, $sources); } private function serviceExtant(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); return $this->mailManager->serviceExtant($tenantId, $userId, $sources); } 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->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); } private function serviceDiscover(string $tenantId, string $userId, array $data): mixed { 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; return $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret); } 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->mailManager->serviceTest( $tenantId, $userId, $data['provider'], $data['identifier'] ?? null, $data['location'] ?? null, $data['identity'] ?? null, ); } 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->mailManager->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); } return $this->mailManager->serviceUpdate( $tenantId, $userId, $data['provider'], $data['identifier'], $data['data'] ); } 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->mailManager->serviceDelete( $tenantId, $userId, $data['provider'], $data['identifier'] ); } // ==================== Collection Operations ==================== private function collectionList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { $sources = new SourceSelector(); $sources->jsonDeserialize($data['sources']); } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; return $this->mailManager->collectionList($tenantId, $userId, $sources, $filter, $sort); } private function collectionExtant(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); return $this->mailManager->collectionExtant($tenantId, $userId, $sources); } private function collectionFetch(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['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier']) && !is_int($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); } return $this->mailManager->collectionFetch( $tenantId, $userId, $data['provider'], $data['service'], $data['identifier'] ); } 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['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) { throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } return $this->mailManager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], $data['collection'] ?? null, $data['properties'] ); } private function collectionModify(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['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier']) && !is_int($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } return $this->mailManager->collectionModify( $tenantId, $userId, $data['provider'], $data['service'], $data['identifier'], $data['properties'] ); } private function collectionDestroy(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['identifier'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } if (!is_string($data['identifier']) && !is_int($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } return $this->mailManager->collectionDestroy( $tenantId, $userId, $data['provider'], $data['service'], $data['identifier'], $data['options'] ?? [] ); } // ==================== Entity Operations ==================== private function entityList(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; return $this->mailManager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); } private function entityDelta(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); return $this->mailManager->entityDelta($tenantId, $userId, $sources); } private function entityExtant(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 = new SourceSelector(); $sources->jsonDeserialize($data['sources']); return $this->mailManager->entityExtant($tenantId, $userId, $sources); } private function entityFetch(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['collection'])) { throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); } if (!is_string($data['collection']) && !is_int($data['collection'])) { throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); } if (!isset($data['identifiers'])) { throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS); } if (!is_array($data['identifiers'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS); } return $this->mailManager->entityFetch( $tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifiers'] ); } private function entityTransmit(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); } $jobId = $this->mailManager->entityTransmit( $tenantId, $userId, $data['provider'], $data['service'], $data['data'] ); return ['jobId' => $jobId]; } }