diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 74b6b93..cb30001 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -21,7 +21,10 @@ 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\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Selector\CollectionSelector; +use KTXF\Resource\Selector\ServiceSelector; use KTXF\Resource\Selector\SourceSelector; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\MailManager\Manager; @@ -41,6 +44,7 @@ class DefaultController extends ControllerAbstract { 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_SOURCE = 'Missing parameter: source'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; private const ERR_MISSING_TARGET = 'Missing parameter: target'; private const ERR_MISSING_TARGETS = 'Missing parameter: targets'; @@ -51,6 +55,7 @@ class DefaultController extends ControllerAbstract { 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_SOURCE = 'Invalid parameter: source must be a string'; 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_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; @@ -410,8 +415,14 @@ class DefaultController extends ControllerAbstract { 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']); + // TODO: Refactor to use identifiers directly + $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'); + } + } + $sources = $this->createSourceSelectorFromIdentifiers($sources); } $filter = $data['filter'] ?? null; @@ -421,44 +432,50 @@ class DefaultController extends ControllerAbstract { } private function collectionFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + + // TODO: Refactor to use identifiers directly + $targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targetIdentifiers as $targetIdentifier) { + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + + $list = []; + foreach ($targetIdentifiers as $targetIdentifier) { + $list[(string)$targetIdentifier] = $this->mailManager->collectionFetch( + $tenantId, + $userId, + $targetIdentifier->provider(), + $targetIdentifier->service(), + $targetIdentifier->collection() + ); } - 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'] - ); + return $list; } private function collectionExtant(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 = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + // TODO: Refactor to use identifiers directly + $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'); + } + } + $sources = $this->createSourceSelectorFromIdentifiers($sources); return $this->mailManager->collectionExtant($tenantId, $userId, $sources); } @@ -476,8 +493,8 @@ class DefaultController extends ControllerAbstract { 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['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); @@ -485,35 +502,30 @@ class DefaultController extends ControllerAbstract { 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->mailManager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], - $data['collection'] ?? null, + $targetIdentifier->collection() ?? null, $data['properties'] ); } private function collectionUpdate(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - 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 (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); @@ -521,45 +533,34 @@ class DefaultController extends ControllerAbstract { 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->mailManager->collectionUpdate( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['identifier'], + $targetIdentifier, $data['properties'] ); } private function collectionDelete(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - 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); + + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } - $result = $this->mailManager->collectionDelete( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $data['options'] ?? [] - ); + $result = $this->mailManager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] ); if (is_bool($result)) { return [ @@ -585,10 +586,10 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['source'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCE); + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } if (!is_string($data['source'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCE); + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } $target = ResourceIdentifier::fromString($data['target']); @@ -615,8 +616,14 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['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'); + } + } + + $sources = $this->createSourceSelectorFromIdentifiers($sources); $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; @@ -634,8 +641,14 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['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'); + } + } + + $sources = $this->createSourceSelectorFromIdentifiers($sources); $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; @@ -671,6 +684,50 @@ class DefaultController extends ControllerAbstract { return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']); } + private function createSourceSelectorFromIdentifiers(ResourceIdentifiers $identifiers): SourceSelector { + $sources = new SourceSelector(); + + foreach ($identifiers as $identifier) { + if (!$identifier instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + + $provider = $identifier->provider(); + $service = $identifier->service(); + + if (!isset($sources[$provider])) { + $sources[$provider] = new ServiceSelector(); + } + + $serviceSelector = $sources[$provider]; + if (!$serviceSelector instanceof ServiceSelector) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection selectors'); + } + + if ($identifier instanceof ServiceIdentifier && !$identifier instanceof CollectionIdentifier) { + $serviceSelector[$service] = true; + continue; + } + + if (isset($serviceSelector[$service]) && $serviceSelector[$service] === true) { + continue; + } + + if (!isset($serviceSelector[$service])) { + $serviceSelector[$service] = new CollectionSelector(); + } + + $collectionSelector = $serviceSelector[$service]; + if (!$collectionSelector instanceof CollectionSelector) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection selectors'); + } + + $collectionSelector[$identifier->collection()] = true; + } + + return $sources; + } + private function entityFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); diff --git a/lib/Manager.php b/lib/Manager.php index 2a84394..56a7897 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use KTXC\Resource\ProviderManager; use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionMutableInterface; +use KTXF\Mail\Collection\CollectionPropertiesMutableInterface; use KTXF\Mail\Collection\ICollectionBase; use KTXF\Mail\Entity\Address; use KTXF\Mail\Entity\IMessageBase; @@ -21,7 +22,9 @@ use KTXF\Mail\Queue\SendOptions; use KTXF\Mail\Service\IServiceSend; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; +use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; @@ -266,6 +269,9 @@ class Manager { if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } + if ($service instanceof ServiceMutableInterface === false) { + throw new InvalidArgumentException("Service '$serviceId' is not mutable and cannot be updated"); + } // Update with new data $service->jsonDeserialize($data, $delta); @@ -416,7 +422,7 @@ class Manager { throw new InvalidArgumentException('Service identity not valid'); } - /** @var ServiceMutableInterface $service */ + /** @var ServiceConfigurableInterface|ServiceMutableInterface $service */ $service = $provider->serviceFresh(); if ($location instanceof ResourceServiceLocationInterface === false) { $location = $service->freshLocation($location['type'], (array)$location); @@ -572,18 +578,16 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int|null $collectionId collection identifier (parent collection) - * @param CollectionMutableInterface|array $object collection to create + * @param CollectionIdentifier $target target parent collection identifier + * @param CollectionPropertiesMutableInterface|array $properties properties for the new collection * @param array $options additional options for creation * * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { + public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + $service = $this->serviceFetch($tenantId, $userId, $provider, $service); // Check if service supports collection creation if (!($service instanceof ServiceCollectionMutableInterface)) { @@ -593,15 +597,14 @@ class Manager { throw new InvalidArgumentException("Service is not capable of creating collections"); } - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); + if (is_array($properties)) { + $collection = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } else { - $collection = $object; + $collection = $properties; } // Create collection - return $service->collectionCreate($collectionId, $collection, $options); + return $service->collectionCreate($target, $collection, $options); } /** @@ -609,17 +612,15 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param CollectionMutableInterface|array $object collection to modify + * @param CollectionIdentifier $target target collection identifier + * @param CollectionPropertiesMutableInterface|array $properties properties to modify * * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection creation if (!($service instanceof ServiceCollectionMutableInterface)) { @@ -629,15 +630,14 @@ class Manager { throw new InvalidArgumentException("Service is not capable of updating collections"); } - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); + if (is_array($properties)) { + $mutation = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } else { - $collection = $object; + $mutation = $properties; } // Update collection - return $service->collectionUpdate($collectionId, $collection); + return $service->collectionUpdate($target, $mutation); } /** @@ -647,15 +647,14 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier + * @param CollectionIdentifier $target Target collection identifier + * @param array $options Additional options for deletion (e.g., 'force' => true to force delete even if not empty) * * @return CollectionBaseInterface|null */ - public function collectionDelete(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): CollectionBaseInterface | bool { + public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection deletion if (!($service instanceof ServiceCollectionMutableInterface)) { @@ -668,7 +667,7 @@ class Manager { $force = $options['force'] ?? false; // delete collection - return $service->collectionDelete($collectionId, $force); + return $service->collectionDelete($target, $force); } /** @@ -704,7 +703,7 @@ class Manager { } // move collection - return $service->collectionMove($source->collection(), $target->collection()); + return $service->collectionMove($target, $source); } // ==================== Message Operations ==================== @@ -1052,7 +1051,6 @@ class Manager { } // Temporarily disabled check until all methods are properly implemented from ServiceEntityMutableInterface - /* if (!($service instanceof ServiceEntityMutableInterface)) { foreach ($serviceSources as $identifier) { $operationOutcome[(string)$identifier] = [ @@ -1072,7 +1070,6 @@ class Manager { } continue; } - */ return $service->entityDelete(...$serviceSources->all()); } @@ -1170,12 +1167,12 @@ class Manager { $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports entity move - // Temporarily disabled check until all methods are properly implemented from ServiceEntityMutableInterface - /* if ($targetService instanceof ServiceEntityMutableInterface === false) { return []; } - */ + if (!$targetService->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE)) { + return []; + } $operationOutcome = []; @@ -1190,128 +1187,4 @@ class Manager { return $operationOutcome; } - /** - * Send a mail message - * - * Routes the message to the appropriate service based on the `from` address. - * By default, messages are queued; use SendOptions::immediate() for urgent messages. - * - * @since 2025.05.01 - * - * @param string $tenantId Tenant identifier - * @param string|null $userId User identifier for context - * @param IMessageMutable $message Message to send - * @param SendOptions|null $options Delivery options (defaults to queued) - * - * @return string Job ID for queued messages, or Message ID for immediate sends - * - * @throws SendException On immediate send failure - * @throws InvalidArgumentException If no suitable service found - */ - public function entityTransmit(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, array $data): string { - $options = $options ?? new SendOptions(); - - // Find the appropriate service - $from = $message->getFrom(); - if ($from !== null) { - $service = $this->serviceFindByAddress($tenantId, $userId, $from->getAddress()); - } - if ($service === null) { - throw new InvalidArgumentException('No mail service found for the message sender address'); - } - - // Verify service can send - if (!($service instanceof IServiceSend) || !$service->capable(IServiceSend::CAPABILITY_SEND)) { - throw new InvalidArgumentException('Selected mail service does not support sending'); - } - - // replace internal address for external 'from' - $message->setFrom((new Address())->setAddress('system@ktrix.local')); - - // Immediate send bypasses queue - if ($options->immediate) { - $this->logger->debug('Sending mail immediately', [ - 'tenant' => $tenantId, - 'provider' => $service->in(), - 'service' => $service->id(), - 'to' => array_map(fn($a) => $a->getAddress(), $message->getTo()), - ]); - - return $service->messageSend($message); - } - - // Queue the message - $jobId = $this->queue->enqueue( - $tenantId, - $service->in(), - $service->id(), - $message, - $options - ); - - $this->logger->debug('Mail queued for delivery', [ - 'tenant' => $tenantId, - 'jobId' => $jobId, - 'provider' => $service->in(), - 'service' => $service->id(), - 'priority' => $options->priority, - ]); - - return $jobId; - } - - - /** - * Process queued mail for a tenant - * - * Called by the mail daemon to process pending messages. - * - * @since 2025.05.01 - * - * @param string $tenantId Tenant identifier - * @param int $batchSize Maximum messages to process - * - * @return array{processed: int, failed: int} - */ - public function queueProcess(string $tenantId, int $batchSize = 50): array { - $processed = 0; - $failed = 0; - - $jobs = $this->queue->dequeue($tenantId, $batchSize); - - foreach ($jobs as $job) { - try { - $service = $this->serviceFetch($tenantId, null, $job->providerId, $job->serviceId); - - if ($service === null || !($service instanceof IServiceSend)) { - throw new SendException("Service not found or cannot send: {$job->providerId}/{$job->serviceId}"); - } - - $messageId = $service->messageSend($job->message); - $this->queue->acknowledge($job->id, $messageId); - $processed++; - - $this->logger->debug('Mail sent from queue', [ - 'tenant' => $tenantId, - 'jobId' => $job->id, - 'messageId' => $messageId, - ]); - - } catch (\Throwable $e) { - $isPermanent = $e instanceof SendException && $e->permanent; - $this->queue->reject($job->id, $e->getMessage(), !$isPermanent); - $failed++; - - $this->logger->warning('Mail send failed', [ - 'tenant' => $tenantId, - 'jobId' => $job->id, - 'error' => $e->getMessage(), - 'permanent' => $isPermanent, - ]); - } - } - - return ['processed' => $processed, 'failed' => $failed]; - } - } diff --git a/src/models/collection.ts b/src/models/collection.ts index e014abb..f5ac01b 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -4,6 +4,7 @@ import type { CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface, CollectionPropertiesModelInterface } from "@/types/collection"; import { clonePlain } from './clone-plain'; +import type { CollectionIdentifier, ServiceIdentifier } from "@/services"; export class CollectionObject implements CollectionModelInterface { @@ -49,16 +50,16 @@ export class CollectionObject implements CollectionModelInterface { return this._data.provider; } - get service(): string | number { - return this._data.service; + get service(): ServiceIdentifier { + return this._data.service as ServiceIdentifier; } - get collection(): string | number | null { - return this._data.collection; + get collection(): CollectionIdentifier | null { + return this._data.collection as CollectionIdentifier | null; } - get identifier(): string | number { - return this._data.identifier; + get identifier(): CollectionIdentifier { + return this._data.identifier as CollectionIdentifier; } get signature(): string | null | undefined { diff --git a/src/models/entity.ts b/src/models/entity.ts index 510e21a..d569d0e 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -6,6 +6,7 @@ import type { EntityInterface, EntityModelInterface } from "@/types/entity"; import type { MessageInterface } from "@/types/message"; import { MessageObject } from "./message"; import { clonePlain } from './clone-plain'; +import type { CollectionIdentifier, EntityIdentifier, ServiceIdentifier } from "@/services"; export class EntityObject implements EntityModelInterface { @@ -18,8 +19,8 @@ export class EntityObject implements EntityModelInterface { version: 1, provider: '', service: '', - collection: '', - identifier: '', + collection: null, + identifier: null, signature: null, created: null, modified: null, @@ -54,16 +55,16 @@ export class EntityObject implements EntityModelInterface { return this._data.provider; } - get service(): string { - return this._data.service; + get service(): ServiceIdentifier { + return this._data.service as ServiceIdentifier; } - get collection(): string|number { - return this._data.collection; + get collection(): CollectionIdentifier { + return this._data.collection as CollectionIdentifier; } - get identifier(): string|number { - return this._data.identifier; + get identifier(): EntityIdentifier { + return this._data.identifier as EntityIdentifier; } get signature(): string | null { diff --git a/src/models/message.ts b/src/models/message.ts index 95812a2..8ea9e58 100644 --- a/src/models/message.ts +++ b/src/models/message.ts @@ -48,38 +48,46 @@ export class MessageObject implements MessageModelInterface { /** Properties */ + get size(): number { + return this._data.size ?? 0; + } + + get headers(): Record { + return clonePlain(this._data.headers ?? {}); + } + get urid(): string | null{ return this._data.urid ?? null; } - get size(): number { - return this._data.size ?? 0; + get inReplyTo(): string | null { + return this._data.inReplyTo ?? null; } - get receivedDate(): string | null { - return this._data.receivedDate ?? null; + get references(): string | null { + return this._data.references ?? null; } - get sentDate(): string | null { - return this._data.sentDate ?? null; + get received(): string | null { + return this._data.received ?? null; } - get date(): string | null { - return this._data.date ?? null; + get sent(): string | null { + return this._data.sent ?? null; } - get subject(): string | null { - return this._data.subject ?? null; - } - - get snippet(): string | null { - return this._data.snippet ?? null; + get sender(): MessageAddressObject | null { + return this._data.sender ? new MessageAddressObject(this._data.sender) : null; } get from(): MessageAddressObject | null { return this._data.from ? new MessageAddressObject(this._data.from) : null; } + get replyTo(): Array | null { + return this._data.replyTo ? this._data.replyTo.map(addr => new MessageAddressObject(addr)) : null; + } + get to(): Array | null { return this._data.to ? this._data.to.map(addr => new MessageAddressObject(addr)) : null; } @@ -92,12 +100,8 @@ export class MessageObject implements MessageModelInterface { return this._data.bcc ? this._data.bcc.map(addr => new MessageAddressObject(addr)) : null; } - get replyTo(): Array | null { - return this._data.replyTo ? this._data.replyTo.map(addr => new MessageAddressObject(addr)) : null; - } - - get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | {} { - return clonePlain(this._data.flags ?? {}); + get subject(): string | null { + return this._data.subject ?? null; } get body(): MessagePartObject | null { @@ -115,6 +119,10 @@ export class MessageObject implements MessageModelInterface { return this._data.attachments ? this._data.attachments.map(att => new MessagePartObject(att)) : []; } + get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | {} { + return clonePlain(this._data.flags ?? {}); + } + /** Helper methods */ get isRead(): boolean { diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index c9f0d24..a985b1d 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -72,9 +72,16 @@ export const collectionService = { * * @returns Promise with collection object */ - async fetch(request: CollectionFetchRequest): Promise { + async fetch(request: CollectionFetchRequest): Promise> { const response = await transceivePost('collection.fetch', request); - return createCollectionObject(response); + + // Convert response to CollectionObject instances + const list: Record = {}; + Object.entries(response).forEach(([identifier, entity]) => { + list[entity.identifier] = createCollectionObject(entity); + }); + + return list; }, /** @@ -128,8 +135,8 @@ export const collectionService = { async delete(request: CollectionDeleteRequest): Promise { const response = await transceivePost('collection.delete', request); - if (response.outcome === 'moved' && response.data) { - return createCollectionObject(response.data); + if (response.disposition === 'moved' && response.mutation) { + return createCollectionObject(response.mutation); } return true; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 4db39b4..055f2bd 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -87,8 +87,8 @@ export const entityService = { // Convert response to EntityObject instances const list: Record = {}; - Object.entries(response).forEach(([identifier, entityData]) => { - list[identifier] = createEntityObject(entityData); + Object.entries(response).forEach(([identifier, entity]) => { + list[entity.identifier] = createEntityObject(entity); }); return list; @@ -184,10 +184,7 @@ export const entityService = { * * @returns Promise resolving to { total } when the stream completes */ - async stream( - request: EntityStreamRequest, - onEntity: (entity: EntityObject) => void - ): Promise<{ total: number }> { + async stream(request: EntityStreamRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> { return await transceiveStream( 'entity.stream', request, diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 66711e5..3223983 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -4,13 +4,16 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' -import { collectionService, entityService } from '../services' +import { + type ServiceIdentifier, + type CollectionIdentifier, + type ListFilter, + type ListSort, + collectionService, +} from '../services' import { CollectionObject, CollectionPropertiesObject } from '../models/collection' -import type { SourceSelector, ListFilter, ListSort, CollectionIdentifier, CollectionMoveResponse } from '../types' export const useCollectionsStore = defineStore('mailCollectionsStore', () => { - const ROOT_IDENTIFIER = '__root__' - const SERVICE_INDEX_IDENTIFIER = '__service__' // State const _collections = ref>({}) @@ -67,14 +70,13 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Collection object or null */ - function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null { - const key = identifierKey(provider, service, identifier) - if (retrieve === true && !_collections.value[key]) { - console.debug(`[Mail Manager][Store] - Force fetching collection "${key}"`) - fetch(provider, service, identifier) + function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null { + if (retrieve === true && !_collections.value[target]) { + console.debug(`[Mail Manager][Store] - Force fetching collection "${target}"`) + fetch([target]) } - return _collections.value[key] || null + return _collections.value[target] || null } /** @@ -87,18 +89,14 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * @returns Array of collection objects */ function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] { + const serviceIdentifier = `${provider}:${service}` as ServiceIdentifier const serviceCollections = collectionObjectsForKeys( - _collectionsByServiceIndex.value[identifierKey(provider, service, SERVICE_INDEX_IDENTIFIER)] ?? [], + _collectionsByServiceIndex.value[serviceIdentifier] ?? [], ) if (retrieve === true && serviceCollections.length === 0) { - console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: true - } - } - list(sources) + console.debug(`[Mail Manager][Store] - Force fetching collections for service "${serviceIdentifier}"`) + list([serviceIdentifier]) } return serviceCollections @@ -114,33 +112,23 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Array of direct child collection objects */ - function collectionsInCollection(provider: string, service: string | number, collectionId: string | number | null, retrieve: boolean = false): CollectionObject[] { + function collectionsInCollection(provider: string, service: string | number, collection?: CollectionIdentifier | null, retrieve: boolean = false): CollectionObject[] { + const collectionIdentifier = collection ?? `${provider}:${service}` as CollectionIdentifier const nestedCollections = collectionObjectsForKeys( - _collectionsByParentIndex.value[identifierKey(provider, service, collectionId)] ?? [], + _collectionsByParentIndex.value[collectionIdentifier] ?? [], ) if (retrieve === true && nestedCollections.length === 0) { - console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: true - } - } - list(sources) + console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${collectionIdentifier}"`) + list([collectionIdentifier]) } return nestedCollections } - function hasChildrenInCollection(provider: string, service: string | number, collectionId: string | number | null): boolean { - return (_collectionsByParentIndex.value[identifierKey(provider, service, collectionId)]?.length ?? 0) > 0 - } - - /** - * Create unique key for a collection - */ - function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string { - return `${provider}:${String(service ?? ROOT_IDENTIFIER)}:${String(identifier ?? ROOT_IDENTIFIER)}` + function hasChildrenInCollection(provider: string, service: string | number, collection: CollectionIdentifier | null): boolean { + const collectionIdentifier = collection ?? `${provider}:${service}` as CollectionIdentifier + return (_collectionsByParentIndex.value[collectionIdentifier]?.length ?? 0) > 0 } function collectionObjectsForKeys(collectionKeys: string[]): CollectionObject[] { @@ -149,6 +137,16 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { .filter((collection): collection is CollectionObject => collection !== undefined) } + function indexCollection(collection: CollectionObject) { + addIndexEntry(_collectionsByServiceIndex.value, String(collection.service), String(collection.identifier)) + addIndexEntry(_collectionsByParentIndex.value, String(collection.collection ?? collection.service), String(collection.identifier)) + } + + function deindexCollection(collection: CollectionObject) { + removeIndexEntry(_collectionsByServiceIndex.value, String(collection.service), String(collection.identifier)) + removeIndexEntry(_collectionsByParentIndex.value, String(collection.collection ?? collection.service), String(collection.identifier)) + } + function addIndexEntry(index: Record, indexKey: string, collectionKey: string) { const existing = index[indexKey] ?? [] @@ -176,24 +174,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { index[indexKey] = filtered } - function indexCollection(collection: CollectionObject) { - const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier) - const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER) - const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection) - - addIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey) - addIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey) - } - - function deindexCollection(collection: CollectionObject) { - const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier) - const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER) - const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection) - - removeIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey) - removeIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey) - } - // Actions /** @@ -205,7 +185,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Promise with collection object list keyed by provider, service, and collection identifier */ - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise> { + async function list(sources?: ServiceIdentifier[] | CollectionIdentifier[], filter?: ListFilter, sort?: ListSort): Promise> { transceiving.value = true try { const response = await collectionService.list({ sources, filter, sort }) @@ -215,14 +195,11 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { Object.entries(response).forEach(([_providerId, providerServices]) => { Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => { Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => { - const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier) - const previousCollection = _collections.value[key] - - if (previousCollection) { - deindexCollection(previousCollection) + if (_collections.value[collectionObj.identifier]) { + deindexCollection(_collections.value[collectionObj.identifier]) } - collections[key] = collectionObj + collections[collectionObj.identifier] = collectionObj }) }) }) @@ -252,26 +229,25 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Promise with collection object */ - async function fetch(provider: string, service: string | number, identifier: string | number): Promise { + async function fetch(targets: CollectionIdentifier[]): Promise> { transceiving.value = true try { - const response = await collectionService.fetch({ provider, service, collection: identifier }) + const response = await collectionService.fetch({ targets }) // Merge fetched collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - const previousCollection = _collections.value[key] + Object.values(response).forEach(collectionObj => { + if (_collections.value[collectionObj.identifier]) { + deindexCollection(_collections.value[collectionObj.identifier]) + } - if (previousCollection) { - deindexCollection(previousCollection) - } + _collections.value[collectionObj.identifier] = collectionObj + indexCollection(collectionObj) + }) - _collections.value[key] = response - indexCollection(response) - - console.debug('[Mail Manager][Store] - Successfully fetched collection:', key) + console.debug('[Mail Manager][Store] - Successfully fetched collections:', Object.keys(response).join(', ')) return response } catch (error: any) { - console.error('[Mail Manager][Store] - Failed to fetch collection:', error) + console.error('[Mail Manager][Store] - Failed to fetch collections:', error) throw error } finally { transceiving.value = false @@ -285,12 +261,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Promise with collection availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: CollectionIdentifier[]): Promise>>> { transceiving.value = true try { - const response = await collectionService.extant({ sources }) + const response = await collectionService.extant({ targets }) - console.debug('[Mail Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections') + console.debug('[Mail Manager][Store] - Successfully checked', targets ? targets.length : 0, 'collections') return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to check collections:', error) @@ -310,22 +286,21 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { * * @returns Promise with created collection object */ - async function create(provider: string, service: string | number, collection: string | number | null, data: CollectionPropertiesObject): Promise { + async function create(provider: string, service: string | number, properties: CollectionPropertiesObject, target?: CollectionIdentifier): Promise { transceiving.value = true try { - const response = await collectionService.create({ - provider, - service, - collection, - properties: data + const response = await collectionService.create({ + provider, + service, + target, + properties: properties.toJson() }) // Merge created collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - _collections.value[key] = response + _collections.value[response.identifier] = response indexCollection(response) - console.debug('[Mail Manager][Store] - Successfully created collection:', key) + console.debug('[Mail Manager][Store] - Successfully created collection:', response.identifier) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to create collection:', error) @@ -336,37 +311,29 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { } /** - * Update an existing collection with given provider, service, identifier, and data + * Update an existing collection with given target and properties * - * @param provider - provider identifier for the collection to update - * @param service - service identifier for the collection to update - * @param identifier - collection identifier for the collection to update - * @param data - collection properties for update + * @param target - collection identifier for the collection to update + * @param properties - collection properties for update * * @returns Promise with updated collection object */ - async function update(provider: string, service: string | number, identifier: string | number, data: CollectionPropertiesObject): Promise { + async function update(target: CollectionIdentifier, properties: CollectionPropertiesObject): Promise { transceiving.value = true try { const response = await collectionService.update({ - provider, - service, - identifier, - properties: data + target, + properties: properties.toJson() }) - // Merge updated collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - const previousCollection = _collections.value[key] - - if (previousCollection) { - deindexCollection(previousCollection) + if (_collections.value[response.identifier]) { + deindexCollection(_collections.value[response.identifier]) } - _collections.value[key] = response + _collections.value[response.identifier] = response indexCollection(response) - console.debug('[Mail Manager][Store] - Successfully updated collection:', key) + console.debug('[Mail Manager][Store] - Successfully updated collection:', response.identifier) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to update collection:', error) @@ -377,45 +344,38 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { } /** - * Delete a collection by provider, service, and identifier + * Delete a collection by identifier, with optional force delete if collection is not empty. * - * @param provider - provider identifier for the collection to delete - * @param service - service identifier for the collection to delete - * @param identifier - collection identifier for the collection to delete + * @param target - collection identifier for the collection to delete + * @param force - optional flag to force delete if collection is not empty * * @returns Promise with deletion result */ - async function remove(provider: string, service: string | number, identifier: string | number): Promise { + async function remove(target: CollectionIdentifier, force?: boolean): Promise { transceiving.value = true try { - const response = await collectionService.delete({ provider, service, identifier }) + const response = await collectionService.delete({ target, options: { force } }) if (response !== true && !(response instanceof CollectionObject)) { console.warn('[Mail Manager][Store] - Delete failed. Received unexpected response from delete operation:', response) return false } - const key = identifierKey(provider, service, identifier) - const previousCollection = _collections.value[key] - - if (previousCollection) { - deindexCollection(previousCollection) + if (_collections.value[target]) { + deindexCollection(_collections.value[target]) } - delete _collections.value[key] + delete _collections.value[target] if (response instanceof CollectionObject) { - const movedCollection = response - const movedKey = identifierKey(movedCollection.provider, movedCollection.service, movedCollection.identifier) + _collections.value[response.identifier] = response + indexCollection(response) - _collections.value[movedKey] = movedCollection - indexCollection(movedCollection) - - console.debug('[Mail Manager][Store] - Successfully moved collection to trash', key, '->', movedKey) + console.debug('[Mail Manager][Store] - Successfully moved collection to trash', target, '->', response.identifier) return response } - console.debug('[Mail Manager][Store] - Successfully deleted collection:', key) + console.debug('[Mail Manager][Store] - Successfully deleted collection:', target) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to delete collection:', error) @@ -446,21 +406,15 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { throw new Error('Failed to move collection: unexpected response from move operation') } - const sourceCollection = _collections.value[source] - - if (sourceCollection) { - deindexCollection(sourceCollection) + if (_collections.value[source]) { + deindexCollection(_collections.value[source]) } - delete _collections.value[source] - const movedCollection = response - const movedKey = identifierKey(movedCollection.provider, movedCollection.service, movedCollection.identifier) + _collections.value[response.identifier] = response + indexCollection(response) - _collections.value[movedKey] = movedCollection - indexCollection(movedCollection) - - console.debug('[Mail Manager][Store] - Successfully moved collection:', source, ' to ', movedKey) + console.debug('[Mail Manager][Store] - Successfully moved collection:', source, ' to ', response.identifier) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to move collection:', error) diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 7be7ad5..b32ec3e 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -5,10 +5,8 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { entityService } from '../services' -import { EntityObject } from '../models' +import { EntityObject, MessageObject } from '../models' import type { - EntityDeleteResponse, - EntityMoveResponse, EntityStreamRequest, EntityTransmitRequest, EntityTransmitResponse, @@ -19,8 +17,8 @@ import type { ListFilter, ListRange, ListSort, - SourceSelector, } from '../types/common' +import type { MessageInterface } from '@/types/message' export const useEntitiesStore = defineStore('mailEntitiesStore', () => { // State @@ -53,43 +51,13 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Entity object or null */ - function entity(provider: string, service: string | number, collection: string | number, identifier: string | number, retrieve: boolean = false): EntityObject | null { - const key = identifierKey(provider, service, collection, identifier) - if (retrieve === true && !_entities.value[key]) { - console.debug(`[Mail Manager][Store] - Force fetching entity "${key}"`) - fetch(provider, service, collection, [identifier]) + function entity(target: EntityIdentifier, retrieve: boolean = false): EntityObject | null { + if (retrieve === true && !_entities.value[target]) { + console.debug(`[Mail Manager][Store] - Force fetching entity "${target}"`) + fetch([target]) } - return _entities.value[key] || null - } - - /** - * Resolve an entity from cache by full entity identifier. - */ - function entityByIdentifier(identifier: EntityIdentifier, retrieve: boolean = false): EntityObject | null { - if (retrieve === true && !_entities.value[identifier]) { - console.debug(`[Mail Manager][Store] - Force fetching entity "${identifier}"`) - const { provider, service, collection, identifier: id } = parseEntityIdentifier(identifier) - fetch(provider, service, collection, [id]) - } - - return _entities.value[identifier] || null - } - - /** - * Resolve multiple entities from cache by full entity identifiers. - */ - function entitiesByIdentifiers(identifiers: EntityIdentifier[], retrieve: boolean = false): Record { - const resolved: Record = {} as Record - - Array.from(new Set(identifiers)).forEach(identifier => { - const entity = entityByIdentifier(identifier, retrieve) - if (entity) { - resolved[identifier] = entity - } - }) - - return resolved + return _entities.value[target] || null } /** @@ -102,52 +70,19 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Array of entity objects */ - function entitiesForCollection(provider: string, service: string | number, collection: string | number, retrieve: boolean = false): EntityObject[] { - const collectionKeyPrefix = `${provider}:${service}:${collection}:` + function entitiesForCollection(target: CollectionIdentifier, retrieve: boolean = false): EntityObject[] { const collectionEntities = Object.entries(_entities.value) - .filter(([key]) => key.startsWith(collectionKeyPrefix)) + .filter(([key]) => key.startsWith(target)) .map(([_, entity]) => entity) if (retrieve === true && collectionEntities.length === 0) { - console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: { - [String(collection)]: true - } - } - } - list(sources) + console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${target}"`) + list([target]) } return collectionEntities } - /** - * Create unique key for an entity - */ - function identifierKey(provider: string, service: string | number, collection: string | number, identifier: string | number): string { - return `${provider}:${service}:${collection}:${identifier}` - } - - /** - * Parse a full entity identifier into its components. - */ - function parseEntityIdentifier(identifier: EntityIdentifier): { - provider: string - service: string - collection: string - identifier: string - } { - const [provider, service, collection, entity] = identifier.split(':', 4) - return { - provider, - service, - collection, - identifier: entity, - } - } - // Actions /** @@ -160,19 +95,18 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise with entity object list keyed by identifier */ - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { + async function list(sources: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { transceiving.value = true try { - const added: Record = {} + const entities: Record = {} await entityService.stream({ sources, filter, sort, range }, (entity: EntityObject) => { - const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier) - _entities.value[key] = entity - added[key] = entity + _entities.value[entity.identifier] = entity + entities[entity.identifier] = entity }) - console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(added).length, 'entities') - return added + console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities') + return entities } catch (error: any) { console.error('[Mail Manager][Store] - Failed to retrieve entities:', error) throw error @@ -191,17 +125,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise with entity objects keyed by identifier */ - async function fetch(provider: string, service: string | number, collection: string | number, identifiers: (string | number)[]): Promise> { + async function fetch(targets: EntityIdentifier[]): Promise> { transceiving.value = true try { - const response = await entityService.fetch({ provider, service, collection, identifiers }) + const response = await entityService.fetch({ targets }) // Merge fetched entities into state const entities: Record = {} - Object.entries(response).forEach(([identifier, entityData]) => { - const key = identifierKey(provider, service, collection, identifier) - entities[key] = entityData - _entities.value[key] = entityData + Object.entries(response).forEach(([identifier, entity]) => { + entities[identifier] = entity + _entities.value[identifier] = entity }) console.debug('[Mail Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities') @@ -215,16 +148,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } /** - * Retrieve entity availability status for a given source selector + * Retrieve entity availability status for a given set of entity identifiers * - * @param sources - source selector to check availability for + * @param targets - array of entity identifiers to check availability for * * @returns Promise with entity availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: EntityIdentifier[]) { transceiving.value = true try { - const response = await entityService.extant({ sources }) + const response = await entityService.extant({ targets }) console.debug('[Mail Manager][Store] - Successfully checked entity availability') return response } catch (error: any) { @@ -245,7 +178,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * Note: Delta returns only identifiers, not full entities. * Caller should fetch full entities for additions/modifications separately. */ - async function delta(sources: SourceSelector) { + async function delta(sources: CollectionIdentifier[]) { transceiving.value = true try { const response = await entityService.delta({ sources }) @@ -266,13 +199,9 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { // Process deletions (remove from store) if (collectionData.deletions && collectionData.deletions.length > 0) { collectionData.deletions.forEach((identifier) => { - const key = identifierKey(provider, service, collection, identifier) - delete _entities.value[key] + delete _entities.value[identifier] }) } - - // Note: additions and modifications contain only identifiers - // The caller should fetch full entities using the fetch() method }) }) }) @@ -288,25 +217,25 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } /** - * Create a new entity with given provider, service, collection, and data + * Create a new entity with given collection identifier and properties * - * @param provider - provider identifier for the new entity - * @param service - service identifier for the new entity - * @param collection - collection identifier for the new entity - * @param data - entity properties for creation + * @param target - collection identifier for the new entity + * @param properties - entity properties for creation * * @returns Promise with created entity object */ - async function create(provider: string, service: string | number, collection: string | number, data: any): Promise { + async function create(target: CollectionIdentifier, properties: MessageInterface | MessageObject): Promise { transceiving.value = true try { - const response = await entityService.create({ provider, service, collection, properties: data }) + if (properties instanceof MessageObject) { + properties = properties.toJson() + } + const response = await entityService.create({ target, properties }) // Add created entity to state - const key = identifierKey(response.provider, response.service, response.collection, response.identifier) - _entities.value[key] = response + _entities.value[response.identifier] = response - console.debug('[Mail Manager][Store] - Successfully created entity:', key) + console.debug('[Mail Manager][Store] - Successfully created entity:', response.identifier) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to create entity:', error) @@ -327,16 +256,18 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise with updated entity object */ - async function update(provider: string, service: string | number, collection: string | number, identifier: string | number, data: any): Promise { + async function update(target: EntityIdentifier, properties: MessageInterface | MessageObject): Promise { transceiving.value = true try { - const response = await entityService.update({ provider, service, collection, identifier, properties: data }) + if (properties instanceof MessageObject) { + properties = properties.toJson() + } + const response = await entityService.update({ target, properties }) // Update entity in state - const key = identifierKey(response.provider, response.service, response.collection, response.identifier) - _entities.value[key] = response + _entities.value[response.identifier] = response - console.debug('[Mail Manager][Store] - Successfully updated entity:', key) + console.debug('[Mail Manager][Store] - Successfully updated entity:', response.identifier) return response } catch (error: any) { console.error('[Mail Manager][Store] - Failed to update entity:', error) @@ -355,7 +286,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise with deletion results keyed by source identifier */ - async function remove(sources: EntityIdentifier[]): Promise { + async function remove(sources: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> { transceiving.value = true try { const response = await entityService.delete({ sources }) @@ -363,42 +294,39 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { const failures: EntityIdentifier[] = [] Object.entries(response).forEach(([sourceIdentifier, result]) => { + const originalIdentifier = sourceIdentifier as EntityIdentifier if (!result.disposition || result.disposition === 'error') { - console.warn(`[Mail Manager][Store] - Entity move on "${sourceIdentifier}" returned an error: ${result.error})`) - failures.push(sourceIdentifier) + console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) return } if (!result.disposition || (result.disposition !== 'moved' && result.disposition !== 'deleted')) { - console.warn(`[Mail Manager][Store] - Entity move on "${sourceIdentifier}" returned invalid disposition: ${result.disposition})`) - failures.push(sourceIdentifier) + console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`) + failures.push(originalIdentifier) return } - const cachedEntity = _entities.value[sourceIdentifier] + const cachedEntity = _entities.value[originalIdentifier] if (!cachedEntity) { return } if (result.disposition === 'moved') { - const mutation = parseEntityIdentifier(result.mutation?.identifier || sourceIdentifier) const movedEntity = cachedEntity.clone().fromJson({ ...cachedEntity.toJson(), - provider: mutation.provider, - service: mutation.service, - collection: mutation.collection, - identifier: mutation.identifier, + collection: result.destination, + identifier: result.mutation, }) - const key = identifierKey(mutation.provider, mutation.service, mutation.collection, mutation.identifier) - _entities.value[key] = movedEntity + _entities.value[result.mutation] = movedEntity } - delete _entities.value[sourceIdentifier] - successes.push(sourceIdentifier) + delete _entities.value[originalIdentifier] + successes.push(originalIdentifier) }) - console.debug('[Mail Manager][Store] - Successfully deleted', Object.keys(response).length, 'entities') - return [successes, failures] + console.debug('[Mail Manager][Store] - Successfully deleted', successes.length, 'entities') + return { successes, failures } } catch (error: any) { console.error('[Mail Manager][Store] - Failed to delete entities:', error) throw error @@ -418,7 +346,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise with move results keyed by source identifier */ - async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise { + async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> { transceiving.value = true try { const response = await entityService.move({ target, sources }) @@ -426,40 +354,37 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { const failures: EntityIdentifier[] = [] Object.entries(response).forEach(([sourceIdentifier, result]) => { + const originalIdentifier = sourceIdentifier as EntityIdentifier if (!result.disposition || result.disposition === 'error') { - console.warn(`[Mail Manager][Store] - Entity move on "${sourceIdentifier}" returned an error: ${result.error})`) - failures.push(sourceIdentifier) + console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) return } if (!result.disposition || result.disposition !== 'moved') { - console.warn(`[Mail Manager][Store] - Entity move on "${sourceIdentifier}" returned invalid disposition: ${result.disposition})`) - failures.push(sourceIdentifier) + console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`) + failures.push(originalIdentifier) return } - const cachedEntity = _entities.value[sourceIdentifier] + const cachedEntity = _entities.value[originalIdentifier] if (!cachedEntity) { return } - const mutation = parseEntityIdentifier(result.mutation?.identifier || sourceIdentifier) const movedEntity = cachedEntity.clone().fromJson({ ...cachedEntity.toJson(), - provider: mutation.provider, - service: mutation.service, - collection: mutation.collection, - identifier: mutation.identifier, + collection: result.destination, + identifier: result.mutation, }) - const movedKey = identifierKey(mutation.provider, mutation.service, mutation.collection, mutation.identifier) - _entities.value[movedKey] = movedEntity + _entities.value[result.mutation] = movedEntity - delete _entities.value[sourceIdentifier] - successes.push(sourceIdentifier) + delete _entities.value[originalIdentifier] + successes.push(originalIdentifier) }) - console.debug('[Mail Manager][Store] - Successfully moved', Object.keys(response).length, 'entities') - return [successes, failures] + console.debug('[Mail Manager][Store] - Successfully moved', successes.length, 'entities') + return { successes, failures } } catch (error: any) { console.error('[Mail Manager][Store] - Failed to move entities:', error) throw error @@ -502,18 +427,12 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * @returns Promise resolving to { total } when the stream completes */ - async function stream( - sources?: SourceSelector, - filter?: ListFilter, - sort?: ListSort, - range?: ListRange - ): Promise<{ total: number }> { + async function stream(sources?: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<{ total: number }> { transceiving.value = true try { const request: EntityStreamRequest = { sources, filter, sort, range } const result = await entityService.stream(request, (entity: EntityObject) => { - const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier) - _entities.value[key] = entity + _entities.value[entity.identifier] = entity }) console.debug('[Mail Manager][Store] - Successfully streamed', result.total, 'entities') return result @@ -534,9 +453,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { has, entities, entitiesForCollection, - entitiesByIdentifiers, entity, - entityByIdentifier, list, fetch, extant, diff --git a/src/types/collection.ts b/src/types/collection.ts index fcb9a7b..15ff465 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,7 +1,7 @@ /** * Collection type definitions */ -import type { CollectionIdentifier, ListFilter, ListSort, SourceSelector } from './common'; +import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier, SourceSelector } from './common'; /** * Collection information @@ -11,8 +11,8 @@ export interface CollectionInterface { version: number; provider: string; service: string | number; - collection: string | number | null; - identifier: string | number; + collection: CollectionIdentifier | null; + identifier: CollectionIdentifier; signature?: string | null; created?: string | null; modified?: string | null; @@ -47,7 +47,7 @@ export interface CollectionPropertiesModelInterface extends Omit { version: number; provider: string; service: string; - collection: string | number; - identifier: string | number; + collection: CollectionIdentifier; + identifier: EntityIdentifier; signature: string | null; created: string | null; modified: string | null; @@ -33,7 +32,7 @@ export interface EntityModelInterface extends Omit { - provider: string; - service: string | number; - collection: string | number; + target: CollectionIdentifier; properties: T; } @@ -116,10 +110,7 @@ export interface EntityCreateResponse extends EntityInterf * Entity update */ export interface EntityUpdateRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; + target: EntityIdentifier; properties: T; } @@ -193,7 +184,7 @@ export interface EntityTransmitResponse { * Entity stream */ export interface EntityStreamRequest { - sources?: SourceSelector; + sources?: CollectionIdentifier[]; filter?: ListFilter; sort?: ListSort; range?: ListRange; diff --git a/src/types/message.ts b/src/types/message.ts index a7b09fc..aec4153 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -10,21 +10,23 @@ export interface MessageModelInterface extends Omit<{ export interface MessageInterface { '@type': string; - urid?: string | null; size?: number | null; - date?: string | null; - receivedDate?: string | null; - sentDate?: string | null; - subject?: string | null; - snippet?: string | null; + headers?: Record | null; + urid?: string | null; + inReplyTo?: string | null; + references?: string | null; + received?: string | null; + sent?: string | null; + sender?: MessageAddressInterface | null; from?: MessageAddressInterface | null; + replyTo?: Array | null; to?: Array | null; cc?: Array | null; bcc?: Array | null; - replyTo?: Array | null; - flags?: MessageFlagsInterface | null; + subject?: string | null; body?: MessagePartInterface | null; attachments?: Array | []; + flags?: MessageFlagsInterface | null; } export interface MessageAddressInterface {