Initial commit

This commit is contained in:
root
2025-12-21 09:55:58 -05:00
committed by Sebastian Krupinski
commit 169b7b4c91
57 changed files with 10105 additions and 0 deletions

View File

@@ -0,0 +1,715 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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];
}
}