commit 169b7b4c9122ee21d2d132fe3c340e1be8f449a6 Author: root Date: Sun Dec 21 09:55:58 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..36986df --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ktxm/mail-manager", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "MailManager", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.2 <=8.5" + }, + "autoload": { + "psr-4": { + "KTXM\\MailManager\\": "lib/" + } + } +} diff --git a/docs/interfaces.md b/docs/interfaces.md new file mode 100644 index 0000000..5fab28e --- /dev/null +++ b/docs/interfaces.md @@ -0,0 +1,428 @@ +# Mail Manager - Interface Relationships + +This document visualizes all the interfaces in the mail_manager module and their relationships. + +## Overview + +The mail manager uses a hierarchical structure where interfaces are organized by their domain responsibilities: +- **Common Types**: Base types and selectors +- **Providers**: Mail service providers (Gmail, IMAP, etc.) +- **Services**: Individual mail accounts/services +- **Collections**: Mailboxes and folders +- **Messages**: Email messages and their parts + +--- + +## Complete Interface Diagram + +```mermaid +classDiagram + %% Common/Base Types + class SourceSelector { + +string provider + +string service + +string collection + +string message + } + + class ApiRequest~T~ { + +T data + } + + class ApiResponse~T~ { + +T data + +Error error + } + + class ListRange { + +number offset + +number limit + } + + %% Provider Interfaces + class ProviderInterface { + +string @type + +string identifier + +string label + +ProviderCapabilitiesInterface capabilities + } + + class ProviderCapabilitiesInterface { + +boolean ServiceList + +boolean ServiceFetch + +boolean ServiceExtant + +boolean ServiceCreate + +boolean ServiceModify + +boolean ServiceDestroy + +boolean ServiceDiscover + +boolean ServiceTest + } + + class ProviderListRequest { + +SourceSelector sources + } + + class ProviderListResponse { + +ProviderInterface[identifier] providers + } + + class ProviderFetchRequest { + +string identifier + } + + class ProviderFetchResponse { + <> + } + + class ProviderExtantRequest { + +SourceSelector sources + } + + class ProviderExtantResponse { + +boolean[identifier] exists + } + + %% Service Interfaces + class ServiceInterface { + +string @type + +string identifier + +string provider + +string label + +ServiceCapabilitiesInterface capabilities + +object configuration + } + + class ServiceCapabilitiesInterface { + +boolean CollectionList + +boolean CollectionFetch + +boolean CollectionExtant + +boolean CollectionCreate + +boolean CollectionModify + +boolean CollectionDestroy + } + + class ServiceListRequest { + +SourceSelector sources + +ListRange range + } + + class ServiceListResponse { + +ServiceInterface[identifier] services + } + + class ServiceFetchRequest { + +string provider + +string identifier + } + + class ServiceFetchResponse { + <> + } + + class ServiceExtantRequest { + +SourceSelector sources + } + + class ServiceExtantResponse { + +boolean[identifier] exists + } + + class ServiceCreateRequest { + +string provider + +string label + +object configuration + } + + class ServiceCreateResponse { + <> + } + + class ServiceModifyRequest { + +string provider + +string identifier + +string label + +object configuration + } + + class ServiceModifyResponse { + <> + } + + class ServiceDestroyRequest { + +string provider + +string identifier + } + + class ServiceDestroyResponse { + +boolean success + } + + %% Collection Interfaces + class CollectionInterface { + +string @type + +string identifier + +string service + +string provider + +string label + +CollectionCapabilitiesInterface capabilities + +string[] flags + +number messageCount + } + + class CollectionCapabilitiesInterface { + +boolean MessageList + +boolean MessageFetch + +boolean MessageExtant + +boolean MessageCreate + +boolean MessageModify + +boolean MessageDestroy + } + + class CollectionListRequest { + +SourceSelector sources + +ListRange range + } + + class CollectionListResponse { + +CollectionInterface[identifier] collections + } + + class CollectionFetchRequest { + +string provider + +string service + +string identifier + } + + class CollectionFetchResponse { + <> + } + + %% Message Interfaces + class MessageInterface { + +string @type + +string identifier + +string collection + +string service + +string provider + +string[] flags + +Date receivedDate + +Date internalDate + +MessageHeadersInterface headers + +MessagePartInterface[] parts + } + + class MessageHeadersInterface { + +string from + +string[] to + +string[] cc + +string[] bcc + +string subject + +string messageId + +string[] references + +string inReplyTo + +Date date + } + + class MessagePartInterface { + +string partId + +string mimeType + +string filename + +number size + +MessagePartInterface[] subParts + +object headers + +string body + } + + class MessageListRequest { + +SourceSelector sources + +ListRange range + +string[] flags + } + + class MessageListResponse { + +MessageInterface[identifier] messages + } + + class MessageFetchRequest { + +string provider + +string service + +string collection + +string identifier + } + + class MessageFetchResponse { + <> + } + + %% Relationships + ProviderInterface --> ProviderCapabilitiesInterface + ProviderFetchResponse --|> ProviderInterface + ProviderListResponse --> ProviderInterface + + ServiceInterface --> ServiceCapabilitiesInterface + ServiceFetchResponse --|> ServiceInterface + ServiceCreateResponse --|> ServiceInterface + ServiceModifyResponse --|> ServiceInterface + ServiceListResponse --> ServiceInterface + + CollectionInterface --> CollectionCapabilitiesInterface + CollectionFetchResponse --|> CollectionInterface + CollectionListResponse --> CollectionInterface + + MessageInterface --> MessageHeadersInterface + MessageInterface --> MessagePartInterface + MessagePartInterface --> MessagePartInterface : subParts + MessageFetchResponse --|> MessageInterface + MessageListResponse --> MessageInterface + + %% Selector Usage + ProviderListRequest --> SourceSelector + ProviderExtantRequest --> SourceSelector + ServiceListRequest --> SourceSelector + ServiceExtantRequest --> SourceSelector + CollectionListRequest --> SourceSelector + MessageListRequest --> SourceSelector +``` + +--- + +## Hierarchical Structure + +```mermaid +graph TD + A[SourceSelector] --> B[Provider Level] + B --> C[Service Level] + C --> D[Collection Level] + D --> E[Message Level] + + B --> B1[ProviderInterface] + B --> B2[ProviderCapabilities] + + C --> C1[ServiceInterface] + C --> C2[ServiceCapabilities] + + D --> D1[CollectionInterface] + D --> D2[CollectionCapabilities] + + E --> E1[MessageInterface] + E --> E2[MessageHeaders] + E --> E3[MessagePart] +``` + +--- + +## Request/Response Pattern + +All operations follow a consistent request/response pattern: + +```mermaid +sequenceDiagram + participant Client + participant API + participant Provider + + Client->>API: {Operation}Request + API->>Provider: Process Request + Provider->>API: Data + API->>Client: {Operation}Response +``` + +### Operations by Level: + +**Provider Level:** +- List, Fetch, Extant + +**Service Level:** +- List, Fetch, Extant, Create, Modify, Destroy + +**Collection Level:** +- List, Fetch, Extant, Create, Modify, Destroy + +**Message Level:** +- List, Fetch, Extant, Create, Modify, Destroy + +--- + +## Capability Inheritance + +```mermaid +graph LR + A[ProviderCapabilities] -->|enables| B[ServiceCapabilities] + B -->|enables| C[CollectionCapabilities] + C -->|enables| D[Message Operations] +``` + +Capabilities cascade down the hierarchy - if a provider doesn't support `ServiceList`, then no services can be listed for that provider. + +--- + +## Key Patterns + +### 1. **Extends Pattern** +Response interfaces extend their base interface: +- `ProviderFetchResponse extends ProviderInterface` +- `ServiceFetchResponse extends ServiceInterface` + +### 2. **Dictionary Pattern** +List responses use identifier as key: +```typescript +{ + [identifier: string]: Interface +} +``` + +### 3. **SourceSelector Pattern** +Resources are selected hierarchically: +```typescript +{ + provider: "gmail", + service: "user@example.com", + collection: "INBOX", + message: "msg123" +} +``` + +### 4. **Recursive Structure** +MessagePart can contain subParts: +```typescript +MessagePartInterface { + subParts?: MessagePartInterface[] +} +``` + +--- + +## Usage Examples + +### Selecting a specific message: +```typescript +const selector: SourceSelector = { + provider: "gmail", + service: "user@example.com", + collection: "INBOX", + message: "12345" +}; +``` + +### Listing all services for a provider: +```typescript +const request: ServiceListRequest = { + sources: { + provider: "gmail" + }, + range: { + offset: 0, + limit: 50 + } +}; +``` + +--- + +## Interface Files + +- `common.ts` - Base types and selectors +- `provider.ts` - Provider-level interfaces +- `service.ts` - Service-level interfaces +- `collection.ts` - Collection-level interfaces +- `message.ts` - Message-level interfaces diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php new file mode 100644 index 0000000..80acb34 --- /dev/null +++ b/lib/Controllers/DefaultController.php @@ -0,0 +1,715 @@ + + * 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]; + } + +} diff --git a/lib/Daemon/MailDaemon.php b/lib/Daemon/MailDaemon.php new file mode 100644 index 0000000..a73b981 --- /dev/null +++ b/lib/Daemon/MailDaemon.php @@ -0,0 +1,237 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\MailManager\Daemon; + +use KTXM\MailManager\Manager; +use Psr\Log\LoggerInterface; + +/** + * Mail Queue Daemon + * + * Long-running worker process for processing queued mail messages. + * Supports graceful shutdown via signals and configurable batch processing. + * + * @since 2025.05.01 + */ +class MailDaemon { + + private bool $running = false; + private bool $shutdown = false; + private bool $reload = false; + + /** + * @param Manager $manager Mail manager + * @param LoggerInterface $logger Logger + * @param int $pollInterval Seconds between queue polls when idle + * @param int $batchSize Messages to process per batch + * @param int|null $maxMemory Maximum memory usage in bytes before restart + * @param array|null $tenants Specific tenants to process (null = all) + */ + public function __construct( + private Manager $manager, + private LoggerInterface $logger, + private int $pollInterval = 5, + private int $batchSize = 50, + private ?int $maxMemory = null, + private ?array $tenants = null, + ) { + // Set default max memory to 128MB + $this->maxMemory = $this->maxMemory ?? (128 * 1024 * 1024); + } + + /** + * Run the daemon main loop + * + * @since 2025.05.01 + */ + public function run(): void { + $this->running = true; + $this->shutdown = false; + + $this->setupSignalHandlers(); + + $this->logger->info('Mail daemon starting', [ + 'pollInterval' => $this->pollInterval, + 'batchSize' => $this->batchSize, + 'maxMemory' => $this->formatBytes($this->maxMemory), + 'tenants' => $this->tenants ?? 'all', + ]); + + $consecutiveEmpty = 0; + + while (!$this->shutdown) { + // Handle reload signal + if ($this->reload) { + $this->handleReload(); + $this->reload = false; + } + + // Check memory usage + if ($this->isMemoryExceeded()) { + $this->logger->warning('Memory limit exceeded, shutting down for restart', [ + 'current' => $this->formatBytes(memory_get_usage(true)), + 'limit' => $this->formatBytes($this->maxMemory), + ]); + break; + } + + // Process queues + $processed = $this->processTenants(); + + if ($processed === 0) { + $consecutiveEmpty++; + // Exponential backoff up to poll interval + $sleepTime = min($consecutiveEmpty, $this->pollInterval); + $this->sleep($sleepTime); + } else { + $consecutiveEmpty = 0; + } + + // Dispatch pending signals + pcntl_signal_dispatch(); + } + + $this->logger->info('Mail daemon stopped'); + $this->running = false; + } + + /** + * Request graceful shutdown + * + * @since 2025.05.01 + */ + public function stop(): void { + $this->shutdown = true; + } + + /** + * Check if daemon is running + * + * @since 2025.05.01 + * + * @return bool + */ + public function isRunning(): bool { + return $this->running; + } + + /** + * Process all tenants and return total messages processed + */ + private function processTenants(): int { + $totalProcessed = 0; + $tenants = $this->tenants ?? $this->discoverTenants(); + + foreach ($tenants as $tenantId) { + if ($this->shutdown) { + break; + } + + $result = $this->manager->queueProcess($tenantId, $this->batchSize); + $totalProcessed += $result['processed'] + $result['failed']; + + if ($result['processed'] > 0 || $result['failed'] > 0) { + $this->logger->debug('Processed tenant queue', [ + 'tenant' => $tenantId, + 'processed' => $result['processed'], + 'failed' => $result['failed'], + ]); + } + } + + return $totalProcessed; + } + + /** + * Discover all tenants with mail queues + * + * @return array + */ + private function discoverTenants(): array { + // This would need to be implemented based on your tenant discovery mechanism + // For now, return empty array - specific tenants should be configured + return []; + } + + /** + * Setup signal handlers for graceful shutdown + */ + private function setupSignalHandlers(): void { + if (!function_exists('pcntl_signal')) { + $this->logger->warning('PCNTL extension not available, signal handling disabled'); + return; + } + + pcntl_signal(SIGTERM, function() { + $this->logger->info('Received SIGTERM, initiating graceful shutdown'); + $this->shutdown = true; + }); + + pcntl_signal(SIGINT, function() { + $this->logger->info('Received SIGINT, initiating graceful shutdown'); + $this->shutdown = true; + }); + + pcntl_signal(SIGHUP, function() { + $this->logger->info('Received SIGHUP, will reload configuration'); + $this->reload = true; + }); + } + + /** + * Handle configuration reload + */ + private function handleReload(): void { + $this->logger->info('Reloading configuration'); + // Configuration reload logic would go here + } + + /** + * Check if memory limit has been exceeded + */ + private function isMemoryExceeded(): bool { + if ($this->maxMemory === null) { + return false; + } + + return memory_get_usage(true) > $this->maxMemory; + } + + /** + * Sleep with signal dispatch + */ + private function sleep(int $seconds): void { + for ($i = 0; $i < $seconds; $i++) { + if ($this->shutdown) { + return; + } + sleep(1); + pcntl_signal_dispatch(); + } + } + + /** + * Format bytes to human readable string + */ + private function formatBytes(?int $bytes): string { + if ($bytes === null) { + return 'unlimited'; + } + + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 2) . ' ' . $units[$i]; + } + +} diff --git a/lib/Daemon/MailQueueCli.php b/lib/Daemon/MailQueueCli.php new file mode 100644 index 0000000..0198802 --- /dev/null +++ b/lib/Daemon/MailQueueCli.php @@ -0,0 +1,246 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\MailManager\Daemon; + +use KTXM\MailManager\Queue\JobStatus; +use KTXM\MailManager\Queue\MailQueue; +use Psr\Log\LoggerInterface; + +/** + * Mail Queue CLI + * + * Command-line interface for mail queue management operations. + * + * Usage: + * php mail-queue.php list [--status=pending] + * php mail-queue.php retry + * php mail-queue.php retry-all --status=failed + * php mail-queue.php purge --status=complete --older-than=7d + * php mail-queue.php stats + * + * @since 2025.05.01 + */ +class MailQueueCli { + + public function __construct( + private MailQueue $queue, + private LoggerInterface $logger, + ) {} + + /** + * Run CLI command + * + * @param array $args Command line arguments + * + * @return int Exit code + */ + public function run(array $args): int { + $command = $args[1] ?? 'help'; + + return match($command) { + 'list' => $this->commandList($args), + 'retry' => $this->commandRetry($args), + 'retry-all' => $this->commandRetryAll($args), + 'purge' => $this->commandPurge($args), + 'stats' => $this->commandStats($args), + 'help', '--help', '-h' => $this->commandHelp(), + default => $this->commandHelp(), + }; + } + + /** + * List jobs in queue + */ + private function commandList(array $args): int { + $tenantId = $args[2] ?? null; + if ($tenantId === null) { + echo "Error: tenant ID required\n"; + return 1; + } + + $status = $this->parseOption($args, 'status'); + $statusEnum = $status !== null ? JobStatus::tryFrom($status) : null; + $limit = (int)($this->parseOption($args, 'limit') ?? 100); + + $jobs = $this->queue->listJobs($tenantId, $statusEnum, $limit); + + if (empty($jobs)) { + echo "No jobs found\n"; + return 0; + } + + echo sprintf("%-36s %-12s %-8s %-20s %s\n", + 'JOB ID', 'STATUS', 'ATTEMPTS', 'CREATED', 'SUBJECT'); + echo str_repeat('-', 100) . "\n"; + + foreach ($jobs as $job) { + $subject = substr($job->message->getSubject(), 0, 30); + echo sprintf("%-36s %-12s %-8d %-20s %s\n", + $job->id, + $job->status->value, + $job->attempts, + $job->created?->format('Y-m-d H:i:s') ?? '-', + $subject + ); + } + + return 0; + } + + /** + * Retry a specific job + */ + private function commandRetry(array $args): int { + $jobId = $args[2] ?? null; + if ($jobId === null) { + echo "Error: job ID required\n"; + return 1; + } + + if ($this->queue->retry($jobId)) { + echo "Job $jobId queued for retry\n"; + return 0; + } + + echo "Failed to retry job $jobId (not found or not failed)\n"; + return 1; + } + + /** + * Retry all failed jobs for a tenant + */ + private function commandRetryAll(array $args): int { + $tenantId = $args[2] ?? null; + if ($tenantId === null) { + echo "Error: tenant ID required\n"; + return 1; + } + + $jobs = $this->queue->listJobs($tenantId, JobStatus::Failed); + $retried = 0; + + foreach ($jobs as $job) { + if ($this->queue->retry($job->id)) { + $retried++; + } + } + + echo "Retried $retried jobs\n"; + return 0; + } + + /** + * Purge old jobs + */ + private function commandPurge(array $args): int { + $tenantId = $args[2] ?? null; + if ($tenantId === null) { + echo "Error: tenant ID required\n"; + return 1; + } + + $status = $this->parseOption($args, 'status') ?? 'complete'; + $statusEnum = JobStatus::tryFrom($status); + if ($statusEnum === null) { + echo "Error: invalid status '$status'\n"; + return 1; + } + + $olderThan = $this->parseOption($args, 'older-than') ?? '7d'; + $seconds = $this->parseDuration($olderThan); + + $purged = $this->queue->purge($tenantId, $statusEnum, $seconds); + echo "Purged $purged jobs\n"; + + return 0; + } + + /** + * Show queue statistics + */ + private function commandStats(array $args): int { + $tenantId = $args[2] ?? null; + if ($tenantId === null) { + echo "Error: tenant ID required\n"; + return 1; + } + + $stats = $this->queue->stats($tenantId); + + echo "Queue Statistics for $tenantId:\n"; + echo " Pending: {$stats['pending']}\n"; + echo " Processing: {$stats['processing']}\n"; + echo " Complete: {$stats['complete']}\n"; + echo " Failed: {$stats['failed']}\n"; + + return 0; + } + + /** + * Show help message + */ + private function commandHelp(): int { + echo << [options] + +Commands: + list List jobs in queue + --status= Filter by status (pending, processing, complete, failed) + --limit= Maximum jobs to show (default: 100) + + retry Retry a specific failed job + + retry-all Retry all failed jobs for a tenant + + purge Purge old jobs + --status= Status to purge (default: complete) + --older-than= Age threshold (default: 7d, e.g., 1h, 30d) + + stats Show queue statistics + + help Show this help message + +HELP; + return 0; + } + + /** + * Parse a command line option + */ + private function parseOption(array $args, string $name): ?string { + foreach ($args as $arg) { + if (str_starts_with($arg, "--$name=")) { + return substr($arg, strlen("--$name=")); + } + } + return null; + } + + /** + * Parse a duration string to seconds + */ + private function parseDuration(string $duration): int { + preg_match('/^(\d+)([smhd])?$/', $duration, $matches); + $value = (int)($matches[1] ?? 0); + $unit = $matches[2] ?? 's'; + + return match($unit) { + 's' => $value, + 'm' => $value * 60, + 'h' => $value * 3600, + 'd' => $value * 86400, + default => $value, + }; + } + +} diff --git a/lib/Manager.php b/lib/Manager.php new file mode 100644 index 0000000..76f5453 --- /dev/null +++ b/lib/Manager.php @@ -0,0 +1,1036 @@ + collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] + */ + public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { + // determine filter from sources + $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; + // retrieve providers from provider manager + return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter); + } + + /** + * Confirm which providers are available + * + * @param SourceSelector|null $sources collection of provider identifiers to confirm + * + * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] + */ + public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // determine which providers are available + $providersResolved = $this->providerList($tenantId, $userId, $sources); + $providersAvailable = array_keys($providersResolved); + $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); + // construct response data + $responseData = array_merge( + array_fill_keys($providersAvailable, true), + array_fill_keys($providersUnavailable, false) + ); + return $responseData; + } + + /** + * Retrieve specific provider for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $provider provider identifier + * + * @return ProviderBaseInterface + * @throws InvalidArgumentException + */ + public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { + // retrieve provider + $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); + if (!isset($providers[$provider])) { + throw new InvalidArgumentException("Provider '$provider' not found"); + } + return $providers[$provider]; + } + + /** + * Retrieve available services for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param SourceSelector|null $sources list of provider and service identifiers + * + * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] + */ + public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; + $services = $provider->serviceList($tenantId, $userId, $serviceFilter); + $responseData[$provider->identifier()] = $services; + } + return $responseData; + } + + /** + * Confirm which services are available + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param SourceSelector|null $sources collection of provider and service identifiers to confirm + * + * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] + */ + public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // retrieve services for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); + $responseData[$provider->identifier()] = $serviceAvailability; + } + return $responseData; + } + + /** + * Retrieve service for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * + * @return ServiceBaseInterface + * @throws InvalidArgumentException + */ + public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface { + // retrieve provider and service + $service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'"); + } + // retrieve services + return $service; + } + + /** + * Find a service that handles a specific email address + * + * Searches all providers for a service that handles the given address, + * respecting the user context for scope filtering. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param string $address Email address to find service for + * + * @return ?ServiceBaseInterface + */ + public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBaseInterface { + // retrieve providers + $providers = $this->providerList($tenantId, $userId); + + foreach ($providers as $providerId => $provider) { + $service = $provider->serviceFindByAddress($tenantId, $userId, $address); + if ($service !== null) { + return $service; + } + } + + return null; + } + + /** + * Create a new service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param array $data Service configuration data + * + * @return ServiceBaseInterface Created service + * + * @throws InvalidArgumentException If provider doesn't support service creation + */ + public function serviceCreate(string $tenantId, ?string $userId, string $providerId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Create a fresh service instance + $service = $provider->serviceFresh(); + + // Deserialize the data into the service + $service->jsonDeserialize($data); + + // Create the service + $serviceId = $provider->serviceCreate($tenantId, $userId, $service); + + // Fetch and return the created service + return $provider->serviceFetch($tenantId, $userId, $serviceId); + } + + /** + * Update an existing service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param array $data Updated service configuration data + * + * @return ServiceBaseInterface Updated service + * + * @throws InvalidArgumentException If provider doesn't support service modification or service not found + */ + public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Update with new data + $service->jsonDeserialize($data); + + // Modify the service + $provider->serviceModify($tenantId, $userId, $service); + + // Fetch and return the updated service + return $provider->serviceFetch($tenantId, $userId, $serviceId); + } + + /** + * Delete a service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * + * @return bool True if service was deleted + * + * @throws InvalidArgumentException If provider doesn't support service deletion or service not found + */ + public function serviceDelete(string $tenantId, string $userId, string $providerId, string|int $serviceId): bool { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Delete the service + return $provider->serviceDestroy($tenantId, $userId, $service); + } + + /** + * Discover mail service settings from identity + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param string|null $providerId Specific provider to use for discovery (or null for all) + * @param string $identity Identity to discover configuration for (e.g., email address) + * @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup) + * @param string|null $secret Optional password/token to validate discovered service + * + * @return array Array of discovered service locations keyed by provider ID + * [ + * 'jmap' => ResourceServiceLocationInterface, + * 'smtp' => ResourceServiceLocationInterface, + * // Only providers that successfully discovered (non-null) + * ] + */ + public function serviceDiscover( + string $tenantId, + string $userId, + string|null $providerId, + string $identity, + string|null $location = null, + string|null $secret = null + ): array { + $locations = []; + + $providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null); + + foreach ($providers as $providerId => $provider) { + if (!($provider instanceof ProviderServiceDiscoverInterface)) { + continue; + } + + try { + $location = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret); + + if ($location !== null) { + $locations[$providerId] = $location; + } + } catch (\Throwable $e) { + $this->logger->warning('Provider autodiscovery failed', [ + 'provider' => $providerId, + 'identity' => $identity, + 'error' => $e->getMessage(), + ]); + } + } + + return $locations; + } + + /** + * Test a mail service connection + * + * Tests connectivity and authentication for either an existing service + * or a fresh configuration. Delegates to the appropriate provider. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider ID (for existing service or targeted test) + * @param string|int|null $serviceId Service ID (for existing service test) + * @param ResourceServiceLocationInterface|array|null $location Service location (for fresh config test) + * @param ResourceServiceIdentityInterface|array|null $identity Service credentials (for fresh config test) + * + * @return array Test results + * + * @throws InvalidArgumentException If invalid parameters + */ + public function serviceTest( + string $tenantId, + string $userId, + string $providerId, + string|int|null $serviceId = null, + ResourceServiceLocationInterface|array|null $location = null, + ResourceServiceIdentityInterface|array|null $identity = null + ): array { + // retrieve provider + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceTestInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service testing"); + } + + // Testing existing service + if ($providerId !== null && $serviceId !== null) { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); + } + + try { + return $provider->serviceTest($service); + } catch (\Throwable $e) { + throw new InvalidArgumentException('Service test failed: ' . $e->getMessage()); + } + } + + // Testing fresh configuration + if ($location !== null && $identity !== null) { + + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support fresh service configuration testing"); + } + + if (empty($location['type'])) { + throw new InvalidArgumentException('Service location not valid'); + } + + if (empty($identity['type'])) { + throw new InvalidArgumentException('Service identity not valid'); + } + + /** @var ServiceMutableInterface $service */ + $service = $provider->serviceFresh(); + if ($location instanceof ResourceServiceLocationInterface === false) { + $location = $service->freshLocation($location['type'], (array)$location); + $service->setLocation($location); + } + if ($identity instanceof ResourceServiceIdentityInterface === false) { + $identity = $service->freshIdentity($identity['type'], (array)$identity); + $service->setIdentity($identity); + } + + return $provider->serviceTest($service); + } + + throw new InvalidArgumentException( + 'Either (provider + service) or (provider + location + identity) must be provided' + ); + } + + // ==================== Collection Operations ==================== + + /** + * List collections across services + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector|null $sources Provider/service sources + * @param IFilter|null $filter Collection filter + * @param ISort|null $sort Collection sort + * + * @return array>> Collections grouped by provider/service + */ + public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ + $services = $provider->serviceList($tenantId, $userId, $serviceFilter); + // retrieve collections for each service + foreach ($services as $service) { + // construct filter for collections + $collectionFilter = null; + if ($filter !== null && $filter !== []) { + $collectionFilter = $service->collectionListFilter(); + foreach ($filter as $attribute => $value) { + $collectionFilter->condition($attribute, $value); + } + } + // construct sort for collections + $collectionSort = null; + if ($sort !== null && $sort !== []) { + $collectionSort = $service->collectionListSort(); + foreach ($sort as $attribute => $direction) { + $collectionSort->condition($attribute, $direction); + } + } + $collections = $service->collectionList('', $collectionFilter, $collectionSort); + if ($collections !== []) { + $responseData[$provider->identifier()][$service->identifier()] = $collections; + } + } + } + return $responseData; + } + + /** + * Check if collections exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Collection sources with identifiers + * + * @return array>> Existence map grouped by provider/service + */ + public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { + // retrieve available providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // check services and collections for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ + $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); + + // mark unavailable services as false + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + + // confirm collections for each available service + foreach ($servicesAvailable as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector->identifiers(); + + if ($collectionsRequested === []) { + continue; + } + + // check each requested collection + $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); + $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); + $responseData[$provider->identifier()][$service->identifier()] = array_merge( + $collectionsAvailable, + array_fill_keys($collectionsUnavailable, false) + ); + } + } + return $responseData; + } + + /** + * Fetch a specific collection + * + * @since 2025.05.01 + * + * @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 + * + * @return CollectionBaseInterface|null + */ + public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service === null) { + return null; + } + // retrieve collection + return $service->collectionFetch($collectionId); + } + + /** + * Create a new collection for a specific user + * + * @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 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 { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; + } + + // Create collection + return $service->collectionCreate($collectionId, $collection, $options); + } + + /** + * Modify an existing collection for a specific user + * + * @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 + * + * @return CollectionBaseInterface + * @throws InvalidArgumentException + */ + public function collectionModify(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MODIFY)) { + throw new InvalidArgumentException("Service is not capable of modifying collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; + } + + // Modify collection + return $service->collectionModify($collectionId, $collection); + } + + /** + * Destroy a specific collection + * + * @since 2025.05.01 + * + * @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 + * + * @return CollectionBaseInterface|null + */ + public function collectionDestroy(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports collection destruction + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DESTROY)) { + throw new InvalidArgumentException("Service is not capable of destroying collections"); + } + + $force = $options['force'] ?? false; + $recursive = $options['recursive'] ?? false; + + // destroy collection + return $service->collectionDestroy($collectionId, $force, $recursive); + } + + // ==================== Message Operations ==================== + + /** + * List messages in a collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param SourceSelector $sources Message sources with collection identifiers + * @param array|null $filter Message filter + * @param array|null $sort Message sort + * @param array|null $range Message range/pagination + * + * @return array>>> Messages grouped by provider/service/collection + */ + public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + // retrieve services for each provider + $serviceSelector = $sources[$provider->identifier()]; + $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); + /** @var ServiceBaseInterface $service */ + foreach ($servicesSelected as $service) { + // retrieve collections for each service + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + if ($collectionSelected === []) { + $collections = $service->collectionList(''); + $collectionSelected = array_map( + fn($collection) => $collection->identifier(), + $collections + ); + } + if ($collectionSelected === []) { + continue; + } + // construct filter for entities + $entityFilter = null; + if ($filter !== null && $filter !== []) { + $entityFilter = $service->entityListFilter(); + foreach ($filter as $attribute => $value) { + $entityFilter->condition($attribute, $value); + } + } + // construct sort for entities + $entitySort = null; + if ($sort !== null && $sort !== []) { + $entitySort = $service->entityListSort(); + foreach ($sort as $attribute => $direction) { + $entitySort->condition($attribute, $direction); + } + } + // construct range for entities + $entityRange = null; + if ($range !== null && $range !== [] && isset($range['type'])) { + $entityRange = $service->entityListRange(RangeType::from($range['type'])); + // Cast to IRangeTally if the range type is TALLY + if ($entityRange->type() === RangeType::TALLY) { + /** @var IRangeTally $entityRange */ + if (isset($range['anchor'])) { + $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); + } + if (isset($range['position'])) { + $entityRange->setPosition($range['position']); + } + if (isset($range['tally'])) { + $entityRange->setTally($range['tally']); + } + } + } + // retrieve entities for each collection + foreach ($collectionSelected as $collectionId) { + $entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null); + // skip collections with no entities + if ($entities === []) { + continue; + } + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities; + } + } + } + + return $responseData; + } + + /** + * Get message delta/changes + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with signatures + * + * @return array>> Delta grouped by provider/service/collection + */ + public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + // iterate through available providers + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ + $services = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + // iterate through available services + foreach ($services as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + if ($collectionsRequested === []) { + $responseData[$provider->identifier()][$service->identifier()] = false; + continue; + } + foreach ($collectionsRequested as $collection) { + $entitySelector = $collectionSelector[$collection] ?? null; + $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); + } + } + } + return $responseData; + } + + /** + * Check if messages exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with identifiers + * + * @return array>>> Existence map grouped by provider/service/collection + */ + public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve available providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // check services, collections, and entities for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ + $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); + + // mark unavailable services as false + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + + // check collections and entities for each available service + foreach ($servicesAvailable as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + + if ($collectionsRequested === []) { + continue; + } + + // check entities for each requested collection + foreach ($collectionsRequested as $collectionId) { + // first check if collection exists + $collectionExists = $service->collectionExtant((string)$collectionId); + + if (!$collectionExists) { + // collection doesn't exist, mark as false + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; + continue; + } + + // extract entity identifiers from collection selector + $entitySelector = $collectionSelector[$collectionId]; + + // handle both array of entity IDs and boolean true (meaning check if collection exists) + if ($entitySelector instanceof EntitySelector) { + // check specific entities within the collection + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); + } elseif ($entitySelector === true) { + // just checking if collection exists (already confirmed above) + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; + } + } + } + } + return $responseData; + } + + /** + * Fetch specific messages + * + * @since 2025.05.01 + * + * @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 array $identifiers Message identifiers + * + * @return array Messages indexed by ID + */ + public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // retrieve collection + return $service->entityFetch($collectionId, ...$identifiers); + } + + /** + * 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/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..da9e400 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,67 @@ + [ + 'label' => 'Access Mail Manager', + 'description' => 'View and access the mail manager module', + 'group' => 'Mail Management' + ], + ]; + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'MailManager', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description(), + 'boot' => 'static/module.mjs', + ]; + } +} diff --git a/lib/Queue/JobStatus.php b/lib/Queue/JobStatus.php new file mode 100644 index 0000000..3508d6a --- /dev/null +++ b/lib/Queue/JobStatus.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\MailManager\Queue; + +use JsonSerializable; + +/** + * Mail Job Status + * + * Status states for queued mail jobs. + * + * @since 2025.05.01 + */ +enum JobStatus: string implements JsonSerializable { + + case Pending = 'pending'; + case Processing = 'processing'; + case Complete = 'complete'; + case Failed = 'failed'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/lib/Queue/MailJob.php b/lib/Queue/MailJob.php new file mode 100644 index 0000000..af1fdee --- /dev/null +++ b/lib/Queue/MailJob.php @@ -0,0 +1,115 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\MailManager\Queue; + +use DateTimeImmutable; +use KTXF\Mail\Entity\IMessageMutable; +use KTXF\Mail\Queue\SendOptions; + +/** + * Mail Job + * + * Represents a queued mail job with metadata and message content. + * + * @since 2025.05.01 + */ +class MailJob { + + public function __construct( + public readonly string $id, + public readonly string $tenantId, + public readonly string $providerId, + public readonly string|int $serviceId, + public readonly IMessageMutable $message, + public readonly SendOptions $options, + public JobStatus $status = JobStatus::Pending, + public int $attempts = 0, + public ?string $lastError = null, + public ?string $messageId = null, + public ?DateTimeImmutable $created = null, + public ?DateTimeImmutable $scheduled = null, + public ?DateTimeImmutable $lastAttempt = null, + public ?DateTimeImmutable $completed = null, + ) { + $this->created = $this->created ?? new DateTimeImmutable(); + $this->scheduled = $this->scheduled ?? $this->calculateScheduledTime(); + } + + /** + * Calculate when this job should be processed + * + * @return DateTimeImmutable + */ + private function calculateScheduledTime(): DateTimeImmutable { + $scheduled = $this->created ?? new DateTimeImmutable(); + + if ($this->options->delaySeconds !== null && $this->options->delaySeconds > 0) { + $scheduled = $scheduled->modify("+{$this->options->delaySeconds} seconds"); + } + + return $scheduled; + } + + /** + * Check if the job is ready to be processed + * + * @return bool + */ + public function isReady(): bool { + if ($this->status !== JobStatus::Pending) { + return false; + } + + return $this->scheduled === null || $this->scheduled <= new DateTimeImmutable(); + } + + /** + * Check if the job can be retried + * + * @return bool + */ + public function canRetry(): bool { + return $this->attempts < $this->options->retryCount; + } + + /** + * Get retry delay in seconds based on attempt count (exponential backoff) + * + * @return int + */ + public function getRetryDelay(): int { + // Exponential backoff: 30s, 60s, 120s, 240s, ... + return min(30 * (2 ** $this->attempts), 3600); + } + + /** + * Serialize job metadata for storage + * + * @return array + */ + public function toMetaArray(): array { + return [ + 'id' => $this->id, + 'tenantId' => $this->tenantId, + 'providerId' => $this->providerId, + 'serviceId' => $this->serviceId, + 'options' => $this->options->jsonSerialize(), + 'status' => $this->status->value, + 'attempts' => $this->attempts, + 'lastError' => $this->lastError, + 'messageId' => $this->messageId, + 'created' => $this->created?->format('c'), + 'scheduled' => $this->scheduled?->format('c'), + 'lastAttempt' => $this->lastAttempt?->format('c'), + 'completed' => $this->completed?->format('c'), + ]; + } + +} diff --git a/lib/Queue/MailQueueFile.php b/lib/Queue/MailQueueFile.php new file mode 100644 index 0000000..a85e0eb --- /dev/null +++ b/lib/Queue/MailQueueFile.php @@ -0,0 +1,492 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\MailManager\Queue; + +use DateTimeImmutable; +use DI\Attribute\Inject; +use Psr\Log\LoggerInterface; +use KTXF\Mail\Entity\IMessageMutable; +use KTXF\Mail\Entity\Message; +use KTXF\Mail\Queue\SendOptions; +use RuntimeException; + +/** + * File-Based Mail Queue + * + * Stores mail queue jobs on disk with atomic operations using file locks. + * + * Structure: + * storage/{tenantId}/mail/queue/ + * pending/{jobId}/ + * meta.json + * message.json + * processing/{jobId}/... + * complete/{jobId}/... + * failed/{jobId}/... + * + * @since 2025.05.01 + */ +class MailQueueFile { + + private const DIR_PENDING = 'pending'; + private const DIR_PROCESSING = 'processing'; + private const DIR_COMPLETE = 'complete'; + private const DIR_FAILED = 'failed'; + private string $storagePath; + + public function __construct( + private LoggerInterface $logger, + #[Inject('rootDir')] private readonly string $rootDir, + ) { + $this->storagePath = $this->rootDir . '/var/cache/mail_manager/queue'; + } + + /** + * @inheritDoc + */ + public function enqueue( + string $tenantId, + string $providerId, + string|int $serviceId, + IMessageMutable $message, + SendOptions $options + ): string { + $jobId = $this->generateJobId(); + + $job = new MailJob( + id: $jobId, + tenantId: $tenantId, + providerId: $providerId, + serviceId: $serviceId, + message: $message, + options: $options, + ); + + $this->writeJob($job, self::DIR_PENDING); + + return $jobId; + } + + /** + * @inheritDoc + */ + public function dequeue(string $tenantId, int $limit = 50): array { + $pendingDir = $this->getQueueDir($tenantId, self::DIR_PENDING); + + if (!is_dir($pendingDir)) { + return []; + } + + $jobs = []; + $entries = scandir($pendingDir); + + // Sort by priority (read meta files) + $jobsWithPriority = []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $jobDir = $pendingDir . '/' . $entry; + if (!is_dir($jobDir)) { + continue; + } + + $metaFile = $jobDir . '/meta.json'; + if (!file_exists($metaFile)) { + continue; + } + + $meta = json_decode(file_get_contents($metaFile), true); + if ($meta === null) { + continue; + } + + $scheduled = isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null; + if ($scheduled !== null && $scheduled > new DateTimeImmutable()) { + continue; // Not ready yet + } + + $jobsWithPriority[] = [ + 'id' => $entry, + 'priority' => $meta['options']['priority'] ?? 0, + 'created' => $meta['created'] ?? '', + ]; + } + + // Sort by priority (desc) then by created (asc) + usort($jobsWithPriority, function($a, $b) { + if ($a['priority'] !== $b['priority']) { + return $b['priority'] <=> $a['priority']; + } + return $a['created'] <=> $b['created']; + }); + + // Take up to limit and move to processing + $jobsWithPriority = array_slice($jobsWithPriority, 0, $limit); + + foreach ($jobsWithPriority as $jobInfo) { + $job = $this->loadJob($tenantId, $jobInfo['id'], self::DIR_PENDING); + if ($job === null) { + continue; + } + + // Move to processing + $this->moveJob($tenantId, $jobInfo['id'], self::DIR_PENDING, self::DIR_PROCESSING); + $job->status = JobStatus::Processing; + $job->lastAttempt = new DateTimeImmutable(); + $job->attempts++; + $this->updateJobMeta($tenantId, $jobInfo['id'], $job, self::DIR_PROCESSING); + + $jobs[] = $job; + } + + return $jobs; + } + + /** + * @inheritDoc + */ + public function acknowledge(string $jobId, string $messageId): void { + $job = $this->findJobById($jobId); + if ($job === null) { + return; + } + + $job->status = JobStatus::Complete; + $job->messageId = $messageId; + $job->completed = new DateTimeImmutable(); + + $this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_COMPLETE); + $this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_COMPLETE); + } + + /** + * @inheritDoc + */ + public function reject(string $jobId, string $error, bool $retry = true): void { + $job = $this->findJobById($jobId); + if ($job === null) { + return; + } + + $job->lastError = $error; + + if ($retry && $job->canRetry()) { + // Move back to pending with delay + $job->status = JobStatus::Pending; + $job->scheduled = (new DateTimeImmutable())->modify('+' . $job->getRetryDelay() . ' seconds'); + + $this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_PENDING); + $this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING); + } else { + // Move to failed + $job->status = JobStatus::Failed; + $job->completed = new DateTimeImmutable(); + + $this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_FAILED); + $this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_FAILED); + } + } + + /** + * @inheritDoc + */ + public function getJob(string $jobId): ?MailJob { + return $this->findJobById($jobId); + } + + /** + * @inheritDoc + */ + public function listJobs(string $tenantId, ?JobStatus $status = null, int $limit = 100, int $offset = 0): array { + $dirs = $status !== null + ? [$this->statusToDir($status)] + : [self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED]; + + $jobs = []; + + foreach ($dirs as $dir) { + $queueDir = $this->getQueueDir($tenantId, $dir); + if (!is_dir($queueDir)) { + continue; + } + + foreach (scandir($queueDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $job = $this->loadJob($tenantId, $entry, $dir); + if ($job !== null) { + $jobs[] = $job; + } + } + } + + // Sort by created desc + usort($jobs, fn($a, $b) => ($b->created?->getTimestamp() ?? 0) <=> ($a->created?->getTimestamp() ?? 0)); + + return array_slice($jobs, $offset, $limit); + } + + /** + * @inheritDoc + */ + public function retry(string $jobId): bool { + $job = $this->findJobById($jobId); + if ($job === null || $job->status !== JobStatus::Failed) { + return false; + } + + $job->status = JobStatus::Pending; + $job->attempts = 0; + $job->lastError = null; + $job->scheduled = new DateTimeImmutable(); + + $this->moveJob($job->tenantId, $jobId, self::DIR_FAILED, self::DIR_PENDING); + $this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING); + + return true; + } + + /** + * @inheritDoc + */ + public function purge(string $tenantId, JobStatus $status, int $olderThanSeconds): int { + $dir = $this->statusToDir($status); + $queueDir = $this->getQueueDir($tenantId, $dir); + + if (!is_dir($queueDir)) { + return 0; + } + + $threshold = new DateTimeImmutable("-{$olderThanSeconds} seconds"); + $purged = 0; + + foreach (scandir($queueDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $jobDir = $queueDir . '/' . $entry; + $metaFile = $jobDir . '/meta.json'; + + if (!file_exists($metaFile)) { + continue; + } + + $meta = json_decode(file_get_contents($metaFile), true); + $completed = isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null; + + if ($completed !== null && $completed < $threshold) { + $this->deleteJobDir($jobDir); + $purged++; + } + } + + return $purged; + } + + /** + * @inheritDoc + */ + public function stats(string $tenantId): array { + $stats = [ + 'pending' => 0, + 'processing' => 0, + 'complete' => 0, + 'failed' => 0, + ]; + + foreach ($stats as $status => $_) { + $dir = $this->getQueueDir($tenantId, $status); + if (is_dir($dir)) { + $stats[$status] = count(array_filter( + scandir($dir), + fn($e) => $e !== '.' && $e !== '..' + )); + } + } + + return $stats; + } + + /** + * Generate a unique job ID + */ + private function generateJobId(): string { + return sprintf( + '%08x-%04x-%04x-%04x-%012x', + time(), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffffffffffff) + ); + } + + /** + * Get the queue directory path for a tenant and status + */ + private function getQueueDir(string $tenantId, string $status): string { + return $this->storagePath . '/' . $tenantId . '/mail/queue/' . $status; + } + + /** + * Write a job to disk + */ + private function writeJob(MailJob $job, string $status): void { + $jobDir = $this->getQueueDir($job->tenantId, $status) . '/' . $job->id; + + if (!is_dir($jobDir)) { + mkdir($jobDir, 0755, true); + } + + // Write meta + $metaFile = $jobDir . '/meta.json'; + file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT)); + + // Write message + $messageFile = $jobDir . '/message.json'; + file_put_contents($messageFile, json_encode($job->message, JSON_PRETTY_PRINT)); + } + + /** + * Load a job from disk + */ + private function loadJob(string $tenantId, string $jobId, string $status): ?MailJob { + $jobDir = $this->getQueueDir($tenantId, $status) . '/' . $jobId; + $metaFile = $jobDir . '/meta.json'; + $messageFile = $jobDir . '/message.json'; + + if (!file_exists($metaFile) || !file_exists($messageFile)) { + return null; + } + + $meta = json_decode(file_get_contents($metaFile), true); + $messageData = json_decode(file_get_contents($messageFile), true); + + if ($meta === null || $messageData === null) { + return null; + } + + $message = Message::fromArray($messageData); + + return new MailJob( + id: $meta['id'], + tenantId: $meta['tenantId'], + providerId: $meta['providerId'], + serviceId: $meta['serviceId'], + message: $message, + options: new SendOptions( + immediate: $meta['options']['immediate'] ?? false, + priority: $meta['options']['priority'] ?? 0, + retryCount: $meta['options']['retryCount'] ?? 3, + delaySeconds: $meta['options']['delaySeconds'] ?? null, + ), + status: JobStatus::from($meta['status']), + attempts: $meta['attempts'] ?? 0, + lastError: $meta['lastError'] ?? null, + messageId: $meta['messageId'] ?? null, + created: isset($meta['created']) ? new DateTimeImmutable($meta['created']) : null, + scheduled: isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null, + lastAttempt: isset($meta['lastAttempt']) ? new DateTimeImmutable($meta['lastAttempt']) : null, + completed: isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null, + ); + } + + /** + * Move a job between status directories + */ + private function moveJob(string $tenantId, string $jobId, string $fromStatus, string $toStatus): void { + $fromDir = $this->getQueueDir($tenantId, $fromStatus) . '/' . $jobId; + $toDir = $this->getQueueDir($tenantId, $toStatus) . '/' . $jobId; + + if (!is_dir($fromDir)) { + throw new RuntimeException("Job directory not found: $fromDir"); + } + + $toParent = dirname($toDir); + if (!is_dir($toParent)) { + mkdir($toParent, 0755, true); + } + + rename($fromDir, $toDir); + } + + /** + * Update job metadata + */ + private function updateJobMeta(string $tenantId, string $jobId, MailJob $job, string $status): void { + $metaFile = $this->getQueueDir($tenantId, $status) . '/' . $jobId . '/meta.json'; + file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT)); + } + + /** + * Find a job by ID across all status directories + */ + private function findJobById(string $jobId): ?MailJob { + // We need to search across all tenants and statuses + // This is inefficient - in production, consider caching or indexing + $tenantsDir = $this->storagePath; + + if (!is_dir($tenantsDir)) { + return null; + } + + foreach (scandir($tenantsDir) as $tenantId) { + if ($tenantId === '.' || $tenantId === '..') { + continue; + } + + foreach ([self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED] as $status) { + $job = $this->loadJob($tenantId, $jobId, $status); + if ($job !== null) { + return $job; + } + } + } + + return null; + } + + /** + * Delete a job directory recursively + */ + private function deleteJobDir(string $dir): void { + if (!is_dir($dir)) { + return; + } + + foreach (scandir($dir) as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $path = $dir . '/' . $file; + is_dir($path) ? $this->deleteJobDir($path) : unlink($path); + } + + rmdir($dir); + } + + /** + * Convert JobStatus to directory name + */ + private function statusToDir(JobStatus $status): string { + return match($status) { + JobStatus::Pending => self::DIR_PENDING, + JobStatus::Processing => self::DIR_PROCESSING, + JobStatus::Complete => self::DIR_COMPLETE, + JobStatus::Failed => self::DIR_FAILED, + }; + } + +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4657996 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "mail_manager", + "description": "Ktrix Mail Manager Module - Frontend Store", + "version": "1.0.0", + "private": true, + "license": "AGPL-3.0-or-later", + "author": "Ktrix", + "type": "module", + "scripts": { + "build": "vite build --mode production --config vite.config.ts", + "dev": "vite build --mode development --config vite.config.ts", + "watch": "vite build --mode development --watch --config vite.config.ts", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "pinia": "^2.3.1" + }, + "devDependencies": { + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vite": "^7.1.2", + "vue-tsc": "^3.0.5" + } +} diff --git a/src/components/AddAccountDialog.vue b/src/components/AddAccountDialog.vue new file mode 100644 index 0000000..7ceaa44 --- /dev/null +++ b/src/components/AddAccountDialog.vue @@ -0,0 +1,568 @@ + + + \ No newline at end of file diff --git a/src/components/EditAccountDialog.vue b/src/components/EditAccountDialog.vue new file mode 100644 index 0000000..0465c16 --- /dev/null +++ b/src/components/EditAccountDialog.vue @@ -0,0 +1,355 @@ + + + diff --git a/src/components/steps/DiscoveryEntryStep.vue b/src/components/steps/DiscoveryEntryStep.vue new file mode 100644 index 0000000..2dfd583 --- /dev/null +++ b/src/components/steps/DiscoveryEntryStep.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/components/steps/DiscoveryStatusStep.vue b/src/components/steps/DiscoveryStatusStep.vue new file mode 100644 index 0000000..2ca1888 --- /dev/null +++ b/src/components/steps/DiscoveryStatusStep.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/components/steps/ProviderAuthStep.vue b/src/components/steps/ProviderAuthStep.vue new file mode 100644 index 0000000..28b0e8d --- /dev/null +++ b/src/components/steps/ProviderAuthStep.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/steps/ProviderConfigStep.vue b/src/components/steps/ProviderConfigStep.vue new file mode 100644 index 0000000..1b9c299 --- /dev/null +++ b/src/components/steps/ProviderConfigStep.vue @@ -0,0 +1,124 @@ + + + \ No newline at end of file diff --git a/src/components/steps/ProviderSelectionStep.vue b/src/components/steps/ProviderSelectionStep.vue new file mode 100644 index 0000000..607df37 --- /dev/null +++ b/src/components/steps/ProviderSelectionStep.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/steps/TestAndSaveStep.vue b/src/components/steps/TestAndSaveStep.vue new file mode 100644 index 0000000..3463c80 --- /dev/null +++ b/src/components/steps/TestAndSaveStep.vue @@ -0,0 +1,295 @@ + + + diff --git a/src/composables/useMailSync.ts b/src/composables/useMailSync.ts new file mode 100644 index 0000000..1190aef --- /dev/null +++ b/src/composables/useMailSync.ts @@ -0,0 +1,245 @@ +/** + * Background mail synchronization composable + * + * Periodically checks for changes in mailboxes using the delta method + */ + +import { ref, onMounted, onUnmounted } from 'vue'; +import { useEntitiesStore } from '../stores/entitiesStore'; +import { useCollectionsStore } from '../stores/collectionsStore'; + +interface SyncSource { + provider: string; + service: string | number; + collections: (string | number)[]; +} + +interface SyncOptions { + /** Polling interval in milliseconds (default: 30000 = 30 seconds) */ + interval?: number; + /** Auto-start sync on mount (default: true) */ + autoStart?: boolean; + /** Fetch full entity details after delta (default: true) */ + fetchDetails?: boolean; +} + +export function useMailSync(options: SyncOptions = {}) { + const { + interval = 30000, + autoStart = true, + fetchDetails = true, + } = options; + + const entitiesStore = useEntitiesStore(); + const collectionsStore = useCollectionsStore(); + + const isRunning = ref(false); + const lastSync = ref(null); + const error = ref(null); + const sources = ref([]); + + let syncInterval: ReturnType | null = null; + + /** + * Add a source to sync (mailbox to monitor) + */ + function addSource(source: SyncSource) { + const exists = sources.value.some( + s => s.provider === source.provider + && s.service === source.service + && JSON.stringify(s.collections) === JSON.stringify(source.collections) + ); + + if (!exists) { + sources.value.push(source); + } + } + + /** + * Remove a source from sync + */ + function removeSource(source: SyncSource) { + const index = sources.value.findIndex( + s => s.provider === source.provider + && s.service === source.service + && JSON.stringify(s.collections) === JSON.stringify(source.collections) + ); + + if (index !== -1) { + sources.value.splice(index, 1); + } + } + + /** + * Clear all sources + */ + function clearSources() { + sources.value = []; + } + + /** + * Perform a single sync check + */ + async function sync() { + if (sources.value.length === 0) { + return; + } + + try { + error.value = null; + + // Build sources structure for delta request + const deltaSources: any = {}; + + sources.value.forEach(source => { + if (!deltaSources[source.provider]) { + deltaSources[source.provider] = {}; + } + if (!deltaSources[source.provider][source.service]) { + deltaSources[source.provider][source.service] = {}; + } + + // Add collections to check with their signatures + source.collections.forEach(collection => { + // Look up signature from entities store first (updated by delta), fallback to collections store + let signature = entitiesStore.signatures[source.provider]?.[String(source.service)]?.[String(collection)]; + + // Fallback to collection signature if not yet synced + if (!signature) { + const collectionData = collectionsStore.collections[source.provider]?.[String(source.service)]?.[String(collection)]; + signature = collectionData?.signature || ''; + } + + console.log(`[Sync] Collection ${source.provider}/${source.service}/${collection} signature: "${signature}"`); + + // Map collection identifier to signature string + deltaSources[source.provider][source.service][collection] = signature || ''; + }); + }); + + // Get delta changes + const deltaResponse = await entitiesStore.getDelta(deltaSources); + // If fetchDetails is enabled, fetch full entity data for additions and modifications + if (fetchDetails) { + const fetchPromises: Promise[] = []; + + Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => { + Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => { + Object.entries(serviceData).forEach(([collection, collectionData]: [string, any]) => { + // Skip if no changes (server returns false or string signature) + if (collectionData === false || typeof collectionData === 'string') { + return; + } + + // Check if signature actually changed (if not, skip fetching) + const oldSignature = deltaSources[provider]?.[service]?.[collection]; + const newSignature = collectionData.signature; + + if (oldSignature && newSignature && oldSignature === newSignature) { + // Signature unchanged - server bug returning additions anyway, skip fetch + console.log(`[Sync] Skipping fetch for ${provider}/${service}/${collection} - signature unchanged (${newSignature})`); + return; + } + + const identifiersToFetch = [ + ...(collectionData.additions || []), + ...(collectionData.modifications || []), + ]; + + if (identifiersToFetch.length > 0) { + console.log(`[Sync] Fetching ${identifiersToFetch.length} entities for ${provider}/${service}/${collection}`); + fetchPromises.push( + entitiesStore.getMessages( + provider, + service, + collection, + identifiersToFetch + ) + ); + } + }); + }); + }); + + // Fetch all in parallel + await Promise.allSettled(fetchPromises); + } + + lastSync.value = new Date(); + } catch (err: any) { + error.value = err.message || 'Sync failed'; + console.error('Mail sync error:', err); + } + } + + /** + * Start the background sync worker + */ + function start() { + if (isRunning.value) { + return; + } + + isRunning.value = true; + + // Do initial sync + sync(); + + // Set up periodic sync + syncInterval = setInterval(() => { + sync(); + }, interval); + } + + /** + * Stop the background sync worker + */ + function stop() { + if (!isRunning.value) { + return; + } + + isRunning.value = false; + + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = null; + } + } + + /** + * Restart the sync worker + */ + function restart() { + stop(); + start(); + } + + // Auto-start/stop on component lifecycle + onMounted(() => { + if (autoStart && sources.value.length > 0) { + start(); + } + }); + + onUnmounted(() => { + stop(); + }); + + return { + // State + isRunning, + lastSync, + error, + sources, + + // Methods + addSource, + removeSource, + clearSources, + sync, + start, + stop, + restart, + }; +} diff --git a/src/integrations.ts b/src/integrations.ts new file mode 100644 index 0000000..3363c6d --- /dev/null +++ b/src/integrations.ts @@ -0,0 +1,16 @@ +import type { ModuleIntegrations } from "@KTXC/types/moduleTypes"; + +const integrations: ModuleIntegrations = { + user_settings_menu: [ + { + id: 'mail_accounts', + label: 'Mail Accounts', + path: '/accounts', + icon: 'mdi-email-multiple', + priority: 40, + caption: 'Manage your mail accounts' + }, + ], +}; + +export default integrations; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..e014742 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,34 @@ +import type { App as Vue } from 'vue' +import routes from '@/routes' +import integrations from '@/integrations' +import { useCollectionsStore } from '@/stores/collectionsStore' +import { useEntitiesStore } from '@/stores/entitiesStore' +import { useProvidersStore } from '@/stores/providersStore' +import { useServicesStore } from '@/stores/servicesStore' + +/** + * Mail Manager Module Boot Script + * + * This script is executed when the mail_manager module is loaded. + * It initializes the stores which manage mail providers, services, collections, and messages. + */ + +console.log('[MailManager] Booting Mail Manager module...') + +console.log('[MailManager] Mail Manager module booted successfully') + +// CSS will be injected by build process +export const css = ['__CSS_FILENAME_PLACEHOLDER__'] + +// Export routes and integrations for module system +export { routes, integrations } + +// Export stores for external use if needed +export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore } + +// Default export for Vue plugin installation +export default { + install(app: Vue) { + // Module initialization if needed + } +} diff --git a/src/models/collection.ts b/src/models/collection.ts new file mode 100644 index 0000000..08f68a9 --- /dev/null +++ b/src/models/collection.ts @@ -0,0 +1,191 @@ +/** + * Class model for Collection Interface + */ + +import type { CollectionInterface, CollectionPropertiesInterface } from "@/types/collection"; + +export class CollectionObject implements CollectionInterface { + + _data!: CollectionInterface; + + constructor() { + this._data = { + provider: '', + service: '', + collection: null, + identifier: '', + signature: null, + created: null, + modified: null, + properties: { + '@type': 'mail.collection', + version: 1, + total: 0, + unread: 0, + label: '', + role: null, + rank: 0, + subscribed: true, + }, + }; + } + + fromJson(data: CollectionInterface): CollectionObject { + this._data = data; + if (data.properties) { + this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); + } else { + this._data.properties = new CollectionPropertiesObject(); + } + return this; + } + + toJson(): CollectionInterface { + const json = { ...this._data }; + if (this._data.properties instanceof CollectionPropertiesObject) { + json.properties = this._data.properties.toJson(); + } + return json; + } + + clone(): CollectionObject { + const cloned = new CollectionObject(); + cloned._data = { ...this._data }; + cloned._data.properties = this.properties.clone(); + return cloned; + } + + /** Immutable Properties */ + + get provider(): string { + return this._data.provider; + } + + get service(): string | number { + return this._data.service; + } + + get collection(): string | number | null { + return this._data.collection; + } + + get identifier(): string | number { + return this._data.identifier; + } + + get signature(): string | null | undefined { + return this._data.signature; + } + + get created(): string | null | undefined { + return this._data.created; + } + + get modified(): string | null | undefined { + return this._data.modified; + } + + get properties(): CollectionPropertiesObject { + if (this._data.properties instanceof CollectionPropertiesObject) { + return this._data.properties; + } + + if (this._data.properties) { + const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); + this._data.properties = hydrated; + return hydrated; + } + + return new CollectionPropertiesObject(); + } + + set properties(value: CollectionPropertiesObject) { + if (value instanceof CollectionPropertiesObject) { + this._data.properties = value as any; + } else { + this._data.properties = value; + } + } +} + +export class CollectionPropertiesObject implements CollectionPropertiesInterface { + + _data!: CollectionPropertiesInterface; + + constructor() { + this._data = { + '@type': 'mail.collection', + version: 1, + total: 0, + unread: 0, + label: '', + role: null, + rank: 0, + subscribed: true, + }; + } + + fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject { + this._data = data; + return this; + } + + toJson(): CollectionPropertiesInterface { + return this._data; + } + + clone(): CollectionPropertiesObject { + const cloned = new CollectionPropertiesObject(); + cloned._data = { ...this._data }; + return cloned; + } + + /** Immutable Properties */ + + get '@type'(): string { + return this._data['@type']; + } + + get version(): number { + return this._data.version; + } + + get role(): string | null | undefined { + return this._data.role; + } + + get total(): number | undefined { + return this._data.total; + } + + get unread(): number | undefined { + return this._data.unread; + } + + /** Mutable Properties */ + + get label(): string { + return this._data.label || ''; + } + + set label(value: string) { + this._data.label = value; + } + + get rank(): number | undefined { + return this._data.rank; + } + + set rank(value: number) { + this._data.rank = value; + } + + get subscribed(): boolean | undefined { + return this._data.subscribed; + } + + set subscribed(value: boolean) { + this._data.subscribed = value; + } + +} \ No newline at end of file diff --git a/src/models/entity.ts b/src/models/entity.ts new file mode 100644 index 0000000..43e20ea --- /dev/null +++ b/src/models/entity.ts @@ -0,0 +1,107 @@ +/** + * Class model for Message/Entity Interface + */ + +import type { EntityInterface } from "@/types/entity"; +import type { MessageInterface, MessagePartInterface } from "@/types/message"; +import { MessageObject } from "./message"; + +export class EntityObject { + + _data!: EntityInterface; + _message!: MessageObject; + + constructor() { + this._data = { + '@type': 'mail.entity', + provider: '', + service: '', + collection: '', + identifier: '', + signature: null, + created: null, + modified: null, + properties: { + '@type': 'mail.message', + version: 1, + urid: '', + size: 0, + receivedDate: undefined, + date: undefined, + subject: '', + snippet: '', + from: undefined, + to: [], + cc: [], + bcc: [], + replyTo: [], + flags: {}, + body: undefined, + attachments: [], + } + }; + } + + fromJson(data: EntityInterface): EntityObject { + this._data = data; + return this; + } + + toJson(): EntityInterface { + return this._data; + } + + clone(): EntityObject { + const cloned = new EntityObject(); + cloned._data = { + ...this._data, + properties: { ...this._data.properties } + }; + return cloned; + } + + /** Metadata Properties */ + + get provider(): string { + return this._data.provider; + } + + get service(): string { + return this._data.service; + } + + get collection(): string|number { + return this._data.collection; + } + + get identifier(): string|number { + return this._data.identifier; + } + + get signature(): string | null { + return this._data.signature; + } + + get created(): string | null { + return this._data.created; + } + + get modified(): string | null { + return this._data.modified; + } + + /** Message Object Properties */ + + get properties(): MessageObject { + if (!this._message) { + this._message = new MessageObject(this._data.properties); + } + return this._message; + } + + // Alias for backward compatibility + get object(): MessageObject { + return this.properties; + } + +} diff --git a/src/models/identity.ts b/src/models/identity.ts new file mode 100644 index 0000000..3291090 --- /dev/null +++ b/src/models/identity.ts @@ -0,0 +1,196 @@ +/** + * Identity implementation classes for Mail Manager services + */ + +import type { + ServiceIdentity, + ServiceIdentityNone, + ServiceIdentityBasic, + ServiceIdentityToken, + ServiceIdentityOAuth, + ServiceIdentityCertificate +} from '@/types/service'; + +/** + * Base Identity class + */ +export abstract class Identity { + abstract toJson(): ServiceIdentity; + + static fromJson(data: ServiceIdentity): Identity { + switch (data.type) { + case 'NA': + return IdentityNone.fromJson(data); + case 'BA': + return IdentityBasic.fromJson(data); + case 'TA': + return IdentityToken.fromJson(data); + case 'OA': + return IdentityOAuth.fromJson(data); + case 'CC': + return IdentityCertificate.fromJson(data); + default: + throw new Error(`Unknown identity type: ${(data as any).type}`); + } + } +} + +/** + * No authentication + */ +export class IdentityNone extends Identity { + readonly type = 'NA' as const; + + static fromJson(_data: ServiceIdentityNone): IdentityNone { + return new IdentityNone(); + } + + toJson(): ServiceIdentityNone { + return { + type: this.type + }; + } +} + +/** + * Basic authentication (username/password) + */ +export class IdentityBasic extends Identity { + readonly type = 'BA' as const; + identity: string; + secret: string; + + constructor(identity: string = '', secret: string = '') { + super(); + this.identity = identity; + this.secret = secret; + } + + static fromJson(data: ServiceIdentityBasic): IdentityBasic { + return new IdentityBasic(data.identity, data.secret); + } + + toJson(): ServiceIdentityBasic { + return { + type: this.type, + identity: this.identity, + secret: this.secret + }; + } +} + +/** + * Token authentication (API key, static token) + */ +export class IdentityToken extends Identity { + readonly type = 'TA' as const; + token: string; + + constructor(token: string = '') { + super(); + this.token = token; + } + + static fromJson(data: ServiceIdentityToken): IdentityToken { + return new IdentityToken(data.token); + } + + toJson(): ServiceIdentityToken { + return { + type: this.type, + token: this.token + }; + } +} + +/** + * OAuth authentication + */ +export class IdentityOAuth extends Identity { + readonly type = 'OA' as const; + accessToken: string; + accessScope?: string[]; + accessExpiry?: number; + refreshToken?: string; + refreshLocation?: string; + + constructor( + accessToken: string = '', + accessScope?: string[], + accessExpiry?: number, + refreshToken?: string, + refreshLocation?: string + ) { + super(); + this.accessToken = accessToken; + this.accessScope = accessScope; + this.accessExpiry = accessExpiry; + this.refreshToken = refreshToken; + this.refreshLocation = refreshLocation; + } + + static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { + return new IdentityOAuth( + data.accessToken, + data.accessScope, + data.accessExpiry, + data.refreshToken, + data.refreshLocation + ); + } + + toJson(): ServiceIdentityOAuth { + return { + type: this.type, + accessToken: this.accessToken, + ...(this.accessScope && { accessScope: this.accessScope }), + ...(this.accessExpiry && { accessExpiry: this.accessExpiry }), + ...(this.refreshToken && { refreshToken: this.refreshToken }), + ...(this.refreshLocation && { refreshLocation: this.refreshLocation }) + }; + } + + isExpired(): boolean { + if (!this.accessExpiry) return false; + return Date.now() / 1000 >= this.accessExpiry; + } + + expiresIn(): number { + if (!this.accessExpiry) return Infinity; + return Math.max(0, this.accessExpiry - Date.now() / 1000); + } +} + +/** + * Client certificate authentication (mTLS) + */ +export class IdentityCertificate extends Identity { + readonly type = 'CC' as const; + certificate: string; + privateKey: string; + passphrase?: string; + + constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { + super(); + this.certificate = certificate; + this.privateKey = privateKey; + this.passphrase = passphrase; + } + + static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { + return new IdentityCertificate( + data.certificate, + data.privateKey, + data.passphrase + ); + } + + toJson(): ServiceIdentityCertificate { + return { + type: this.type, + certificate: this.certificate, + privateKey: this.privateKey, + ...(this.passphrase && { passphrase: this.passphrase }) + }; + } +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..d7bca45 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,27 @@ +/** + * Central export point for all Mail Manager models + */ + +export { CollectionObject } from './collection'; +export { EntityObject } from './entity'; +export { ProviderObject } from './provider'; +export { ServiceObject } from './service'; + +// Identity models +export { + Identity, + IdentityNone, + IdentityBasic, + IdentityToken, + IdentityOAuth, + IdentityCertificate +} from './identity'; + +// Location models +export { + Location, + LocationUri, + LocationSocketSole, + LocationSocketSplit, + LocationFile +} from './location'; diff --git a/src/models/location.ts b/src/models/location.ts new file mode 100644 index 0000000..7e19cce --- /dev/null +++ b/src/models/location.ts @@ -0,0 +1,240 @@ +/** + * Location implementation classes for Mail Manager services + */ + +import type { + ServiceLocation, + ServiceLocationUri, + ServiceLocationSocketSole, + ServiceLocationSocketSplit, + ServiceLocationFile +} from '@/types/service'; + +/** + * Base Location class + */ +export abstract class Location { + abstract toJson(): ServiceLocation; + + static fromJson(data: ServiceLocation): Location { + switch (data.type) { + case 'URI': + return LocationUri.fromJson(data); + case 'SOCKET_SOLE': + return LocationSocketSole.fromJson(data); + case 'SOCKET_SPLIT': + return LocationSocketSplit.fromJson(data); + case 'FILE': + return LocationFile.fromJson(data); + default: + throw new Error(`Unknown location type: ${(data as any).type}`); + } + } +} + +/** + * URI-based service location for API and web services + * Used by: JMAP, Gmail API, etc. + */ +export class LocationUri extends Location { + readonly type = 'URI' as const; + scheme: string; + host: string; + port: number; + path?: string; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + scheme: string = 'https', + host: string = '', + port: number = 443, + path?: string, + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.scheme = scheme; + this.host = host; + this.port = port; + this.path = path; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationUri): LocationUri { + return new LocationUri( + data.scheme, + data.host, + data.port, + data.path, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationUri { + return { + type: this.type, + scheme: this.scheme, + host: this.host, + port: this.port, + ...(this.path && { path: this.path }), + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } + + getUrl(): string { + const path = this.path || ''; + return `${this.scheme}://${this.host}:${this.port}${path}`; + } +} + +/** + * Single socket-based service location + * Used by: services using a single host/port combination + */ +export class LocationSocketSole extends Location { + readonly type = 'SOCKET_SOLE' as const; + host: string; + port: number; + encryption: 'none' | 'ssl' | 'tls' | 'starttls'; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + host: string = '', + port: number = 993, + encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.host = host; + this.port = port; + this.encryption = encryption; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationSocketSole): LocationSocketSole { + return new LocationSocketSole( + data.host, + data.port, + data.encryption, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSole { + return { + type: this.type, + host: this.host, + port: this.port, + encryption: this.encryption, + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } +} + +/** + * Split socket-based service location + * Used by: traditional IMAP/SMTP configurations + */ +export class LocationSocketSplit extends Location { + readonly type = 'SOCKET_SPLIT' as const; + inboundHost: string; + inboundPort: number; + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + outboundHost: string; + outboundPort: number; + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + inboundVerifyPeer: boolean; + inboundVerifyHost: boolean; + outboundVerifyPeer: boolean; + outboundVerifyHost: boolean; + + constructor( + inboundHost: string = '', + inboundPort: number = 993, + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + outboundHost: string = '', + outboundPort: number = 465, + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + inboundVerifyPeer: boolean = true, + inboundVerifyHost: boolean = true, + outboundVerifyPeer: boolean = true, + outboundVerifyHost: boolean = true + ) { + super(); + this.inboundHost = inboundHost; + this.inboundPort = inboundPort; + this.inboundEncryption = inboundEncryption; + this.outboundHost = outboundHost; + this.outboundPort = outboundPort; + this.outboundEncryption = outboundEncryption; + this.inboundVerifyPeer = inboundVerifyPeer; + this.inboundVerifyHost = inboundVerifyHost; + this.outboundVerifyPeer = outboundVerifyPeer; + this.outboundVerifyHost = outboundVerifyHost; + } + + static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit { + return new LocationSocketSplit( + data.inboundHost, + data.inboundPort, + data.inboundEncryption, + data.outboundHost, + data.outboundPort, + data.outboundEncryption, + data.inboundVerifyPeer ?? true, + data.inboundVerifyHost ?? true, + data.outboundVerifyPeer ?? true, + data.outboundVerifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSplit { + return { + type: this.type, + inboundHost: this.inboundHost, + inboundPort: this.inboundPort, + inboundEncryption: this.inboundEncryption, + outboundHost: this.outboundHost, + outboundPort: this.outboundPort, + outboundEncryption: this.outboundEncryption, + ...(this.inboundVerifyPeer !== undefined && { inboundVerifyPeer: this.inboundVerifyPeer }), + ...(this.inboundVerifyHost !== undefined && { inboundVerifyHost: this.inboundVerifyHost }), + ...(this.outboundVerifyPeer !== undefined && { outboundVerifyPeer: this.outboundVerifyPeer }), + ...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost }) + }; + } +} + +/** + * File-based service location + * Used by: local file system providers + */ +export class LocationFile extends Location { + readonly type = 'FILE' as const; + path: string; + + constructor(path: string = '') { + super(); + this.path = path; + } + + static fromJson(data: ServiceLocationFile): LocationFile { + return new LocationFile(data.path); + } + + toJson(): ServiceLocationFile { + return { + type: this.type, + path: this.path + }; + } +} diff --git a/src/models/message.ts b/src/models/message.ts new file mode 100644 index 0000000..8503d7f --- /dev/null +++ b/src/models/message.ts @@ -0,0 +1,376 @@ +/** + * Message and MessagePart model classes + */ + +import type { MessageInterface, MessagePartInterface } from "@/types/message"; + +/** + * MessagePart class for working with message body parts + */ +export class MessagePartObject { + + _data: MessagePartInterface; + + constructor(data?: Partial) { + this._data = { + partId: data?.partId ?? null, + blobId: data?.blobId ?? null, + size: data?.size ?? null, + name: data?.name ?? null, + type: data?.type ?? undefined, + charset: data?.charset ?? null, + disposition: data?.disposition ?? null, + cid: data?.cid ?? null, + language: data?.language ?? null, + location: data?.location ?? null, + content: data?.content ?? undefined, + subParts: data?.subParts ?? undefined, + }; + } + + fromJson(data: MessagePartInterface): MessagePartObject { + this._data = data; + return this; + } + + toJson(): MessagePartInterface { + return this._data; + } + + clone(): MessagePartObject { + return new MessagePartObject(JSON.parse(JSON.stringify(this._data))); + } + + /** Properties */ + + get partId(): string | null | undefined { + return this._data.partId; + } + + get blobId(): string | null | undefined { + return this._data.blobId; + } + + get size(): number | null | undefined { + return this._data.size; + } + + get name(): string | null | undefined { + return this._data.name; + } + + get type(): string | undefined { + return this._data.type; + } + + get charset(): string | null | undefined { + return this._data.charset; + } + + get disposition(): string | null | undefined { + return this._data.disposition; + } + + get cid(): string | null | undefined { + return this._data.cid; + } + + get language(): string | null | undefined { + return this._data.language; + } + + get location(): string | null | undefined { + return this._data.location; + } + + get content(): string | undefined { + return this._data.content; + } + + get subParts(): MessagePartInterface[] | undefined { + return this._data.subParts; + } + + /** Helper methods */ + + hasContent(): boolean { + return !!this._data.content; + } + + hasSubParts(): boolean { + return !!this._data.subParts && this._data.subParts.length > 0; + } + + isMultipart(): boolean { + return this._data.type?.startsWith('multipart/') ?? false; + } + + isText(): boolean { + return this._data.type === 'text/plain'; + } + + isHtml(): boolean { + return this._data.type === 'text/html'; + } + + isAttachment(): boolean { + return this._data.disposition === 'attachment'; + } + + isInline(): boolean { + return this._data.disposition === 'inline'; + } + + /** + * Find a part by partId (recursive search) + */ + findPartById(partId: string): MessagePartInterface | null { + if (this._data.partId === partId) { + return this._data; + } + + if (this._data.subParts) { + for (const subPart of this._data.subParts) { + const part = new MessagePartObject(subPart); + const found = part.findPartById(partId); + if (found) { + return found; + } + } + } + + return null; + } + + /** + * Find all parts of a specific type (recursive search) + */ + findPartsByType(type: string): MessagePartInterface[] { + const parts: MessagePartInterface[] = []; + + if (this._data.type === type) { + parts.push(this._data); + } + + if (this._data.subParts) { + for (const subPart of this._data.subParts) { + const part = new MessagePartObject(subPart); + parts.push(...part.findPartsByType(type)); + } + } + + return parts; + } + + /** + * Extract text content from body structure + */ + extractTextContent(): string | null { + if (this._data.type === 'text/plain' && this._data.content) { + return this._data.content; + } + + if (this._data.subParts) { + for (const subPart of this._data.subParts) { + const part = new MessagePartObject(subPart); + const content = part.extractTextContent(); + if (content) { + return content; + } + } + } + + return null; + } + + /** + * Extract HTML content from body structure + */ + extractHtmlContent(): string | null { + if (this._data.type === 'text/html' && this._data.content) { + return this._data.content; + } + + if (this._data.subParts) { + for (const subPart of this._data.subParts) { + const part = new MessagePartObject(subPart); + const content = part.extractHtmlContent(); + if (content) { + return content; + } + } + } + + return null; + } + +} + +/** + * Message class for working with message objects + */ +export class MessageObject { + + _data: MessageInterface; + _body: MessagePartObject | null = null; + + constructor(data?: Partial) { + this._data = { + urid: data?.urid ?? undefined, + size: data?.size ?? undefined, + receivedDate: data?.receivedDate ?? undefined, + date: data?.date ?? undefined, + subject: data?.subject ?? undefined, + snippet: data?.snippet ?? undefined, + from: data?.from ?? undefined, + to: data?.to ?? [], + cc: data?.cc ?? [], + bcc: data?.bcc ?? [], + replyTo: data?.replyTo ?? [], + flags: data?.flags ?? {}, + body: data?.body ?? undefined, + attachments: data?.attachments ?? [], + }; + } + + fromJson(data: MessageInterface): MessageObject { + this._data = data; + return this; + } + + toJson(): MessageInterface { + return this._data; + } + + clone(): MessageObject { + return new MessageObject(JSON.parse(JSON.stringify(this._data))); + } + + /** Properties */ + + get urid(): string | undefined { + return this._data.urid; + } + + get size(): number | undefined { + return this._data.size; + } + + get receivedDate(): string | undefined { + return this._data.receivedDate; + } + + get date(): string | undefined { + return this._data.date; + } + + get subject(): string | undefined { + return this._data.subject; + } + + get snippet(): string | undefined { + return this._data.snippet; + } + + get from(): { address: string; label?: string } | undefined { + return this._data.from; + } + + get to(): Array<{ address: string; label?: string }> | undefined { + return this._data.to; + } + + get cc(): Array<{ address: string; label?: string }> | undefined { + return this._data.cc; + } + + get bcc(): Array<{ address: string; label?: string }> | undefined { + return this._data.bcc; + } + + get replyTo(): Array<{ address: string; label?: string }> | undefined { + return this._data.replyTo; + } + + get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | undefined { + return this._data.flags; + } + + get body(): MessagePartInterface | undefined { + return this._data.body; + } + + get attachments(): MessageInterface['attachments'] { + return this._data.attachments; + } + + /** Helper methods */ + + get isRead(): boolean { + return this._data.flags?.read ?? false; + } + + get isFlagged(): boolean { + return this._data.flags?.flagged ?? false; + } + + get isAnswered(): boolean { + return this._data.flags?.answered ?? false; + } + + get isDraft(): boolean { + return this._data.flags?.draft ?? false; + } + + get hasAttachments(): boolean { + return (this._data.attachments?.length ?? 0) > 0; + } + + hasRecipients(): boolean { + return (this._data.to?.length ?? 0) > 0 + || (this._data.cc?.length ?? 0) > 0 + || (this._data.bcc?.length ?? 0) > 0; + } + + /** Body content helpers */ + + getBody(): MessagePartObject | null { + if (!this._body && this._data.body) { + this._body = new MessagePartObject(this._data.body); + } + return this._body; + } + + hasContent(): boolean { + return !!this.getTextContent() || !!this.getHtmlContent(); + } + + hasTextContent(): boolean { + return !!this.getTextContent(); + } + + getTextContent(): string | null { + const bodyPart = this.getBody(); + return bodyPart ? bodyPart.extractTextContent() : null; + } + + hasHtmlContent(): boolean { + return !!this.getHtmlContent(); + } + + getHtmlContent(): string | null { + const bodyPart = this.getBody(); + return bodyPart ? bodyPart.extractHtmlContent() : null; + } + + findPartById(partId: string): MessagePartInterface | null { + const bodyPart = this.getBody(); + return bodyPart ? bodyPart.findPartById(partId) : null; + } + + findPartsByType(type: string): MessagePartInterface[] { + const bodyPart = this.getBody(); + return bodyPart ? bodyPart.findPartsByType(type) : []; + } + +} diff --git a/src/models/provider.ts b/src/models/provider.ts new file mode 100644 index 0000000..0f171a7 --- /dev/null +++ b/src/models/provider.ts @@ -0,0 +1,62 @@ +/** + * Class model for Provider Interface + */ + +import type { + ProviderInterface, + ProviderCapabilitiesInterface +} from "@/types/provider"; + +export class ProviderObject implements ProviderInterface { + + _data!: ProviderInterface; + + constructor() { + this._data = { + '@type': 'mail.provider', + identifier: '', + label: '', + capabilities: {}, + }; + } + + fromJson(data: ProviderInterface): ProviderObject { + this._data = data; + return this; + } + + toJson(): ProviderInterface { + return this._data; + } + + capable(capability: keyof ProviderCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; + } + + capability(capability: keyof ProviderCapabilitiesInterface): any | null { + if (this._data.capabilities) { + return this._data.capabilities[capability]; + } + return null; + } + + /** Immutable Properties */ + + get '@type'(): string { + return this._data['@type']; + } + + get identifier(): string { + return this._data.identifier; + } + + get label(): string { + return this._data.label; + } + + get capabilities(): ProviderCapabilitiesInterface { + return this._data.capabilities; + } + +} diff --git a/src/models/service.ts b/src/models/service.ts new file mode 100644 index 0000000..2d232a8 --- /dev/null +++ b/src/models/service.ts @@ -0,0 +1,136 @@ +/** + * Class model for Service Interface + */ + +import type { + ServiceInterface, + ServiceCapabilitiesInterface, + ServiceIdentity, + ServiceLocation +} from "@/types/service"; +import { Identity } from './identity'; +import { Location } from './location'; + +export class ServiceObject implements ServiceInterface { + + _data!: ServiceInterface; + + constructor() { + this._data = { + '@type': 'mail:service', + provider: '', + identifier: null, + label: null, + enabled: false, + capabilities: {} + }; + } + + fromJson(data: ServiceInterface): ServiceObject { + this._data = data; + return this; + } + + toJson(): ServiceInterface { + return this._data; + } + + capable(capability: keyof ServiceCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; + } + + capability(capability: keyof ServiceCapabilitiesInterface): any | null { + if (this._data.capabilities) { + return this._data.capabilities[capability]; + } + return null; + } + + /** Immutable Properties */ + + get '@type'(): string { + return this._data['@type']; + } + + get provider(): string { + return this._data.provider; + } + + get identifier(): string | number | null { + return this._data.identifier; + } + + get capabilities(): ServiceCapabilitiesInterface | undefined { + return this._data.capabilities; + } + + get primaryAddress(): string | null { + return this._data.primaryAddress ?? null; + } + + get secondaryAddresses(): string[] { + return this._data.secondaryAddresses ?? []; + } + + /** Mutable Properties */ + + get label(): string | null { + return this._data.label; + } + + set label(value: string | null) { + this._data.label = value; + } + + get enabled(): boolean { + return this._data.enabled; + } + + set enabled(value: boolean) { + this._data.enabled = value; + } + + get location(): ServiceLocation | null { + return this._data.location ?? null; + } + + set location(value: ServiceLocation | null) { + this._data.location = value; + } + + get identity(): ServiceIdentity | null { + return this._data.identity ?? null; + } + + set identity(value: ServiceIdentity | null) { + this._data.identity = value; + } + + get auxiliary(): Record { + return this._data.auxiliary ?? {}; + } + + set auxiliary(value: Record) { + this._data.auxiliary = value; + } + + /** Helper Methods */ + + /** + * Get identity as a class instance for easier manipulation + */ + getIdentity(): Identity | null { + if (!this._data.identity) return null; + return Identity.fromJson(this._data.identity); + } + + /** + * Get location as a class instance for easier manipulation + */ + getLocation(): Location | null { + if (!this._data.location) return null; + return Location.fromJson(this._data.location); + } + +} diff --git a/src/pages/AccountsPage.vue b/src/pages/AccountsPage.vue new file mode 100644 index 0000000..b1c15be --- /dev/null +++ b/src/pages/AccountsPage.vue @@ -0,0 +1,375 @@ + + + \ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..8ccea51 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,13 @@ +const routes = [ + { + name: 'mail-accounts', + path: '/accounts', + component: () => import('@/pages/AccountsPage.vue'), + meta: { + title: 'Mail Accounts', + requiresAuth: true + } + }, +] + +export default routes diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts new file mode 100644 index 0000000..f6e12df --- /dev/null +++ b/src/services/collectionService.ts @@ -0,0 +1,84 @@ +/** + * Collection management service + */ + +import { transceivePost } from './transceive'; +import type { + CollectionListRequest, + CollectionListResponse, + CollectionExtantRequest, + CollectionExtantResponse, + CollectionFetchRequest, + CollectionFetchResponse, + CollectionCreateRequest, + CollectionCreateResponse, + CollectionModifyRequest, + CollectionModifyResponse, + CollectionDestroyRequest, + CollectionDestroyResponse +} from '../types/collection'; + +export const collectionService = { + + /** + * List all available collections + * + * @param request - Collection list request parameters + * @returns Promise with collection list grouped by provider and service + */ + async list(request: CollectionListRequest = {}): Promise { + return await transceivePost('collection.list', request); + }, + + /** + * Check which collections exist/are available + * + * @param request - Collection extant request with source selector + * @returns Promise with collection availability status + */ + async extant(request: CollectionExtantRequest): Promise { + return await transceivePost('collection.extant', request); + }, + + /** + * Fetch a specific collection + * + * @param request - Collection fetch request + * @returns Promise with collection details + */ + async fetch(request: CollectionFetchRequest): Promise { + return await transceivePost('collection.fetch', request); + }, + + /** + * Create a new collection/folder + * + * @param request - Collection creation parameters + * @returns Promise with created collection details + */ + async create(request: CollectionCreateRequest): Promise { + return await transceivePost('collection.create', request); + }, + + /** + * Modify an existing collection/folder + * + * @param request - Collection modification parameters + * @returns Promise with modified collection details + */ + async modify(request: CollectionModifyRequest): Promise { + return await transceivePost('collection.modify', request); + }, + + /** + * Destroy/delete a collection/folder + * + * @param request - Collection destroy parameters + * @returns Promise with destroy operation result + */ + async destroy(request: CollectionDestroyRequest): Promise { + return await transceivePost('collection.destroy', request); + }, +}; + +export default collectionService; diff --git a/src/services/entityService.ts b/src/services/entityService.ts new file mode 100644 index 0000000..cafc6da --- /dev/null +++ b/src/services/entityService.ts @@ -0,0 +1,120 @@ +/** + * Message/Entity management service + */ + +import { transceivePost } from './transceive'; +import type { + MessageListRequest, + MessageListResponse, + MessageDeltaRequest, + MessageDeltaResponse, + MessageExtantRequest, + MessageExtantResponse, + MessageFetchRequest, + MessageFetchResponse, + MessageSearchRequest, + MessageSearchResponse, + MessageSendRequest, + MessageSendResponse, + MessageCreateRequest, + MessageCreateResponse, + MessageUpdateRequest, + MessageUpdateResponse, + MessageDestroyRequest, + MessageDestroyResponse, +} from '../types/entity'; + +export const entityService = { + + /** + * List all available messages + * + * @param request - Message list request parameters + * @returns Promise with message list grouped by provider, service, and collection + */ + async list(request: MessageListRequest = {}): Promise { + return await transceivePost('entity.list', request); + }, + + /** + * Get delta changes for messages + * + * @param request - Message delta request with source selector + * @returns Promise with delta changes (created, modified, deleted) + */ + async delta(request: MessageDeltaRequest): Promise { + return await transceivePost('entity.delta', request); + }, + + /** + * Check which messages exist/are available + * + * @param request - Message extant request with source selector + * @returns Promise with message availability status + */ + async extant(request: MessageExtantRequest): Promise { + return await transceivePost('entity.extant', request); + }, + + /** + * Fetch specific messages + * + * @param request - Message fetch request + * @returns Promise with message details + */ + async fetch(request: MessageFetchRequest): Promise { + return await transceivePost('entity.fetch', request); + }, + + /** + * Search messages + * + * @param request - Message search request + * @returns Promise with search results + */ + async search(request: MessageSearchRequest): Promise { + return await transceivePost('entity.search', request); + }, + + /** + * Send a message + * + * @param request - Message send request + * @returns Promise with send result + */ + async send(request: MessageSendRequest): Promise { + return await transceivePost('entity.send', request); + }, + + /** + * Create a new message (draft) + * + * @param request - Message create request + * @returns Promise with created message details + */ + async create(request: MessageCreateRequest): Promise { + return await transceivePost('entity.create', request); + }, + + /** + * Update an existing message (flags, labels, etc.) + * + * @param request - Message update request + * @returns Promise with update result + */ + async update(request: MessageUpdateRequest): Promise { + return await transceivePost('entity.update', request); + }, + + /** + * Delete/destroy a message + * + * @param request - Message destroy request + * @returns Promise with destroy result + */ + async destroy(request: MessageDestroyRequest): Promise { + return await transceivePost('entity.destroy', request); + }, +}; + +export default entityService; diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..fd1f9a7 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,16 @@ +/** + * Central export point for all Mail Manager services + */ + +// Services +export { providerService } from './providerService'; +export { serviceService } from './serviceService'; +export { collectionService } from './collectionService'; +export { entityService } from './entityService'; + +// Type exports +export type * from '../types/common'; +export type * from '../types/provider'; +export type * from '../types/service'; +export type * from '../types/collection'; +export type * from '../types/entity'; diff --git a/src/services/providerService.ts b/src/services/providerService.ts new file mode 100644 index 0000000..39cfb9e --- /dev/null +++ b/src/services/providerService.ts @@ -0,0 +1,62 @@ +/** + * Provider management service + */ + +import type { + ProviderListRequest, + ProviderListResponse, + ProviderExtantRequest, + ProviderExtantResponse, + ProviderFetchRequest, + ProviderFetchResponse, +} from '../types/provider'; +import { transceivePost } from './transceive'; +import { ProviderObject } from '../models/provider'; + + +export const providerService = { + + /** + * List available providers + * + * @param request - List request parameters + * + * @returns Promise with provider object list keyed by provider identifier + */ + async list(request: ProviderListRequest = {}): Promise> { + const response = await transceivePost('provider.list', request); + + // Convert response to ProviderObject instances + const list: Record = {}; + Object.entries(response).forEach(([providerId, providerData]) => { + list[providerId] = new ProviderObject().fromJson(providerData); + }); + + return list; + }, + + /** + * Fetch a specific provider + * + * @param request - Fetch request parameters + * + * @returns Promise with provider object + */ + async fetch(request: ProviderFetchRequest): Promise { + const response = await transceivePost('provider.fetch', request); + return new ProviderObject().fromJson(response); + }, + + /** + * Check which providers exist/are available + * + * @param request - Extant request parameters + * + * @returns Promise with provider availability status + */ + async extant(request: ProviderExtantRequest): Promise { + return await transceivePost('provider.extant', request); + }, +}; + +export default providerService; diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts new file mode 100644 index 0000000..dca88f8 --- /dev/null +++ b/src/services/serviceService.ts @@ -0,0 +1,154 @@ +/** + * Service management service + */ + +import type { + ServiceListRequest, + ServiceListResponse, + ServiceExtantRequest, + ServiceExtantResponse, + ServiceFetchRequest, + ServiceFetchResponse, + ServiceDiscoverRequest, + ServiceDiscoverResponse, + ServiceTestRequest, + ServiceTestResponse, + ServiceInterface, + ServiceCreateResponse, + ServiceCreateRequest, + ServiceUpdateResponse, + ServiceUpdateRequest, +} from '../types/service'; +import { transceivePost } from './transceive'; +import { ServiceObject } from '../models/service'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; + +/** + * Helper to create the right service model class based on provider + * Uses provider-specific factory if available, otherwise returns base ServiceObject + */ +function createServiceObject(data: ServiceInterface): ServiceObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('mail_service_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new ServiceObject().fromJson(data); +} + +export const serviceService = { + + /** + * List available services + * + * @param request - Service list request parameters + * + * @returns Promise with service object list grouped by provider and keyed by service identifier + */ + async list(request: ServiceListRequest = {}): Promise>> { + const response = await transceivePost('service.list', request); + + // Convert nested response to ServiceObject instances + const list: Record> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + list[providerId] = {}; + Object.entries(providerServices).forEach(([serviceId, serviceData]) => { + list[providerId][serviceId] = createServiceObject(serviceData); + }); + }); + + return list; + }, + + /** + * Check which services exist/are available + * + * @param request - Service extant request with source selector + * @returns Promise with service availability status + */ + async extant(request: ServiceExtantRequest): Promise { + return await transceivePost('service.extant', request); + }, + + /** + * Fetch a specific service + * + * @param request - Service fetch request with provider and service IDs + * @returns Promise with service object + */ + async fetch(request: ServiceFetchRequest): Promise { + const response = await transceivePost('service.fetch', request); + return createServiceObject(response); + }, + + /** + * Discover mail service configuration from identity + * + * @param request - Discovery request with identity and optional hints + * @returns Promise with array of discovered services sorted by provider + */ + async discover(request: ServiceDiscoverRequest): Promise { + const response = await transceivePost('service.discover', request); + + // Convert discovery results to ServiceObjects + const services: ServiceObject[] = []; + Object.entries(response).forEach(([providerId, location]) => { + const serviceData: ServiceInterface = { + '@type': 'mail:service', + provider: providerId, + identifier: null, + label: null, + enabled: false, + location: location, + }; + services.push(createServiceObject(serviceData)); + }); + + // Sort by provider + return services.sort((a, b) => a.provider.localeCompare(b.provider)); + }, + + /** + * Test a mail service connection + * + * @param request - Service test request + * @returns Promise with test results + */ + async test(request: ServiceTestRequest): Promise { + return await transceivePost('service.test', request); + }, + + /** + * Create a new service + * + * @param request - Service create request with provider ID and service data + * @returns Promise with created service object + */ + async create(request: ServiceCreateRequest): Promise { + const response = await transceivePost('service.create', request); + return createServiceObject(response); + }, + + /** + * Update a existing service + * + * @param request - Service update request with provider ID, service ID, and updated data + * @returns Promise with updated service object + */ + async update(request: ServiceUpdateRequest): Promise { + const response = await transceivePost('service.update', request); + return createServiceObject(response); + }, + + /** + * Delete a service + * + * @param request - Service delete request with provider ID and service ID + * @returns Promise with deletion result + */ + async delete(request: { provider: string; identifier: string | number }): Promise { + return await transceivePost('service.delete', request); + }, +}; + +export default serviceService; diff --git a/src/services/transceive.ts b/src/services/transceive.ts new file mode 100644 index 0000000..1fe51cf --- /dev/null +++ b/src/services/transceive.ts @@ -0,0 +1,50 @@ +/** + * API Client for Mail Manager + * Provides a centralized way to make API calls with envelope wrapping/unwrapping + */ + +import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; +import type { ApiRequest, ApiResponse } from '../types/common'; + +const fetchWrapper = createFetchWrapper(); +const API_URL = '/m/mail_manager/v1'; +const API_VERSION = 1; + +/** + * Generate a unique transaction ID + */ +export function generateTransactionId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Make an API call with automatic envelope wrapping and unwrapping + * + * @param operation - Operation name (e.g., 'provider.list', 'service.autodiscover') + * @param data - Operation-specific request data + * @param user - Optional user identifier override + * @returns Promise with unwrapped response data + * @throws Error if the API returns an error status + */ +export async function transceivePost( + operation: string, + data: TRequest, + user?: string +): Promise { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user + }; + + const response: ApiResponse = await fetchWrapper.post(API_URL, request); + + if (response.status === 'error') { + const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`; + throw new Error(errorMessage); + } + + return response.data; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts new file mode 100644 index 0000000..38ae48f --- /dev/null +++ b/src/stores/collectionsStore.ts @@ -0,0 +1,169 @@ +import { defineStore } from 'pinia' +import { collectionService } from '../services' +import type { CollectionInterface, CollectionCreateRequest } from '../types' +import { CollectionObject, CollectionPropertiesObject } from '../models/collection' + +export const useCollectionsStore = defineStore('mail-collections', { + state: () => ({ + collections: {} as Record>>, + loading: false, + error: null as string | null, + }), + + actions: { + async loadCollections(sources?: any) { + this.loading = true + this.error = null + try { + const response = await collectionService.list({ sources }) + + // Response is already in nested object format: provider -> service -> collection + // Transform to CollectionObject instances + const transformed: Record>> = {} + + for (const [providerId, providerData] of Object.entries(response)) { + transformed[providerId] = {} + + for (const [serviceId, collections] of Object.entries(providerData as any)) { + transformed[providerId][serviceId] = {} + + // Collections come as an object keyed by identifier + for (const [collectionId, collection] of Object.entries(collections as any)) { + // Create CollectionObject instance with provider and service set + const collectionData = { + ...collection, + provider: providerId, + service: serviceId, + } as CollectionInterface + + transformed[providerId][serviceId][collectionId] = new CollectionObject().fromJson(collectionData) + } + } + } + + this.collections = transformed + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async getCollection(provider: string, service: string | number, collectionId: string | number) { + this.loading = true + this.error = null + try { + const response = await collectionService.fetch({ + provider, + service, + collection: collectionId + }) + + // Create CollectionObject instance + const collectionObject = new CollectionObject().fromJson(response) + + // Update in store + if (!this.collections[provider]) { + this.collections[provider] = {} + } + if (!this.collections[provider][String(service)]) { + this.collections[provider][String(service)] = {} + } + this.collections[provider][String(service)][String(collectionId)] = collectionObject + + return collectionObject + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async createCollection(params: { + provider: string + service: string | number + collection?: string | number | null + properties: CollectionPropertiesObject + }): Promise { + this.loading = true + this.error = null + + try { + // Prepare request data from CollectionPropertiesObject + const requestData: CollectionCreateRequest = { + provider: params.provider, + service: params.service, + collection: params.collection ?? null, + properties: { + '@type': 'mail.collection', + label: params.properties.label, + role: params.properties.role ?? null, + rank: params.properties.rank ?? 0, + subscribed: params.properties.subscribed ?? true, + }, + } + + // Call service to create collection + const response = await collectionService.create(requestData) + + // Create CollectionObject instance + const collectionObject = new CollectionObject().fromJson(response) + + // Update store with new collection + const provider = response.provider + const service = String(response.service) + const identifier = String(response.identifier) + + if (!this.collections[provider]) { + this.collections[provider] = {} + } + if (!this.collections[provider][service]) { + this.collections[provider][service] = {} + } + + this.collections[provider][service][identifier] = collectionObject + + return collectionObject + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + }, + + getters: { + collectionList: (state) => { + const list: CollectionObject[] = [] + Object.values(state.collections).forEach(providerCollections => { + Object.values(providerCollections).forEach(serviceCollections => { + Object.values(serviceCollections).forEach(collection => { + list.push(collection) + }) + }) + }) + return list + }, + + collectionCount: (state) => { + let count = 0 + Object.values(state.collections).forEach(providerCollections => { + Object.values(providerCollections).forEach(serviceCollections => { + count += Object.keys(serviceCollections).length + }) + }) + return count + }, + + hasCollections: (state) => { + return Object.values(state.collections).some(providerCollections => + Object.values(providerCollections).some(serviceCollections => + Object.keys(serviceCollections).length > 0 + ) + ) + }, + }, +}) diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts new file mode 100644 index 0000000..6632cb4 --- /dev/null +++ b/src/stores/entitiesStore.ts @@ -0,0 +1,261 @@ +import { defineStore } from 'pinia' +import { entityService } from '../services' +import type { MessageObject, EntityWrapper, MessageSendRequest } from '../types' + +export const useEntitiesStore = defineStore('mail-entities', { + state: () => ({ + messages: {} as Record>>>>, + signatures: {} as Record>>, // Track delta signatures + loading: false, + error: null as string | null, + }), + + actions: { + async loadMessages(sources?: any, filter?: any, sort?: any, range?: any) { + this.loading = true + this.error = null + try { + const response = await entityService.list({ sources, filter, sort, range }) + + // Entities come as objects keyed by identifier + Object.entries(response).forEach(([provider, providerData]) => { + Object.entries(providerData).forEach(([service, serviceData]) => { + Object.entries(serviceData).forEach(([collection, entities]) => { + if (!this.messages[provider]) { + this.messages[provider] = {} + } + if (!this.messages[provider][service]) { + this.messages[provider][service] = {} + } + if (!this.messages[provider][service][collection]) { + this.messages[provider][service][collection] = {} + } + + // Entities are already keyed by identifier + this.messages[provider][service][collection] = entities as Record> + }) + }) + }) + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async getMessages( + provider: string, + service: string | number, + collection: string | number, + identifiers: (string | number)[], + properties?: string[] + ) { + this.loading = true + this.error = null + try { + const response = await entityService.fetch({ + provider, + service, + collection, + identifiers, + properties + }) + + // Update in store + if (!this.messages[provider]) { + this.messages[provider] = {} + } + if (!this.messages[provider][String(service)]) { + this.messages[provider][String(service)] = {} + } + if (!this.messages[provider][String(service)][String(collection)]) { + this.messages[provider][String(service)][String(collection)] = {} + } + + // Index fetched entities by identifier + response.entities.forEach((entity: EntityWrapper) => { + this.messages[provider][String(service)][String(collection)][entity.identifier] = entity + }) + + return response + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async searchMessages( + provider: string, + service: string | number, + query: string, + collections?: (string | number)[], + filter?: any, + sort?: any, + range?: any + ) { + this.loading = true + this.error = null + try { + const response = await entityService.search({ + provider, + service, + query, + collections, + filter, + sort, + range + }) + return response + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async sendMessage(request: MessageSendRequest) { + this.loading = true + this.error = null + try { + const response = await entityService.send(request) + return response + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + + async getDelta(sources: any) { + this.loading = true + this.error = null + try { + // Sources are already in correct format: { provider: { service: { collection: signature } } } + const response = await entityService.delta({ sources }) + + // Process delta and update store + Object.entries(response).forEach(([provider, providerData]) => { + Object.entries(providerData).forEach(([service, serviceData]) => { + Object.entries(serviceData).forEach(([collection, collectionData]) => { + // Skip if no changes (server returns false or string signature) + if (collectionData === false || typeof collectionData === 'string') { + return + } + + if (!this.messages[provider]) { + this.messages[provider] = {} + } + if (!this.messages[provider][service]) { + this.messages[provider][service] = {} + } + if (!this.messages[provider][service][collection]) { + this.messages[provider][service][collection] = {} + } + + const collectionMessages = this.messages[provider][service][collection] + + // Update signature if provided + if (typeof collectionData === 'object' && collectionData.signature) { + if (!this.signatures[provider]) { + this.signatures[provider] = {} + } + if (!this.signatures[provider][service]) { + this.signatures[provider][service] = {} + } + this.signatures[provider][service][collection] = collectionData.signature + console.log(`[Store] Updated signature for ${provider}/${service}/${collection}: "${collectionData.signature}"`) + } + + // Process additions (from delta response format) + if (collectionData.additions) { + // Note: additions are just identifiers, need to fetch full entities separately + // This is handled by the sync composable + } + + // Process modifications + if (collectionData.modifications) { + // Note: modifications are just identifiers, need to fetch full entities separately + } + + // Remove deleted messages + if (collectionData.deletions) { + collectionData.deletions.forEach((id: string | number) => { + delete collectionMessages[String(id)] + }) + } + + // Legacy support: Also handle created/modified/deleted format + if (collectionData.created) { + collectionData.created.forEach((entity: EntityWrapper) => { + collectionMessages[entity.identifier] = entity + }) + } + + if (collectionData.modified) { + collectionData.modified.forEach((entity: EntityWrapper) => { + collectionMessages[entity.identifier] = entity + }) + } + + if (collectionData.deleted) { + collectionData.deleted.forEach((id: string | number) => { + delete collectionMessages[String(id)] + }) + } + }) + }) + }) + + return response + } catch (error: any) { + this.error = error.message + throw error + } finally { + this.loading = false + } + }, + }, + + getters: { + messageList: (state) => { + const list: EntityWrapper[] = [] + Object.values(state.messages).forEach(providerMessages => { + Object.values(providerMessages).forEach(serviceMessages => { + Object.values(serviceMessages).forEach(collectionMessages => { + Object.values(collectionMessages).forEach(message => { + list.push(message) + }) + }) + }) + }) + return list + }, + + messageCount: (state) => { + let count = 0 + Object.values(state.messages).forEach(providerMessages => { + Object.values(providerMessages).forEach(serviceMessages => { + Object.values(serviceMessages).forEach(collectionMessages => { + count += Object.keys(collectionMessages).length + }) + }) + }) + return count + }, + + hasMessages: (state) => { + return Object.values(state.messages).some(providerMessages => + Object.values(providerMessages).some(serviceMessages => + Object.values(serviceMessages).some(collectionMessages => + Object.keys(collectionMessages).length > 0 + ) + ) + ) + }, + }, +}) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts new file mode 100644 index 0000000..1194c6e --- /dev/null +++ b/src/stores/providersStore.ts @@ -0,0 +1,108 @@ +/** + * Providers Store + */ + +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { providerService } from '../services' +import { ProviderObject } from '../models/provider' +import type { SourceSelector } from '../types' + +export const useProvidersStore = defineStore('mailProvidersStore', () => { + // State + const _providers = ref>({}) + const transceiving = ref(false) + const error = ref(null) + + // Getters + const count = computed(() => Object.keys(_providers.value).length) + const has = computed(() => count.value > 0) + + /** + * Get providers as an array + * @returns Array of provider objects + */ + const providers = computed(() => Object.values(_providers.value)) + + /** + * Get a specific provider by identifier from cache + * @param identifier - Provider identifier + * @returns Provider object or null + */ + function provider(identifier: string): ProviderObject | null { + return _providers.value[identifier] || null + } + + // Actions + /** + * Retrieve all or specific providers + */ + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true + error.value = null + try { + const response = await providerService.list({ sources }) + + console.debug('[Mail Manager](Store) - Successfully retrieved', Object.keys(response).length, 'providers') + + _providers.value = response + return response + } catch (err: any) { + console.error('[Mail Manager](Store) - Failed to retrieve providers:', err) + error.value = err.message + throw err + } finally { + transceiving.value = false + } + } + + /** + * Fetch a specific provider + */ + async function fetch(identifier: string): Promise { + transceiving.value = true + try { + return await providerService.fetch({ identifier }) + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to fetch provider:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Check which providers exist/are available + */ + async function extant(sources: SourceSelector) { + transceiving.value = true + error.value = null + try { + const response = await providerService.extant({ sources }) + console.debug('[Mail Manager](Store) - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + return response + } catch (err: any) { + console.error('[Mail Manager](Store) - Failed to check providers:', err) + error.value = err.message + throw err + } finally { + transceiving.value = false + } + } + + // Return public API + return { + // State + transceiving: readonly(transceiving), + error: readonly(error), + // computed + count, + has, + providers, + provider, + // functions + list, + fetch, + extant, + } +}) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts new file mode 100644 index 0000000..7aecb98 --- /dev/null +++ b/src/stores/servicesStore.ts @@ -0,0 +1,209 @@ +/** + * Services Store + */ + +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { serviceService } from '../services' +import { ServiceObject } from '../models/service' +import type { + ServiceLocation, + SourceSelector, + ServiceIdentity, +} from '../types' + +export const useServicesStore = defineStore('mailServicesStore', () => { + // State + const _services = ref>({}) + const transceiving = ref(false) + const lastTestResult = ref(null) + + // Getters + const count = computed(() => Object.keys(_services.value).length) + const has = computed(() => count.value > 0) + + /** + * Get services as an array + * @returns Array of service objects + */ + const services = computed(() => Object.values(_services.value)) + + /** + * Get services grouped by provider + * @returns Services grouped by provider ID + */ + const servicesByProvider = computed(() => { + const groups: Record = {} + + Object.values(_services.value).forEach((service) => { + if (!groups[service.provider]) { + groups[service.provider] = [] + } + groups[service.provider].push(service) + }) + + return groups + }) + + // Actions + /** + * Retrieve for all or specific services + */ + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true + try { + const response = await serviceService.list({ sources }) + + // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object + const flattened: Record = {} + Object.entries(response).forEach(([_providerId, providerServices]) => { + Object.entries(providerServices).forEach(([_serviceId, serviceObj]) => { + const key = `${serviceObj.provider}:${serviceObj.identifier}` + flattened[key] = serviceObj + }) + }) + + console.debug('[Mail Manager](Store) - Successfully retrieved', Object.keys(flattened).length, 'services') + + _services.value = flattened + return flattened + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to retrieve services:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Fetch a specific service + */ + async function fetch(provider: string, identifier: string | number): Promise { + transceiving.value = true + try { + return await serviceService.fetch({ provider, identifier }) + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to fetch service:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Discover service configuration + * + * @returns Array of discovered services sorted by provider + */ + async function discover( + identity: string, + secret: string | undefined, + location: string | undefined, + provider: string | undefined, + ): Promise { + transceiving.value = true + + try { + const services = await serviceService.discover({identity, secret, location, provider}) + console.debug('[Mail Manager](Store) - Successfully discovered', services.length, 'services') + return services + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to discover service:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function test( + provider: string, + identifier?: string | number | null, + location?: ServiceLocation | null, + identity?: ServiceIdentity | null, + ): Promise { + transceiving.value = true + try { + const response = await serviceService.test({ provider, identifier, location, identity }) + lastTestResult.value = response + return response + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to test service:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function create(provider: string, data: any) { + transceiving.value = true + try { + const serviceObj = await serviceService.create({ provider, data }) + + // Add to store with composite key + const key = `${serviceObj.provider}:${serviceObj.identifier}` + _services.value[key] = serviceObj + + return serviceObj + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to create service:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function update(provider: string, identifier: string | number, data: any) { + transceiving.value = true + try { + const serviceObj = await serviceService.update({ provider, identifier, data }) + + // Update in store with composite key + const key = `${serviceObj.provider}:${serviceObj.identifier}` + _services.value[key] = serviceObj + + return serviceObj + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to update service:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function remove(provider: string, identifier: string | number) { + transceiving.value = true + try { + await serviceService.delete({ provider, identifier }) + + // Remove from store using composite key + const key = `${provider}:${identifier}` + delete _services.value[key] + } catch (error: any) { + console.error('[Mail Manager](Store) - Failed to delete service:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API + return { + // State (readonly) + transceiving: readonly(transceiving), + lastTestResult: readonly(lastTestResult), + // Getters + count, + has, + services, + servicesByProvider, + + // Actions + list, + fetch, + discover, + test, + create, + update, + delete: remove, + } +}) diff --git a/src/types/collection.ts b/src/types/collection.ts new file mode 100644 index 0000000..a651c32 --- /dev/null +++ b/src/types/collection.ts @@ -0,0 +1,148 @@ +/** + * Collection-related type definitions for Mail Manager + */ +import type { SourceSelector } from './common'; + +/** + * Collection interface (mailbox/folder) + */ +export interface CollectionInterface { + provider: string; + service: string | number; + collection: string | number | null; + identifier: string | number; + signature?: string | null; + created?: string | null; + modified?: string | null; + properties: CollectionPropertiesInterface; +} + +export interface CollectionBaseProperties { + '@type': string; + version: number; +} + +/** + * Immutable collection properties (computed by server) + */ +export interface CollectionImmutableProperties extends CollectionBaseProperties { + total?: number; + unread?: number; + role?: string | null; +} + +/** + * Mutable collection properties (can be modified by user) + */ +export interface CollectionMutableProperties extends CollectionBaseProperties { + label: string; + rank?: number; + subscribed?: boolean; +} + +/** + * Full collection properties (what server returns) + */ +export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {} + +/** + * Collection list request + */ +export interface CollectionListRequest { + sources?: SourceSelector; + filter?: any; + sort?: any; +} + +/** + * Collection list response + */ +export interface CollectionListResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: CollectionInterface; + }; + }; +} + +/** + * Collection extant request + */ +export interface CollectionExtantRequest { + sources: SourceSelector; +} + +/** + * Collection extant response + */ +export interface CollectionExtantResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: boolean; + }; + }; +} + +/** + * Collection fetch request + */ +export interface CollectionFetchRequest { + provider: string; + service: string | number; + collection: string | number; +} + +/** + * Collection fetch response + */ +export interface CollectionFetchResponse extends CollectionInterface {} + +/** + * Collection create request + */ +export interface CollectionCreateRequest { + provider: string; + service: string | number; + collection?: string | number | null; // Parent Collection Identifier + properties: CollectionMutableProperties; +} + +/** + * Collection create response + */ +export interface CollectionCreateResponse extends CollectionInterface {} + +/** + * Collection modify request + */ +export interface CollectionModifyRequest { + provider: string; + service: string | number; + identifier: string | number; + properties: CollectionMutableProperties; +} + +/** + * Collection modify response + */ +export interface CollectionModifyResponse extends CollectionInterface {} + +/** + * Collection destroy request + */ +export interface CollectionDestroyRequest { + provider: string; + service: string | number; + identifier: string | number; + options?: { + force?: boolean; // Whether to force destroy even if collection is not empty + recursive?: boolean; // Whether to destroy child collections/items as well + }; +} + +/** + * Collection destroy response + */ +export interface CollectionDestroyResponse { + success: boolean; +} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..ba8bc15 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,93 @@ +/** + * Common types shared across Mail Manager services + */ + +/** + * Base API request envelope + */ +export interface ApiRequest { + version: number; + transaction: string; + operation: string; + data: T; + user?: string; +} + +/** + * Success response envelope + */ +export interface ApiSuccessResponse { + version: number; + transaction: string; + operation: string; + status: 'success'; + data: T; +} + +/** + * Error response envelope + */ +export interface ApiErrorResponse { + version: number; + transaction: string; + operation: string; + status: 'error'; + data: { + code: number; + message: string; + }; +} + +/** + * Combined response type + */ +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * Source selector structure for hierarchical resource selection + * Structure: Provider -> Service -> Collection -> Message + */ +export type SourceSelector = { + [provider: string]: boolean | ServiceSelector; +}; + +export type ServiceSelector = { + [service: string]: boolean | CollectionSelector; +}; + +export type CollectionSelector = { + [collection: string | number]: boolean | MessageSelector; +}; + +export type MessageSelector = (string | number)[]; + +/** + * Filter condition for building complex queries + */ +export interface FilterCondition { + field: string; + operator: string; + value: any; +} + +/** + * Filter criteria for list operations + */ +export interface ListFilter { + [key: string]: any; +} + +/** + * Sort options for list operations + */ +export interface ListSort { + [key: string]: boolean; +} + +/** + * Range specification for pagination/limiting results + */ +export interface ListRange { + start: number; + limit: number; +} diff --git a/src/types/entity.ts b/src/types/entity.ts new file mode 100644 index 0000000..fe4c41c --- /dev/null +++ b/src/types/entity.ts @@ -0,0 +1,190 @@ +/** + * Entity type definitions for mail + */ +import type { SourceSelector } from './common'; +import type { MessageInterface } from './message'; + +/** + * Entity wrapper with metadata + */ +export interface EntityInterface { + provider: string; + service: string; + collection: string | number; + identifier: string | number; + signature: string | null; + created: string | null; + modified: string | null; + properties: T; +} + +/** + * Entity list request + */ +export interface EntityListRequest { + sources?: SourceSelector; + filter?: any; + sort?: any; + range?: { start: number; limit: number }; +} + +/** + * Entity list response + */ +export interface EntityListResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: { + [identifier: string]: EntityInterface; + }; + }; + }; +} + +/** + * Entity delta request + */ +export interface EntityDeltaRequest { + sources: SourceSelector; +} + +/** + * Entity delta response + */ +export interface EntityDeltaResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: { + signature: string; + created?: EntityInterface[]; + modified?: EntityInterface[]; + deleted?: string[]; + }; + }; + }; +} + +/** + * Entity extant request + */ +export interface EntityExtantRequest { + sources: SourceSelector; +} + +/** + * Entity extant response + */ +export interface EntityExtantResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: { + [messageId: string]: boolean; + }; + }; + }; +} + +/** + * Entity fetch request + */ +export interface EntityFetchRequest { + provider: string; + service: string | number; + collection: string | number; + identifiers: (string | number)[]; + properties?: string[]; +} + +/** + * Entity fetch response + */ +export interface EntityFetchResponse { + entities: EntityInterface[]; +} + +/** + * Entity create request + */ +export interface EntityCreateRequest { + provider: string; + service: string | number; + collection: string | number; + properties: T; +} + +/** + * Entity create response + */ +export interface EntityCreateResponse { + entity: EntityInterface; +} + +/** + * Entity modify request + */ +export interface EntityModifyRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; + properties: T; +} + +/** + * Entity modify response + */ +export interface EntityModifyResponse { + success: boolean; + entity?: EntityInterface; +} + +/** + * Entity destroy request + */ +export interface EntityDestroyRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; +} + +/** + * Entity destroy response + */ +export interface EntityDestroyResponse { + success: boolean; +} + +/** + * Entity send request + */ +export interface EntitySendRequest { + message: { + from?: string; + to: string[]; + cc?: string[]; + bcc?: string[]; + subject?: string; + body?: { + text?: string; + html?: string; + }; + attachments?: Array<{ + filename: string; + contentType: string; + content: string; // base64 encoded + }>; + }; + options?: { + queue?: boolean; + priority?: number; + }; +} + +/** + * Entity send response + */ +export interface EntitySendResponse { + id: string; + status: 'queued' | 'sent'; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..e1e1cae --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,10 @@ +/** + * Central export point for all Mail Manager types + */ + +export type * from './collection'; +export type * from './common'; +export type * from './entity'; +export type * from './integration'; +export type * from './provider'; +export type * from './service'; diff --git a/src/types/integration.ts b/src/types/integration.ts new file mode 100644 index 0000000..8910967 --- /dev/null +++ b/src/types/integration.ts @@ -0,0 +1,58 @@ +/** + * Integration and panel contract type definitions + * Defines standardized interfaces for provider panels + */ + +// ==================== Provider Panel Contracts ==================== + +/** + * Props all provider CONFIG panels receive + * Config panels handle protocol/location settings only + */ +export interface ProviderConfigPanelProps { + /** Pre-filled location from discovery (if available) */ + discoveredLocation?: import('./service').ServiceLocation; + /** Current location value for v-model binding */ + modelValue?: import('./service').ServiceLocation; +} + +/** + * Events all provider CONFIG panels emit + * Config panels emit location configuration and validation state + */ +export interface ProviderConfigPanelEmits { + /** Emit updated location configuration */ + 'update:modelValue': [value: import('./service').ServiceLocation]; + /** Emit validation state (true = valid, false = invalid) */ + 'valid': [value: boolean]; +} + +/** + * Props all provider AUTH panels receive + * Auth panels handle credentials/authentication only + */ +export interface ProviderAuthPanelProps { + /** Email address from discovery entry (for pre-filling username) */ + emailAddress?: string; + /** Discovered or configured location (for context/auth decisions) */ + discoveredLocation?: import('./service').ServiceLocation; + /** Pre-filled identity/username from discovery */ + prefilledIdentity?: string; + /** Pre-filled secret/password if user entered during discovery */ + prefilledSecret?: string; + /** Current identity value for v-model binding */ + modelValue?: import('./service').ServiceIdentity; +} + +/** + * Events all provider AUTH panels emit + * Auth panels emit identity configuration, validation state, and errors + */ +export interface ProviderAuthPanelEmits { + /** Emit updated identity configuration */ + 'update:modelValue': [value: import('./service').ServiceIdentity]; + /** Emit validation state (true = valid, false = invalid) */ + 'valid': [value: boolean]; + /** Emit authentication errors for user feedback */ + 'error': [error: string]; +} diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 0000000..86aad1c --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,68 @@ +/** + * Message Part Interface + */ +export interface MessagePartInterface { + partId?: string | null; + blobId?: string | null; + size?: number | null; + name?: string | null; + type?: string; + charset?: string | null; + disposition?: string | null; + cid?: string | null; + language?: string | null; + location?: string | null; + content?: string; + subParts?: MessagePartInterface[]; +} + +/** + * Message object interface + */ +export interface MessageInterface { + urid?: string; + size?: number; + receivedDate?: string; + date?: string; + subject?: string; + snippet?: string; + from?: { + address: string; + label?: string; + }; + to?: Array<{ + address: string; + label?: string; + }>; + cc?: Array<{ + address: string; + label?: string; + }>; + bcc?: Array<{ + address: string; + label?: string; + }>; + replyTo?: Array<{ + address: string; + label?: string; + }>; + flags?: { + read?: boolean; + flagged?: boolean; + answered?: boolean; + draft?: boolean; + }; + body?: MessagePartInterface; + attachments?: Array<{ + partId?: string; + blobId?: string; + size?: number; + name?: string; + type?: string; + charset?: string | null; + disposition?: string; + cid?: string | null; + language?: string | null; + location?: string | null; + }>; +} \ No newline at end of file diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..d2c5247 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,69 @@ +/** + * Provider type definitions + */ +import type { SourceSelector } from "./common"; + +/** + * Provider capabilities + */ +export interface ProviderCapabilitiesInterface { + ServiceList?: boolean; + ServiceFetch?: boolean; + ServiceExtant?: boolean; + ServiceCreate?: boolean; + ServiceModify?: boolean; + ServiceDestroy?: boolean; + ServiceDiscover?: boolean; + ServiceTest?: boolean; + [key: string]: boolean | object | string[] | undefined; +} + +/** + * Provider information + */ +export interface ProviderInterface { + '@type': string; + identifier: string; + label: string; + capabilities: ProviderCapabilitiesInterface; +} + +/** + * Provider list request + */ +export interface ProviderListRequest { + sources?: SourceSelector; +} + +/** + * Provider list response + */ +export interface ProviderListResponse { + [identifier: string]: ProviderInterface; +} + +/** + * Provider fetch request + */ +export interface ProviderFetchRequest { + identifier: string; +} + +/** + * Provider fetch response + */ +export interface ProviderFetchResponse extends ProviderInterface {} + +/** + * Provider extant request + */ +export interface ProviderExtantRequest { + sources: SourceSelector; +} + +/** + * Provider extant response + */ +export interface ProviderExtantResponse { + [identifier: string]: boolean; +} diff --git a/src/types/service.ts b/src/types/service.ts new file mode 100644 index 0000000..19d192b --- /dev/null +++ b/src/types/service.ts @@ -0,0 +1,344 @@ +/** + * Service-related type definitions + */ +import type { SourceSelector } from './common'; + +/** + * Service capabilities + */ +export interface ServiceCapabilitiesInterface { + // Collection capabilities + CollectionList?: boolean; + CollectionListFilter?: boolean | { [field: string]: string }; + CollectionListSort?: boolean | string[]; + CollectionExtant?: boolean; + CollectionFetch?: boolean; + CollectionCreate?: boolean; + CollectionModify?: boolean; + CollectionDelete?: boolean; + // Message capabilities + EntityList?: boolean; + EntityListFilter?: boolean | { [field: string]: string }; + EntityListSort?: boolean | string[]; + EntityListRange?: boolean | { tally?: string[] }; + EntityDelta?: boolean; + EntityExtant?: boolean; + EntityFetch?: boolean; + EntityCreate?: boolean; + EntityModify?: boolean; + EntityDelete?: boolean; + EntityMove?: boolean; + EntityCopy?: boolean; + // Send capability + EntityTransmit?: boolean; + [key: string]: boolean | object | string[] | undefined; +} + +/** + * Service information + */ +export interface ServiceInterface { + '@type': string; + provider: string; + identifier: string | number | null; + label: string | null; + enabled: boolean; + capabilities?: ServiceCapabilitiesInterface; + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; + primaryAddress?: string | null; + secondaryAddresses?: string[] | null; + auxiliary?: Record; // Provider-specific extension data +} + +/** + * Service list request + */ +export interface ServiceListRequest { + sources?: SourceSelector; +} + +/** + * Service list response + */ +export interface ServiceListResponse { + [provider: string]: { + [identifier: string]: ServiceInterface; + }; +} + +/** + * Service extant request + */ +export interface ServiceExtantRequest { + sources: SourceSelector; +} + +/** + * Service extant response + */ +export interface ServiceExtantResponse { + [provider: string]: { + [identifier: string]: boolean; + }; +} + +/** + * Service fetch request + */ +export interface ServiceFetchRequest { + provider: string; + identifier: string | number; +} + +/** + * Service fetch response + */ +export interface ServiceFetchResponse extends ServiceInterface {} + +/** + * Service find by address request + */ +export interface ServiceFindByAddressRequest { + address: string; +} + +/** + * Service find by address response + */ +export interface ServiceFindByAddressResponse extends ServiceInterface {} + +/** + * Service create request + */ +export interface ServiceCreateRequest { + provider: string; + data: Partial; +} + +/** + * Service create response + */ +export interface ServiceCreateResponse extends ServiceInterface {} + +/** + * Service update request + */ +export interface ServiceUpdateRequest { + provider: string; + identifier: string | number; + data: Partial; +} + +/** + * Service update response + */ +export interface ServiceUpdateResponse extends ServiceInterface {} + +/** + * Service delete request + */ +export interface ServiceDeleteRequest { + provider: string; + identifier: string | number; +} + +/** + * Service delete response + */ +export interface ServiceDeleteResponse {} + +// ==================== Discovery Types ==================== + +/** + * Service discovery request - NEW VERSION + * Supports identity-based discovery with optional hints + */ +export interface ServiceDiscoverRequest { + identity: string; // Email address or domain + provider?: string; // Optional: specific provider ('jmap', 'smtp', etc.) or null for all + location?: string; // Optional: known hostname (bypasses DNS lookup) + secret?: string; // Optional: password/token for credential validation +} + +/** + * Service discovery response - NEW VERSION + * Provider-keyed map of discovered service locations + */ +export interface ServiceDiscoverResponse { + [provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union +} + +/** + * Discovery status tracking for real-time UI updates + * Used by store to track per-provider discovery progress + */ +export interface ProviderDiscoveryStatus { + provider: string; + status: 'pending' | 'discovering' | 'success' | 'failed'; + location?: ServiceLocation; + error?: string; + metadata?: { + host?: string; + port?: number; + protocol?: string; + }; +} + +// ==================== Service Testing Types ==================== + +/** + * Base service location interface + */ +export interface ServiceLocationBase { + type: 'URI' | 'SOCKET_SOLE' | 'SOCKET_SPLIT' | 'FILE'; +} + +/** + * URI-based service location for API and web services + * Used by: JMAP, Gmail API, etc. + */ +export interface ServiceLocationUri extends ServiceLocationBase { + type: 'URI'; + scheme: string; // e.g., 'https', 'http' + host: string; // e.g., 'api.example.com' + port: number; // e.g., 443 + path?: string; // e.g., '/v1/api' + verifyPeer?: boolean; // Verify SSL/TLS peer certificate + verifyHost?: boolean; // Verify SSL/TLS certificate host +} + +/** + * Single socket-based service location + * Used by: services using a single host/port combination + */ +export interface ServiceLocationSocketSole extends ServiceLocationBase { + type: 'SOCKET_SOLE'; + host: string; // e.g., 'mail.example.com' + port: number; // e.g., 993 + encryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Security mode + verifyPeer?: boolean; // Verify SSL/TLS peer certificate + verifyHost?: boolean; // Verify SSL/TLS certificate host +} + +/** + * Split socket-based service location + * Used by: traditional IMAP/SMTP configurations + */ +export interface ServiceLocationSocketSplit extends ServiceLocationBase { + type: 'SOCKET_SPLIT'; + inboundHost: string; // e.g., 'imap.example.com' + inboundPort: number; // e.g., 993 + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Inbound security mode + inboundVerifyPeer?: boolean; // Verify inbound SSL/TLS peer certificate + inboundVerifyHost?: boolean; // Verify inbound SSL/TLS certificate host + outboundHost: string; // e.g., 'smtp.example.com' + outboundPort: number; // e.g., 465 + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Outbound security mode + outboundVerifyPeer?: boolean; // Verify outbound SSL/TLS peer certificate + outboundVerifyHost?: boolean; // Verify outbound SSL/TLS certificate host +} + +/** + * File-based service location + * Used by: local file system providers + */ +export interface ServiceLocationFile extends ServiceLocationBase { + type: 'FILE'; + path: string; // File system path +} + +/** + * Discriminated union of all service location types + */ +export type ServiceLocation = + | ServiceLocationUri + | ServiceLocationSocketSole + | ServiceLocationSocketSplit + | ServiceLocationFile; + +// ==================== Service Identity Types ==================== + +/** + * Base service identity interface + */ +export interface ServiceIdentityBase { + type: 'NA' | 'BA' | 'TA' | 'OA' | 'CC'; +} + +/** + * No authentication + */ +export interface ServiceIdentityNone extends ServiceIdentityBase { + type: 'NA'; +} + +/** + * Basic authentication (username/password) + */ +export interface ServiceIdentityBasic extends ServiceIdentityBase { + type: 'BA'; + identity: string; // Username/email + secret: string; // Password +} + +/** + * Token authentication (API key, static token) + */ +export interface ServiceIdentityToken extends ServiceIdentityBase { + type: 'TA'; + token: string; // Authentication token/API key +} + +/** + * OAuth authentication + */ +export interface ServiceIdentityOAuth extends ServiceIdentityBase { + type: 'OA'; + accessToken: string; // Current access token + accessScope?: string[]; // Token scopes + accessExpiry?: number; // Unix timestamp when token expires + refreshToken?: string; // Refresh token for getting new access tokens + refreshLocation?: string; // Token refresh endpoint URL +} + +/** + * Client certificate authentication (mTLS) + */ +export interface ServiceIdentityCertificate extends ServiceIdentityBase { + type: 'CC'; + certificate: string; // X.509 certificate (PEM format or file path) + privateKey: string; // Private key (PEM format or file path) + passphrase?: string; // Optional passphrase for encrypted private key +} + +/** + * Service identity configuration + * Discriminated union of all identity types + */ +export type ServiceIdentity = + | ServiceIdentityNone + | ServiceIdentityBasic + | ServiceIdentityToken + | ServiceIdentityOAuth + | ServiceIdentityCertificate; + +/** + * Service connection test request + */ +export interface ServiceTestRequest { + provider: string; + // For existing service + identifier?: string | number | null; + // For fresh configuration + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; +} + +/** + * Service connection test response + */ +export interface ServiceTestResponse { + success: boolean; + message: string; +} diff --git a/src/utils/key-generator.ts b/src/utils/key-generator.ts new file mode 100644 index 0000000..8ae3a51 --- /dev/null +++ b/src/utils/key-generator.ts @@ -0,0 +1,31 @@ +/** + * Utility functions for generating unique identifiers + */ + +const globalCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined; + +export const generateUuid = (): string => { + if (globalCrypto?.randomUUID) { + return globalCrypto.randomUUID(); + } + + const template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; + return template.replace(/[xy]/g, char => { + const randomNibble = Math.floor(Math.random() * 16); + const value = char === "x" ? randomNibble : (randomNibble & 0x3) | 0x8; + return value.toString(16); + }); +}; + +export const generateKey = (): string => { + if (globalCrypto?.randomUUID) { + return globalCrypto.randomUUID().replace(/-/g, ""); + } + + if (globalCrypto?.getRandomValues) { + const [value] = globalCrypto.getRandomValues(new Uint32Array(1)); + return value.toString(16); + } + + return Math.random().toString(16).slice(2); +}; diff --git a/src/utils/serviceHelpers.ts b/src/utils/serviceHelpers.ts new file mode 100644 index 0000000..1f70f2a --- /dev/null +++ b/src/utils/serviceHelpers.ts @@ -0,0 +1,276 @@ +/** + * Helper functions for working with service identity and location types + */ + +import type { + ServiceIdentity, + ServiceIdentityNone, + ServiceIdentityBasic, + ServiceIdentityToken, + ServiceIdentityOAuth, + ServiceIdentityCertificate, + ServiceLocation, + ServiceLocationUri, + ServiceLocationSocketSole, + ServiceLocationSocketSplit, + ServiceLocationFile +} from '@/types/service'; + +// ==================== Identity Helpers ==================== + +/** + * Create a "None" identity (no authentication) + */ +export function createIdentityNone(): ServiceIdentityNone { + return { type: 'NA' }; +} + +/** + * Create a Basic Auth identity + */ +export function createIdentityBasic(identity: string, secret: string): ServiceIdentityBasic { + return { + type: 'BA', + identity, + secret + }; +} + +/** + * Create a Token Auth identity + */ +export function createIdentityToken(token: string): ServiceIdentityToken { + return { + type: 'TA', + token + }; +} + +/** + * Create an OAuth identity + */ +export function createIdentityOAuth( + accessToken: string, + options?: { + accessScope?: string[]; + accessExpiry?: number; + refreshToken?: string; + refreshLocation?: string; + } +): ServiceIdentityOAuth { + return { + type: 'OA', + accessToken, + ...options + }; +} + +/** + * Create a Certificate identity + */ +export function createIdentityCertificate( + certificate: string, + privateKey: string, + passphrase?: string +): ServiceIdentityCertificate { + return { + type: 'CC', + certificate, + privateKey, + ...(passphrase && { passphrase }) + }; +} + +// ==================== Location Helpers ==================== + +/** + * Create a URI-based location + */ +export function createLocationUri( + host: string, + options?: { + scheme?: 'http' | 'https'; + port?: number; + path?: string; + verifyPeer?: boolean; + verifyHost?: boolean; + } +): ServiceLocationUri { + return { + type: 'URI', + scheme: options?.scheme || 'https', + host, + port: options?.port || (options?.scheme === 'http' ? 80 : 443), + ...(options?.path && { path: options.path }), + verifyPeer: options?.verifyPeer ?? true, + verifyHost: options?.verifyHost ?? true + }; +} + +/** + * Create a URI location from a full URL string + */ +export function createLocationFromUrl(url: string): ServiceLocationUri { + try { + const parsed = new URL(url); + return { + type: 'URI', + scheme: parsed.protocol.replace(':', '') as 'http' | 'https', + host: parsed.hostname, + port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + verifyPeer: true, + verifyHost: true + }; + } catch (error) { + throw new Error(`Invalid URL: ${url}`); + } +} + +/** + * Create a single socket location (IMAP, SMTP on same server) + */ +export function createLocationSocketSole( + host: string, + port: number, + encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + options?: { + verifyPeer?: boolean; + verifyHost?: boolean; + } +): ServiceLocationSocketSole { + return { + type: 'SOCKET_SOLE', + host, + port, + encryption, + verifyPeer: options?.verifyPeer ?? true, + verifyHost: options?.verifyHost ?? true + }; +} + +/** + * Create a split socket location (separate IMAP/SMTP servers) + */ +export function createLocationSocketSplit( + config: { + inboundHost: string; + inboundPort: number; + inboundEncryption?: 'none' | 'ssl' | 'tls' | 'starttls'; + outboundHost: string; + outboundPort: number; + outboundEncryption?: 'none' | 'ssl' | 'tls' | 'starttls'; + inboundVerifyPeer?: boolean; + inboundVerifyHost?: boolean; + outboundVerifyPeer?: boolean; + outboundVerifyHost?: boolean; + } +): ServiceLocationSocketSplit { + return { + type: 'SOCKET_SPLIT', + inboundHost: config.inboundHost, + inboundPort: config.inboundPort, + inboundEncryption: config.inboundEncryption || 'ssl', + outboundHost: config.outboundHost, + outboundPort: config.outboundPort, + outboundEncryption: config.outboundEncryption || 'ssl', + inboundVerifyPeer: config.inboundVerifyPeer ?? true, + inboundVerifyHost: config.inboundVerifyHost ?? true, + outboundVerifyPeer: config.outboundVerifyPeer ?? true, + outboundVerifyHost: config.outboundVerifyHost ?? true + }; +} + +/** + * Create a file-based location + */ +export function createLocationFile(path: string): ServiceLocationFile { + return { + type: 'FILE', + path + }; +} + +// ==================== Validation Helpers ==================== + +/** + * Validate that an identity object is properly formed + */ +export function validateIdentity(identity: ServiceIdentity): boolean { + switch (identity.type) { + case 'NA': + return true; + case 'BA': + return !!(identity as ServiceIdentityBasic).identity && + !!(identity as ServiceIdentityBasic).secret; + case 'TA': + return !!(identity as ServiceIdentityToken).token; + case 'OA': + return !!(identity as ServiceIdentityOAuth).accessToken; + case 'CC': + return !!(identity as ServiceIdentityCertificate).certificate && + !!(identity as ServiceIdentityCertificate).privateKey; + default: + return false; + } +} + +/** + * Validate that a location object is properly formed + */ +export function validateLocation(location: ServiceLocation): boolean { + switch (location.type) { + case 'URI': + return !!(location as ServiceLocationUri).host && + !!(location as ServiceLocationUri).port; + case 'SOCKET_SOLE': + return !!(location as ServiceLocationSocketSole).host && + !!(location as ServiceLocationSocketSole).port; + case 'SOCKET_SPLIT': + const split = location as ServiceLocationSocketSplit; + return !!split.inboundHost && !!split.inboundPort && + !!split.outboundHost && !!split.outboundPort; + case 'FILE': + return !!(location as ServiceLocationFile).path; + default: + return false; + } +} + +// ==================== Type Guards ==================== + +export function isIdentityNone(identity: ServiceIdentity): identity is ServiceIdentityNone { + return identity.type === 'NA'; +} + +export function isIdentityBasic(identity: ServiceIdentity): identity is ServiceIdentityBasic { + return identity.type === 'BA'; +} + +export function isIdentityToken(identity: ServiceIdentity): identity is ServiceIdentityToken { + return identity.type === 'TA'; +} + +export function isIdentityOAuth(identity: ServiceIdentity): identity is ServiceIdentityOAuth { + return identity.type === 'OA'; +} + +export function isIdentityCertificate(identity: ServiceIdentity): identity is ServiceIdentityCertificate { + return identity.type === 'CC'; +} + +export function isLocationUri(location: ServiceLocation): location is ServiceLocationUri { + return location.type === 'URI'; +} + +export function isLocationSocketSole(location: ServiceLocation): location is ServiceLocationSocketSole { + return location.type === 'SOCKET_SOLE'; +} + +export function isLocationSocketSplit(location: ServiceLocation): location is ServiceLocationSocketSplit { + return location.type === 'SOCKET_SPLIT'; +} + +export function isLocationFile(location: ServiceLocation): location is ServiceLocationFile { + return location.type === 'FILE'; +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..b513bb1 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@/*": ["./src/*"], + "@KTXC/*": ["../../core/src/*"], + "@MailManager/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..fc3f3c8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + { + name: 'inject-css-filename', + enforce: 'post', + generateBundle(_options, bundle) { + const cssFile = Object.keys(bundle).find(name => name.endsWith('.css')) + if (!cssFile) return + + for (const fileName of Object.keys(bundle)) { + const chunk = bundle[fileName] + if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) { + chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`) + console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`) + } + } + } + } + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@KTXC': path.resolve(__dirname, '../../core/src'), + '@MailManager': path.resolve(__dirname, './src') + }, + }, + build: { + outDir: 'static', + emptyOutDir: true, + sourcemap: true, + lib: { + entry: path.resolve(__dirname, 'src/main.ts'), + formats: ['es'], + fileName: () => 'module.mjs', + }, + rollupOptions: { + external: [ + 'pinia', + 'vue', + 'vue-router', + // Externalize shared utilities from core to avoid duplication + /^@KTXC\/utils\//, + ], + output: { + assetFileNames: (assetInfo) => { + if (assetInfo.name?.endsWith('.css')) { + return 'mail_manager-[hash].css' + } + return '[name]-[hash][extname]' + } + } + }, + }, +})