Compare commits

..

1 Commits

Author SHA1 Message Date
ec2bc0620a chore: implemement basic tests
Some checks failed
Build Test / test (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Failing after 51s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 22:21:49 -05:00
26 changed files with 914 additions and 2843 deletions

View File

@@ -18,9 +18,6 @@
"require": { "require": {
"php": ">=8.2 <=8.5" "php": ">=8.2 <=8.5"
}, },
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"KTXM\\MailManager\\": "lib/" "KTXM\\MailManager\\": "lib/"
@@ -28,7 +25,7 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"KTXT\\MailManager\\Tests\\": "tests/php/" "KTXT\\ProviderJmapc\\": "tests/php/"
} }
}, },
"scripts": { "scripts": {

1786
composer.lock generated

File diff suppressed because it is too large Load Diff

428
docs/interfaces.md Normal file
View File

@@ -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 {
<<extends ProviderInterface>>
}
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 {
<<extends ServiceInterface>>
}
class ServiceExtantRequest {
+SourceSelector sources
}
class ServiceExtantResponse {
+boolean[identifier] exists
}
class ServiceCreateRequest {
+string provider
+string label
+object configuration
}
class ServiceCreateResponse {
<<extends ServiceInterface>>
}
class ServiceModifyRequest {
+string provider
+string identifier
+string label
+object configuration
}
class ServiceModifyResponse {
<<extends ServiceInterface>>
}
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 {
<<extends CollectionInterface>>
}
%% 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 {
<<extends MessageInterface>>
}
%% 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

View File

@@ -11,17 +11,11 @@ namespace KTXM\MailManager\Controllers;
use InvalidArgumentException; use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedNdJsonResponse;
use KTXC\SessionIdentity; use KTXC\SessionIdentity;
use KTXC\SessionTenant; use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Json\JsonSerializable; use KTXF\Mail\Entity\Message;
use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Mail\Queue\SendOptions;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Selector\SourceSelector; use KTXF\Resource\Selector\SourceSelector;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\MailManager\Manager; use KTXM\MailManager\Manager;
@@ -36,25 +30,23 @@ use Throwable;
*/ */
class DefaultController extends ControllerAbstract { class DefaultController extends ControllerAbstract {
// Error message constants
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier';
private const ERR_MISSING_SERVICE = 'Missing parameter: service'; private const ERR_MISSING_SERVICE = 'Missing parameter: service';
private const ERR_MISSING_COLLECTION = 'Missing parameter: collection'; private const ERR_MISSING_COLLECTION = 'Missing parameter: collection';
private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_DATA = 'Missing parameter: data';
private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources';
private const ERR_MISSING_TARGET = 'Missing parameter: target';
private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers';
private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_MISSING_MESSAGE = 'Missing parameter: message';
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; 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_SERVICE = 'Invalid parameter: service must be a string';
private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier 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_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_SOURCES = 'Invalid parameter: sources must be an array';
private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array';
private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers 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_DATA = 'Invalid parameter: data must be an array';
private const ERR_INVALID_MESSAGE = 'Invalid parameter: message must be an array';
private const STREAM_FLUSH_INTERVAL = 1;
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly SessionTenant $tenantIdentity,
@@ -67,14 +59,15 @@ class DefaultController extends ControllerAbstract {
* Main API endpoint for mail operations * Main API endpoint for mail operations
* *
* Single operation: * Single operation:
* { * { "version": 1, "transaction": "tx-1", "operation": "message.send", "data": {...} }
* "version": 1,
* "transaction": "tx-1",
* "operation": "entity.create",
* "data": {...}
* }
* *
* @return Response * 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'])] #[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])]
public function index( public function index(
@@ -82,22 +75,18 @@ class DefaultController extends ControllerAbstract {
string $transaction, string $transaction,
string|null $operation = null, string|null $operation = null,
array|null $data = null, array|null $data = null,
array|null $operations = null,
string|null $user = null string|null $user = null
): Response { ): JsonResponse {
// authorize request // authorize request
$tenantId = $this->tenantIdentity->identifier(); $tenantId = $this->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier(); $userId = $this->userIdentity->identifier();
try { try {
// Single operation mode
if ($operation !== null) { if ($operation !== null) {
$result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction); $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []);
if ($result instanceof Response) {
return $result;
}
return new JsonResponse([ return new JsonResponse([
'version' => $version, 'version' => $version,
'transaction' => $transaction, 'transaction' => $transaction,
@@ -107,10 +96,21 @@ class DefaultController extends ControllerAbstract {
], JsonResponse::HTTP_OK); ], JsonResponse::HTTP_OK);
} }
throw new InvalidArgumentException('Operation must be provided'); // 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) { } catch (Throwable $t) {
$this->logger->error('Error processing request', ['exception' => $t]); $this->logger->error('Error processing mail manager request', ['exception' => $t]);
return new JsonResponse([ return new JsonResponse([
'version' => $version, 'version' => $version,
'transaction' => $transaction, 'transaction' => $transaction,
@@ -124,10 +124,105 @@ class DefaultController extends ControllerAbstract {
} }
} }
/**
* 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 * Process a single operation
*/ */
private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed { private function processOperation(string $tenantId, string $userId, string $operation, array $data, array $resultMap): mixed {
return match ($operation) { return match ($operation) {
// Provider operations // Provider operations
'provider.list' => $this->providerList($tenantId, $userId, $data), 'provider.list' => $this->providerList($tenantId, $userId, $data),
@@ -141,7 +236,7 @@ class DefaultController extends ControllerAbstract {
'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.create' => $this->serviceCreate($tenantId, $userId, $data),
'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data),
'service.delete' => $this->serviceDelete($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data),
'service.discover' => $this->serviceDiscover($tenantId, $userId, $data, $version, $transaction), 'service.discover' => $this->serviceDiscover($tenantId, $userId, $data),
'service.test' => $this->serviceTest($tenantId, $userId, $data), 'service.test' => $this->serviceTest($tenantId, $userId, $data),
// Collection operations // Collection operations
@@ -161,13 +256,12 @@ class DefaultController extends ControllerAbstract {
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.update' => 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.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction),
'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) default => throw new InvalidArgumentException('Unknown operation: ' . $operation)
}; };
} }
@@ -185,18 +279,6 @@ class DefaultController extends ControllerAbstract {
} }
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
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->providerFetch($tenantId, $userId, $data['identifier']);
}
private function providerExtant(string $tenantId, string $userId, array $data): mixed { private function providerExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) { if (!isset($data['sources'])) {
@@ -212,6 +294,18 @@ class DefaultController extends ControllerAbstract {
} }
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
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->providerFetch($tenantId, $userId, $data['identifier']);
}
// ==================== Service Operations ===================== // ==================== Service Operations =====================
private function serviceList(string $tenantId, string $userId, array $data): mixed { private function serviceList(string $tenantId, string $userId, array $data): mixed {
@@ -226,6 +320,20 @@ class DefaultController extends ControllerAbstract {
} }
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 { private function serviceFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) { if (!isset($data['provider'])) {
@@ -244,18 +352,41 @@ class DefaultController extends ControllerAbstract {
return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
} }
private function serviceExtant(string $tenantId, string $userId, array $data): mixed { private function serviceDiscover(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) { if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); throw new InvalidArgumentException(self::ERR_INVALID_DATA);
} }
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); $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 { private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
@@ -331,73 +462,6 @@ class DefaultController extends ControllerAbstract {
); );
} }
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 serviceDiscover(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
$provider = $data['provider'] ?? null;
$identity = $data['identity'];
$location = $data['location'] ?? null;
$secret = $data['secret'] ?? null;
$discoverGenerator = $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
$logger = $this->logger;
$response = (function () use ($discoverGenerator, $version, $transaction, $logger): \Generator {
yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
$total = 0;
try {
foreach ($discoverGenerator as $providerId => $serviceLocation) {
if (!$serviceLocation instanceof ResourceServiceLocationInterface) {
continue;
}
yield [
'type' => 'data',
'data' => [
'provider' => $providerId,
'location' => $serviceLocation->jsonSerialize()
]
];
$total++;
}
} catch (\Throwable $t) {
$logger->error('Error streaming service discovery', ['exception' => $t]);
yield ['type' => 'error', 'message' => $t->getMessage()];
return;
}
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
})();
return new StreamedNdJsonResponse($response, 1, 200, ['Content-Type' => 'application/json']);
}
// ==================== Collection Operations ==================== // ==================== Collection Operations ====================
private function collectionList(string $tenantId, string $userId, array $data): mixed { private function collectionList(string $tenantId, string $userId, array $data): mixed {
@@ -576,6 +640,34 @@ class DefaultController extends ControllerAbstract {
} }
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 { private function entityFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) { if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
@@ -612,63 +704,6 @@ class DefaultController extends ControllerAbstract {
); );
} }
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 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 entityMove(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$target = ResourceIdentifier::fromString($data['target']);
if (!$target instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
return $this->mailManager->entityMove($tenantId, $userId, $target, $sources);
}
private function entityTransmit(string $tenantId, string $userId, array $data): mixed { private function entityTransmit(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) { if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
@@ -694,49 +729,4 @@ class DefaultController extends ControllerAbstract {
return ['jobId' => $jobId]; return ['jobId' => $jobId];
} }
private function entityStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
$range = $data['range'] ?? null;
$entityGenerator = $this->mailManager->entityStream($tenantId, $userId, $sources, $filter, $sort, $range);
$logger = $this->logger;
$responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator {
yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
$total = 0;
try {
foreach ($entityGenerator as $entity) {
if (!$entity instanceof JsonSerializable) {
continue;
}
yield [
'type' => 'data',
'data' => $entity->jsonSerialize()
];
$total++;
}
} catch (\Throwable $t) {
$logger->error('Error streaming entities', ['exception' => $t]);
yield ['type' => 'error', 'message' => $t->getMessage()];
return;
}
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
})();
return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']);
}
} }

View File

@@ -21,12 +21,7 @@ use KTXF\Mail\Queue\SendOptions;
use KTXF\Mail\Service\IServiceSend; use KTXF\Mail\Service\IServiceSend;
use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\IRange;
@@ -43,7 +38,8 @@ use Psr\Log\LoggerInterface;
/** /**
* Mail Manager * Mail Manager
* *
* Provides unified mail sending across multiple providers * Provides unified mail sending across multiple providers with context-aware
* service discovery and queued delivery support.
*/ */
class Manager { class Manager {
@@ -67,25 +63,6 @@ class Manager {
return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter); return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter);
} }
/**
* 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];
}
/** /**
* Confirm which providers are available * Confirm which providers are available
* *
@@ -106,6 +83,25 @@ class Manager {
return $responseData; 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 * Retrieve available services for specific user
* *
@@ -128,27 +124,6 @@ class Manager {
return $responseData; 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;
}
/** /**
* Confirm which services are available * Confirm which services are available
* *
@@ -176,6 +151,27 @@ class Manager {
return $responseData; 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 * Find a service that handles a specific email address
* *
@@ -308,7 +304,7 @@ class Manager {
} }
/** /**
* Discover mail service settings from identity, yielding results as each provider completes * Discover mail service settings from identity
* *
* @since 2025.05.01 * @since 2025.05.01
* *
@@ -319,7 +315,12 @@ class Manager {
* @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup) * @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup)
* @param string|null $secret Optional password/token to validate discovered service * @param string|null $secret Optional password/token to validate discovered service
* *
* @return \Generator Yields providerId => ResourceServiceLocationInterface pairs as each provider completes * @return array<string,ResourceServiceLocationInterface> Array of discovered service locations keyed by provider ID
* [
* 'jmap' => ResourceServiceLocationInterface,
* 'smtp' => ResourceServiceLocationInterface,
* // Only providers that successfully discovered (non-null)
* ]
*/ */
public function serviceDiscover( public function serviceDiscover(
string $tenantId, string $tenantId,
@@ -328,28 +329,32 @@ class Manager {
string $identity, string $identity,
string|null $location = null, string|null $location = null,
string|null $secret = null string|null $secret = null
): \Generator { ): array {
$locations = [];
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null); $providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null);
foreach ($providers as $currentProviderId => $provider) { foreach ($providers as $providerId => $provider) {
if (!($provider instanceof ProviderServiceDiscoverInterface)) { if (!($provider instanceof ProviderServiceDiscoverInterface)) {
continue; continue;
} }
try { try {
$result = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret); $location = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret);
if ($result !== null) { if ($location !== null) {
yield $currentProviderId => $result; $locations[$providerId] = $location;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->warning('Provider autodiscovery failed', [ $this->logger->warning('Provider autodiscovery failed', [
'provider' => $currentProviderId, 'provider' => $providerId,
'identity' => $identity, 'identity' => $identity,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $locations;
} }
/** /**
@@ -761,101 +766,52 @@ class Manager {
} }
/** /**
* Stream entities * Get message delta/changes
*
* @since 2026.02.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 \Generator<EntityBaseInterface> Yields each entity as it is retrieved
*/
public function entityStream(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator {
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
// retrieve services for each provider
foreach ($providers as $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']));
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']);
}
}
}
// yield entities for each collection individually
foreach ($collectionSelected as $collectionId) {
yield from $service->entityListStream($collectionId, $entityFilter, $entitySort, $entityRange, null);
}
}
}
}
/**
* Fetch specific messages
* *
* @since 2025.05.01 * @since 2025.05.01
* *
* @param string $tenantId Tenant identifier * @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context * @param string|null $userId User identifier for context
* @param string $providerId Provider identifier * @param SourceSelector $sources Message sources with signatures
* @param string|int $serviceId Service identifier
* @param string|int $collectionId Collection identifier
* @param array<string|int> $identifiers Message identifiers
* *
* @return array<string|int, IMessageBase> Messages indexed by ID * @return array<string, array<string|int, array<string|int, array>>> Delta grouped by provider/service/collection
*/ */
public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array {
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); // confirm that sources are provided
if ($sources === null) {
// retrieve collection $sources = new SourceSelector([]);
return $service->entityFetch($collectionId, ...$identifiers); }
// 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;
} }
/** /**
@@ -933,102 +889,24 @@ class Manager {
} }
/** /**
* Get message delta/changes * Fetch specific messages
* *
* @since 2025.05.01 * @since 2025.05.01
* *
* @param string $tenantId Tenant identifier * @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context * @param string|null $userId User identifier for context
* @param SourceSelector $sources Message sources with signatures * @param string $providerId Provider identifier
* @param string|int $serviceId Service identifier
* @param string|int $collectionId Collection identifier
* @param array<string|int> $identifiers Message identifiers
* *
* @return array<string, array<string|int, array<string|int, array>>> Delta grouped by provider/service/collection * @return array<string|int, IMessageBase> Messages indexed by ID
*/ */
public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array {
// confirm that sources are provided $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
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;
}
public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array { // retrieve collection
return $service->entityFetch($collectionId, ...$identifiers);
$targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// Check if service supports entity move
if ($targetService instanceof ServiceEntityMutableInterface === false) {
//return [];
}
$operationOutcome = [];
$destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier());
if (!$destinationSources->isEmpty()) {
$entitiesToMove = [];
foreach ($destinationSources as $identifier) {
$entitiesToMove[$identifier->collection()][] = $identifier->entity();
}
$operationResult = $targetService->entityMove($target->collection(), $entitiesToMove);
foreach ($destinationSources as $identifier) {
$sourceIdentifier = (string)$identifier;
$entityIdentifier = $identifier->entity();
$result = $operationResult[$entityIdentifier] ?? null;
if ($result === true) {
$operationOutcome[$sourceIdentifier] = [
'success' => true,
'identifier' => (string)new EntityIdentifier(
$target->provider(),
$target->service(),
$target->collection(),
$entityIdentifier,
),
];
continue;
}
$operationOutcome[$sourceIdentifier] = [
'success' => false,
'error' => is_string($result) && $result !== '' ? $result : 'unknownError',
];
}
}
// TODO: Handle moving entities across different services/providers by fetching each entity and re-creating it in the target collection,
// then deleting the original if the move is successful. This will require additional logic to handle potential failures and ensure data integrity.
return $operationOutcome;
} }
/** /**

View File

@@ -198,24 +198,23 @@ async function handleDiscover() {
discoveryStatus.value[identifier].status = 'discovering' discoveryStatus.value[identifier].status = 'discovering'
try { try {
let discoveredService: any = undefined const services = await servicesStore.discover(
await servicesStore.discover(
discoverAddress.value, discoverAddress.value,
discoverSecret.value || undefined, discoverSecret.value || undefined,
discoverHostname.value || undefined, discoverHostname.value || undefined,
identifier, identifier
(service) => { discoveredService = service }
) )
// Success - check if we got results for this provider // Success - check if we got results for this provider
if (discoveredService && discoveredService.location) { const service = services.find(s => s.provider === identifier)
if (service && service.location) {
discoveryStatus.value[identifier] = { discoveryStatus.value[identifier] = {
provider: identifier, provider: identifier,
status: 'success', status: 'success',
location: discoveredService.location, location: service.location,
metadata: extractLocationMetadata(discoveredService.location) metadata: extractLocationMetadata(service.location)
} }
discoveredServices.value.push(discoveredService) discoveredServices.value.push(service)
} else { } else {
// No configuration found for this provider // No configuration found for this provider
discoveryStatus.value[identifier].status = 'failed' discoveryStatus.value[identifier].status = 'failed'

View File

@@ -5,11 +5,10 @@
*/ */
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import type { Ref } from 'vue';
import { useEntitiesStore } from '../stores/entitiesStore'; import { useEntitiesStore } from '../stores/entitiesStore';
import { useCollectionsStore } from '../stores/collectionsStore'; import { useCollectionsStore } from '../stores/collectionsStore';
export interface SyncSource { interface SyncSource {
provider: string; provider: string;
service: string | number; service: string | number;
collections: (string | number)[]; collections: (string | number)[];
@@ -24,21 +23,7 @@ interface SyncOptions {
fetchDetails?: boolean; fetchDetails?: boolean;
} }
export interface MailSyncController { export function useMailSync(options: SyncOptions = {}) {
isRunning: Ref<boolean>;
lastSync: Ref<Date | null>;
error: Ref<string | null>;
sources: Ref<SyncSource[]>;
addSource: (source: SyncSource) => void;
removeSource: (source: SyncSource) => void;
clearSources: () => void;
sync: () => Promise<void>;
start: () => void;
stop: () => void;
restart: () => void;
}
export function useMailSync(options: SyncOptions = {}): MailSyncController {
const { const {
interval = 30000, interval = 30000,
autoStart = true, autoStart = true,

View File

@@ -1,13 +1,21 @@
/** import type { App as Vue } from 'vue'
* Mail Manager Module Boot
*/
import routes from '@/routes' import routes from '@/routes'
import integrations from '@/integrations' import integrations from '@/integrations'
import { useCollectionsStore } from '@/stores/collectionsStore'
import { useEntitiesStore } from '@/stores/entitiesStore'
import { useProvidersStore } from '@/stores/providersStore'
import { useServicesStore } from '@/stores/servicesStore'
console.log('[Mail Manager] Booting module...') /**
* 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('[Mail Manager] Module booted successfully...') console.log('[MailManager] Booting Mail Manager module...')
console.log('[MailManager] Mail Manager module booted successfully')
// CSS will be injected by build process // CSS will be injected by build process
export const css = ['__CSS_FILENAME_PLACEHOLDER__'] export const css = ['__CSS_FILENAME_PLACEHOLDER__']
@@ -15,14 +23,12 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// Export routes and integrations for module system // Export routes and integrations for module system
export { routes, integrations } export { routes, integrations }
// Export services, stores and models for external use // Export stores for external use if needed
export * from '@/services' export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore }
export * from '@/stores'
export * from '@/models'
// Export composables for external use // Default export for Vue plugin installation
export { useMailSync } from '@/composables/useMailSync' export default {
install(app: Vue) {
// Export components for external use // Module initialization if needed
export { default as AddAccountDialog } from '@/components/AddAccountDialog.vue' }
export { default as EditAccountDialog } from '@/components/EditAccountDialog.vue' }

View File

@@ -17,7 +17,16 @@ export class CollectionObject implements CollectionInterface {
signature: null, signature: null,
created: null, created: null,
modified: null, modified: null,
properties: new CollectionPropertiesObject(), properties: {
'@type': 'mail.collection',
version: 1,
total: 0,
unread: 0,
label: '',
role: null,
rank: 0,
subscribed: true,
},
}; };
} }
@@ -25,6 +34,8 @@ export class CollectionObject implements CollectionInterface {
this._data = data; this._data = data;
if (data.properties) { if (data.properties) {
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
} else {
this._data.properties = new CollectionPropertiesObject();
} }
return this; return this;
} }
@@ -103,12 +114,12 @@ export class CollectionPropertiesObject implements CollectionPropertiesInterface
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:collection', '@type': 'mail.collection',
version: 1, version: 1,
total: 0, total: 0,
unread: 0, unread: 0,
role: null,
label: '', label: '',
role: null,
rank: 0, rank: 0,
subscribed: true, subscribed: true,
}; };

View File

@@ -1,14 +1,13 @@
/**
* Central export point for all Mail Manager models
*/
export { CollectionObject } from './collection';
export { EntityObject } from './entity';
export { ProviderObject } from './provider'; export { ProviderObject } from './provider';
export { ServiceObject } from './service'; export { ServiceObject } from './service';
export {
CollectionObject, // Identity models
CollectionPropertiesObject
} from './collection';
export { EntityObject } from './entity';
export {
MessageObject,
MessagePartObject
} from './message';
export { export {
Identity, Identity,
IdentityNone, IdentityNone,
@@ -17,6 +16,8 @@ export {
IdentityOAuth, IdentityOAuth,
IdentityCertificate IdentityCertificate
} from './identity'; } from './identity';
// Location models
export { export {
Location, Location,
LocationUri, LocationUri,

View File

@@ -13,7 +13,7 @@ export class ProviderObject implements ProviderInterface {
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:provider', '@type': 'mail.provider',
identifier: '', identifier: '',
label: '', label: '',
capabilities: {}, capabilities: {},

View File

@@ -2,7 +2,7 @@
* Entity management service * Entity management service
*/ */
import { transceivePost, transceiveStream } from './transceive'; import { transceivePost } from './transceive';
import type { import type {
EntityListRequest, EntityListRequest,
EntityListResponse, EntityListResponse,
@@ -18,13 +18,9 @@ import type {
EntityDeleteResponse, EntityDeleteResponse,
EntityDeltaRequest, EntityDeltaRequest,
EntityDeltaResponse, EntityDeltaResponse,
EntityMoveRequest,
EntityMoveResponse,
EntityTransmitRequest, EntityTransmitRequest,
EntityTransmitResponse, EntityTransmitResponse,
EntityInterface, EntityInterface,
EntityStreamRequest,
EntityStreamResponse,
} from '../types/entity'; } from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models'; import { EntityObject } from '../models';
@@ -151,17 +147,6 @@ export const entityService = {
return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('entity.delta', request); return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('entity.delta', request);
}, },
/**
* Move entities to a target collection
*
* @param request - move request parameters
*
* @returns Promise with move results keyed by source entity identifier
*/
async move(request: EntityMoveRequest): Promise<EntityMoveResponse> {
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
},
/** /**
* Send an entity * Send an entity
* *
@@ -172,30 +157,6 @@ export const entityService = {
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> { async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request); return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
}, },
/**
* Stream entities as NDJSON, invoking onEntity for each entity as it arrives.
*
* The server emits one entity per line so the caller receives entities
* progressively rather than waiting for the full collection to load.
*
* @param request - stream request parameters (same shape as list)
* @param onEntity - called synchronously for each entity as it is received
*
* @returns Promise resolving to { total } when the stream completes
*/
async stream(
request: EntityStreamRequest,
onEntity: (entity: EntityObject) => void
): Promise<{ total: number }> {
return await transceiveStream<EntityStreamRequest, EntityStreamResponse>(
'entity.stream',
request,
(entity) => {
onEntity(createEntityObject(entity));
}
);
},
}; };
export default entityService; export default entityService;

View File

@@ -5,24 +5,24 @@
import type { import type {
ServiceListRequest, ServiceListRequest,
ServiceListResponse, ServiceListResponse,
ServiceFetchRequest,
ServiceFetchResponse,
ServiceExtantRequest, ServiceExtantRequest,
ServiceExtantResponse, ServiceExtantResponse,
ServiceFetchRequest,
ServiceFetchResponse,
ServiceDiscoverRequest,
ServiceDiscoverResponse,
ServiceTestRequest,
ServiceTestResponse,
ServiceInterface,
ServiceCreateResponse, ServiceCreateResponse,
ServiceCreateRequest, ServiceCreateRequest,
ServiceUpdateResponse, ServiceUpdateResponse,
ServiceUpdateRequest, ServiceUpdateRequest,
ServiceDeleteResponse, ServiceDeleteResponse,
ServiceDeleteRequest, ServiceDeleteRequest,
ServiceDiscoverRequest,
ServiceTestRequest,
ServiceTestResponse,
ServiceInterface,
ServiceDiscoverResponse,
} from '../types/service'; } from '../types/service';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { transceivePost, transceiveStream } from './transceive'; import { transceivePost } from './transceive';
import { ServiceObject } from '../models/service'; import { ServiceObject } from '../models/service';
/** /**
@@ -87,32 +87,31 @@ export const serviceService = {
}, },
/** /**
* Discover services, streaming results as each provider responds * Retrieve discoverable services for a given source selector, sorted by provider
* *
* @param request - discover request parameters * @param request - discover request parameters
* @param onService - called for each discovered service as it arrives
* *
* @returns Promise resolving to { total } when the stream completes * @returns Promise with array of discovered services sorted by provider
*/ */
async discover( async discover(request: ServiceDiscoverRequest): Promise<ServiceObject[]> {
request: ServiceDiscoverRequest, const response = await transceivePost<ServiceDiscoverRequest, ServiceDiscoverResponse>('service.discover', request);
onService: (service: ServiceObject) => void
): Promise<{ total: number }> { // Convert discovery results to ServiceObjects
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>( const services: ServiceObject[] = [];
'service.discover', Object.entries(response).forEach(([providerId, location]) => {
request,
(service) => {
const serviceData: ServiceInterface = { const serviceData: ServiceInterface = {
'@type': 'mail:service', '@type': 'mail:service',
provider: service.provider, provider: providerId,
identifier: null, identifier: null,
label: null, label: null,
enabled: false, enabled: false,
location: service.location, location: location,
}; };
onService(createServiceObject(serviceData)); services.push(createServiceObject(serviceData));
} });
);
// Sort by provider
return services.sort((a, b) => a.provider.localeCompare(b.provider));
}, },
/** /**

View File

@@ -3,8 +3,8 @@
* Provides a centralized way to make API calls with envelope wrapping/unwrapping * Provides a centralized way to make API calls with envelope wrapping/unwrapping
*/ */
import { createFetchWrapper } from '@KTXC'; import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core';
import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common'; import type { ApiRequest, ApiResponse } from '../types/common';
const fetchWrapper = createFetchWrapper(); const fetchWrapper = createFetchWrapper();
const API_URL = '/m/mail_manager/v1'; const API_URL = '/m/mail_manager/v1';
@@ -48,96 +48,3 @@ export async function transceivePost<TRequest, TResponse>(
return response.data; return response.data;
} }
/**
* Stream an NDJSON API response, unwrapping data frames for the caller.
*
* The server emits one JSON object per line with a transport-level `type`
* discriminant. This helper consumes control and error frames, forwards only
* unwrapped `data` payloads to the caller, and returns the final stream total.
*
* @param operation - Operation name, e.g. 'entity.stream'
* @param data - Operation-specific request data
* @param onData - Synchronous callback invoked for every unwrapped data payload.
* May throw to abort the stream.
* @param user - Optional user identifier override
* @returns Promise resolving to the final stream total from the control/end frame
*/
export async function transceiveStream<TRequest, TData>(
operation: string,
data: TRequest,
onData: (data: TData) => void,
user?: string
): Promise<{ total: number }> {
const request: ApiRequest<TRequest> = {
version: API_VERSION,
transaction: generateTransactionId(),
operation,
data,
user,
};
let total = 0;
await fetchWrapper.post(API_URL, request, {
//headers: { 'Accept': 'application/x-ndjson' },
headers: { 'Accept': 'application/json' },
onStream: async (response: Response) => {
if (!response.body) {
throw new Error(`[${operation}] Response body is not readable`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!; // retain any incomplete trailing chunk
for (const line of lines) {
if (!line.trim()) continue;
const message = JSON.parse(line) as ApiStreamResponse<TData>;
if (message.type === 'control') {
if (message.status === 'end') {
total = message.total;
}
continue;
}
if (message.type === 'error') {
throw new Error(`[${operation}] ${message.message}`);
}
onData(message.data);
}
}
// flush any remaining bytes still in the buffer
if (buffer.trim()) {
const message = JSON.parse(buffer) as ApiStreamResponse<TData>;
if (message.type === 'control') {
if (message.status === 'end') {
total = message.total;
}
} else if (message.type === 'error') {
throw new Error(`[${operation}] ${message.message}`);
} else {
onData(message.data);
}
}
} finally {
reader.releaseLock();
}
},
});
return { total };
}

View File

@@ -9,13 +9,8 @@ import { CollectionObject, CollectionPropertiesObject } from '../models/collecti
import type { SourceSelector, ListFilter, ListSort } from '../types' import type { SourceSelector, ListFilter, ListSort } from '../types'
export const useCollectionsStore = defineStore('mailCollectionsStore', () => { export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
const ROOT_IDENTIFIER = '__root__'
const SERVICE_INDEX_IDENTIFIER = '__service__'
// State // State
const _collections = ref<Record<string, CollectionObject>>({}) const _collections = ref<Record<string, CollectionObject>>({})
const _collectionsByServiceIndex = ref<Record<string, string[]>>({})
const _collectionsByParentIndex = ref<Record<string, string[]>>({})
const transceiving = ref(false) const transceiving = ref(false)
/** /**
@@ -39,19 +34,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
const collectionsByService = computed(() => { const collectionsByService = computed(() => {
const groups: Record<string, CollectionObject[]> = {} const groups: Record<string, CollectionObject[]> = {}
Object.keys(_collectionsByServiceIndex.value).forEach(serviceIndexKey => { Object.values(_collections.value).forEach((collection) => {
const collectionKeys = _collectionsByServiceIndex.value[serviceIndexKey] ?? [] const serviceKey = `${collection.provider}:${collection.service}`
const collectionsForKey = collectionKeys if (!groups[serviceKey]) {
.map(collectionKey => _collections.value[collectionKey]) groups[serviceKey] = []
.filter((collection): collection is CollectionObject => collection !== undefined)
if (collectionsForKey.length === 0) {
return
} }
groups[serviceKey].push(collection)
const firstCollection = collectionsForKey[0]
const serviceKey = `${firstCollection.provider}:${firstCollection.service}`
groups[serviceKey] = collectionsForKey
}) })
return groups return groups
@@ -87,9 +75,10 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
* @returns Array of collection objects * @returns Array of collection objects
*/ */
function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] { function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] {
const serviceCollections = collectionObjectsForKeys( const serviceKeyPrefix = `${provider}:${service}:`
_collectionsByServiceIndex.value[identifierKey(provider, service, SERVICE_INDEX_IDENTIFIER)] ?? [], const serviceCollections = Object.entries(_collections.value)
) .filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, collection]) => collection)
if (retrieve === true && serviceCollections.length === 0) { if (retrieve === true && serviceCollections.length === 0) {
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`) console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`)
@@ -104,26 +93,19 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
return serviceCollections return serviceCollections
} }
/** function collectionsInCollection(provider: string, service: string | number, collectionId: string | number, retrieve: boolean = false): CollectionObject[] {
* Get direct child collections for a parent collection, or root collections when parent is null. const collectionKeyPrefix = `${provider}:${service}:${collectionId}:`
* const nestedCollections = Object.entries(_collections.value)
* @param provider - provider identifier .filter(([key]) => key.startsWith(collectionKeyPrefix))
* @param service - service identifier .map(([_, collection]) => collection)
* @param collectionId - parent collection identifier, or null for root-level collections
* @param retrieve - Retrieve behavior: true = fetch service collections if missing, false = cache only
*
* @returns Array of direct child collection objects
*/
function collectionsInCollection(provider: string, service: string | number, collectionId: string | number | null, retrieve: boolean = false): CollectionObject[] {
const nestedCollections = collectionObjectsForKeys(
_collectionsByParentIndex.value[identifierKey(provider, service, collectionId)] ?? [],
)
if (retrieve === true && nestedCollections.length === 0) { if (retrieve === true && nestedCollections.length === 0) {
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`) console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`)
const sources: SourceSelector = { const sources: SourceSelector = {
[provider]: { [provider]: {
[String(service)]: true [String(service)]: {
[String(collectionId)]: true
}
} }
} }
list(sources) list(sources)
@@ -132,66 +114,11 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
return nestedCollections return nestedCollections
} }
function hasChildrenInCollection(provider: string, service: string | number, collectionId: string | number | null): boolean {
return (_collectionsByParentIndex.value[identifierKey(provider, service, collectionId)]?.length ?? 0) > 0
}
/** /**
* Create unique key for a collection * Create unique key for a collection
*/ */
function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string { function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string {
return `${provider}:${String(service ?? ROOT_IDENTIFIER)}:${String(identifier ?? ROOT_IDENTIFIER)}` return `${provider}:${service ?? ''}:${identifier ?? ''}`
}
function collectionObjectsForKeys(collectionKeys: string[]): CollectionObject[] {
return collectionKeys
.map(collectionKey => _collections.value[collectionKey])
.filter((collection): collection is CollectionObject => collection !== undefined)
}
function addIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
const existing = index[indexKey] ?? []
if (existing.includes(collectionKey)) {
return
}
index[indexKey] = [...existing, collectionKey]
}
function removeIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
const existing = index[indexKey]
if (!existing) {
return
}
const filtered = existing.filter(existingKey => existingKey !== collectionKey)
if (filtered.length === 0) {
delete index[indexKey]
return
}
index[indexKey] = filtered
}
function indexCollection(collection: CollectionObject) {
const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier)
const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER)
const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection)
addIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey)
addIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey)
}
function deindexCollection(collection: CollectionObject) {
const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier)
const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER)
const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection)
removeIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey)
removeIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey)
} }
// Actions // Actions
@@ -216,12 +143,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => { Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => {
Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => { Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => {
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier) const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
const previousCollection = _collections.value[key]
if (previousCollection) {
deindexCollection(previousCollection)
}
collections[key] = collectionObj collections[key] = collectionObj
}) })
}) })
@@ -229,9 +150,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
// Merge retrieved collections into state // Merge retrieved collections into state
_collections.value = { ..._collections.value, ...collections } _collections.value = { ..._collections.value, ...collections }
Object.values(collections).forEach(collectionObj => {
indexCollection(collectionObj)
})
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections') console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections')
return collections return collections
@@ -259,14 +177,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
// Merge fetched collection into state // Merge fetched collection into state
const key = identifierKey(response.provider, response.service, response.identifier) const key = identifierKey(response.provider, response.service, response.identifier)
const previousCollection = _collections.value[key]
if (previousCollection) {
deindexCollection(previousCollection)
}
_collections.value[key] = response _collections.value[key] = response
indexCollection(response)
console.debug('[Mail Manager][Store] - Successfully fetched collection:', key) console.debug('[Mail Manager][Store] - Successfully fetched collection:', key)
return response return response
@@ -323,7 +234,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
// Merge created collection into state // Merge created collection into state
const key = identifierKey(response.provider, response.service, response.identifier) const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response _collections.value[key] = response
indexCollection(response)
console.debug('[Mail Manager][Store] - Successfully created collection:', key) console.debug('[Mail Manager][Store] - Successfully created collection:', key)
return response return response
@@ -357,14 +267,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
// Merge updated collection into state // Merge updated collection into state
const key = identifierKey(response.provider, response.service, response.identifier) const key = identifierKey(response.provider, response.service, response.identifier)
const previousCollection = _collections.value[key]
if (previousCollection) {
deindexCollection(previousCollection)
}
_collections.value[key] = response _collections.value[key] = response
indexCollection(response)
console.debug('[Mail Manager][Store] - Successfully updated collection:', key) console.debug('[Mail Manager][Store] - Successfully updated collection:', key)
return response return response
@@ -392,12 +295,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
// Remove deleted collection from state // Remove deleted collection from state
const key = identifierKey(provider, service, identifier) const key = identifierKey(provider, service, identifier)
const previousCollection = _collections.value[key]
if (previousCollection) {
deindexCollection(previousCollection)
}
delete _collections.value[key] delete _collections.value[key]
console.debug('[Mail Manager][Store] - Successfully deleted collection:', key) console.debug('[Mail Manager][Store] - Successfully deleted collection:', key)
@@ -420,7 +317,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
collectionsByService, collectionsByService,
collectionsForService, collectionsForService,
collectionsInCollection, collectionsInCollection,
hasChildrenInCollection,
// Actions // Actions
collection, collection,
list, list,

View File

@@ -6,20 +6,8 @@ import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { entityService } from '../services' import { entityService } from '../services'
import { EntityObject } from '../models' import { EntityObject } from '../models'
import type { import type { EntityTransmitRequest, EntityTransmitResponse } from '../types/entity'
EntityMoveResponse, import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common'
EntityStreamRequest,
EntityTransmitRequest,
EntityTransmitResponse,
} from '../types/entity'
import type {
CollectionIdentifier,
EntityIdentifier,
ListFilter,
ListRange,
ListSort,
SourceSelector,
} from '../types/common'
export const useEntitiesStore = defineStore('mailEntitiesStore', () => { export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
// State // State
@@ -100,24 +88,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
return `${provider}:${service}:${collection}:${identifier}` return `${provider}:${service}:${collection}:${identifier}`
} }
/**
* Parse a full entity identifier into its components.
*/
function parseEntityIdentifier(identifier: EntityIdentifier): {
provider: string
service: string
collection: string
identifier: string
} {
const [provider, service, collection, entity] = identifier.split(':', 4)
return {
provider,
service,
collection,
identifier: entity,
}
}
// Actions // Actions
/** /**
@@ -133,16 +103,26 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> { async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
transceiving.value = true transceiving.value = true
try { try {
const added: Record<string, EntityObject> = {} const response = await entityService.list({ sources, filter, sort, range })
await entityService.stream({ sources, filter, sort, range }, (entity: EntityObject) => { // Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object
const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier) const entities: Record<string, EntityObject> = {}
_entities.value[key] = entity Object.entries(response).forEach(([providerId, providerServices]) => {
added[key] = entity Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => {
Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => {
Object.entries(collectionEntities).forEach(([entityId, entityData]) => {
const key = identifierKey(providerId, serviceId, collectionId, entityId)
entities[key] = entityData
})
})
})
}) })
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(added).length, 'entities') // Merge retrieved entities into state
return added _entities.value = { ..._entities.value, ...entities }
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities')
return entities
} catch (error: any) { } catch (error: any) {
console.error('[Mail Manager][Store] - Failed to retrieve entities:', error) console.error('[Mail Manager][Store] - Failed to retrieve entities:', error)
throw error throw error
@@ -345,56 +325,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
} }
} }
/**
* Move entities to another collection.
*
* Updates local store keys for successfully moved entities when they are
* already present in cache.
*
* @param target - target collection identifier
* @param sources - source entity identifiers
*
* @returns Promise with move results keyed by source identifier
*/
async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<EntityMoveResponse> {
transceiving.value = true
try {
const response = await entityService.move({ target, sources })
Object.entries(response).forEach(([sourceIdentifier, result]) => {
if (!result.success) {
return
}
const cachedEntity = _entities.value[sourceIdentifier]
if (!cachedEntity) {
return
}
const destination = parseEntityIdentifier(result.identifier)
const movedEntity = cachedEntity.clone().fromJson({
...cachedEntity.toJson(),
provider: destination.provider,
service: destination.service,
collection: destination.collection,
identifier: destination.identifier,
})
delete _entities.value[sourceIdentifier]
_entities.value[result.identifier] = movedEntity
})
console.debug('[Mail Manager][Store] - Successfully moved', Object.keys(response).length, 'entities')
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to move entities:', error)
throw error
} finally {
transceiving.value = false
}
}
/** /**
* Send/transmit an entity * Send/transmit an entity
* *
@@ -416,42 +346,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
} }
} }
/**
* Stream entities progressively, merging each entity into the store as it arrives.
*
* Unlike list(), which waits for the full response before updating the store,
* stream() updates reactive state entity-by-entity so UI renders incrementally.
*
* @param sources - optional source selector
* @param filter - optional list filter
* @param sort - optional list sort
* @param range - optional list range
*
* @returns Promise resolving to { total } when the stream completes
*/
async function stream(
sources?: SourceSelector,
filter?: ListFilter,
sort?: ListSort,
range?: ListRange
): Promise<{ total: number }> {
transceiving.value = true
try {
const request: EntityStreamRequest = { sources, filter, sort, range }
const result = await entityService.stream(request, (entity: EntityObject) => {
const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier)
_entities.value[key] = entity
})
console.debug('[Mail Manager][Store] - Successfully streamed', result.total, 'entities')
return result
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to stream entities:', error)
throw error
} finally {
transceiving.value = false
}
}
// Return public API // Return public API
return { return {
// State (readonly) // State (readonly)
@@ -470,8 +364,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
update, update,
delete: remove, delete: remove,
delta, delta,
move,
transmit, transmit,
stream,
} }
}) })

View File

@@ -1,4 +0,0 @@
export { useCollectionsStore } from './collectionsStore';
export { useEntitiesStore } from './entitiesStore';
export { useProvidersStore } from './providersStore';
export { useServicesStore } from './servicesStore';

View File

@@ -268,29 +268,22 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
* @param secret - optional secret for discovery * @param secret - optional secret for discovery
* @param location - optional location for discovery * @param location - optional location for discovery
* @param provider - optional provider identifier for discovery * @param provider - optional provider identifier for discovery
* @param onService - called for each discovered service as it arrives
* *
* @returns Promise resolving to { total } when the stream completes * @returns Promise with list of discovered service objects
*/ */
async function discover( async function discover(
identity: string, identity: string,
secret: string | undefined, secret: string | undefined,
location: string | undefined, location: string | undefined,
provider: string | undefined, provider: string | undefined,
onService?: (service: ServiceObject) => void, ): Promise<ServiceObject[]> {
): Promise<{ total: number }> {
transceiving.value = true transceiving.value = true
try { try {
const result = await serviceService.discover( const services = await serviceService.discover({identity, secret, location, provider})
{ identity, secret, location, provider },
(service: ServiceObject) => {
onService?.(service)
}
)
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services') console.debug('[Mail Manager][Store] - Successfully discovered', services.length, 'services')
return result return services
} catch (error: any) { } catch (error: any) {
console.error('[Mail Manager][Store] - Failed to discover service:', error) console.error('[Mail Manager][Store] - Failed to discover service:', error)
throw error throw error

View File

@@ -43,47 +43,6 @@ export interface ApiErrorResponse {
*/ */
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse; export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
/**
* Stream control start line
*/
export interface ApiStreamStartResponse {
type: 'control';
status: 'start';
version: number;
transaction: string;
}
/**
* Stream control end line
*/
export interface ApiStreamEndResponse {
type: 'control';
status: 'end';
total: number;
}
/**
* Stream error line
*/
export interface ApiStreamErrorResponse {
type: 'error';
message: string;
}
export interface ApiStreamDataResponse<T = any> {
type: 'data';
data: T;
}
/**
* Shared stream control lines
*/
export type ApiStreamResponse<T = any> =
| ApiStreamStartResponse
| ApiStreamEndResponse
| ApiStreamErrorResponse
| ApiStreamDataResponse<T>;
/** /**
* Selector for targeting specific providers, services, collections, or entities in list or extant operations. * Selector for targeting specific providers, services, collections, or entities in list or extant operations.
* *
@@ -113,10 +72,6 @@ export type CollectionSelector = {
export type EntitySelector = (string | number)[]; export type EntitySelector = (string | number)[];
export type ProviderIdentifier = `${string}`;
export type ServiceIdentifier = `${string}:${string}`;
export type CollectionIdentifier = `${string}:${string}:${string}`;
export type EntityIdentifier = `${string}:${string}:${string}:${string}`;
/** /**
* Filter comparison for list operations * Filter comparison for list operations

View File

@@ -1,14 +1,7 @@
/** /**
* Entity type definitions * Entity type definitions
*/ */
import type { import type { SourceSelector, ListFilter, ListSort, ListRange } from './common';
CollectionIdentifier,
EntityIdentifier,
SourceSelector,
ListFilter,
ListRange,
ListSort,
} from './common';
import type { MessageInterface } from './message'; import type { MessageInterface } from './message';
/** /**
@@ -135,28 +128,6 @@ export interface EntityDeltaResponse {
}; };
} }
/**
* Entity move
*/
export interface EntityMoveRequest {
target: CollectionIdentifier;
sources: EntityIdentifier[];
}
export interface EntityMoveResultSuccess {
success: boolean;
identifier: EntityIdentifier;
}
export interface EntityMoveResultFailure {
success: boolean;
error: string;
}
export interface EntityMoveResponse {
[sourceIdentifier: EntityIdentifier]: EntityMoveResultSuccess | EntityMoveResultFailure;
}
/** /**
* Entity transmit * Entity transmit
*/ */
@@ -187,15 +158,3 @@ export interface EntityTransmitResponse {
id: string; id: string;
status: 'queued' | 'sent'; status: 'queued' | 'sent';
} }
/**
* Entity stream
*/
export interface EntityStreamRequest {
sources?: SourceSelector;
filter?: ListFilter;
sort?: ListSort;
range?: ListRange;
}
export interface EntityStreamResponse extends EntityInterface<MessageInterface> {}

View File

@@ -1,3 +1,7 @@
/**
* Central export point for all Mail Manager types
*/
export type * from './collection'; export type * from './collection';
export type * from './common'; export type * from './common';
export type * from './entity'; export type * from './entity';

View File

@@ -11,8 +11,8 @@ export interface ProviderCapabilitiesInterface {
ServiceFetch?: boolean; ServiceFetch?: boolean;
ServiceExtant?: boolean; ServiceExtant?: boolean;
ServiceCreate?: boolean; ServiceCreate?: boolean;
ServiceUpdate?: boolean; ServiceModify?: boolean;
ServiceDelete?: boolean; ServiceDestroy?: boolean;
ServiceDiscover?: boolean; ServiceDiscover?: boolean;
ServiceTest?: boolean; ServiceTest?: boolean;
[key: string]: boolean | object | string[] | undefined; [key: string]: boolean | object | string[] | undefined;

View File

@@ -1,10 +1,7 @@
/** /**
* Service type definitions * Service type definitions
*/ */
import type { import type { SourceSelector, ListFilterComparisonOperator } from './common';
ListFilterComparisonOperator,
SourceSelector,
} from './common';
/** /**
* Service capabilities * Service capabilities
@@ -19,7 +16,6 @@ export interface ServiceCapabilitiesInterface {
CollectionCreate?: boolean; CollectionCreate?: boolean;
CollectionUpdate?: boolean; CollectionUpdate?: boolean;
CollectionDelete?: boolean; CollectionDelete?: boolean;
CollectionMove?: boolean;
// Message capabilities // Message capabilities
EntityList?: boolean; EntityList?: boolean;
EntityListFilter?: ServiceListFilterEntity; EntityListFilter?: ServiceListFilterEntity;
@@ -133,8 +129,7 @@ export interface ServiceDiscoverRequest {
} }
export interface ServiceDiscoverResponse { export interface ServiceDiscoverResponse {
provider: string; [provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union
location: ServiceLocation;
} }
/** /**

View File

@@ -32,6 +32,10 @@
<directory>../../core/lib</directory> <directory>../../core/lib</directory>
<directory>../../shared/lib</directory> <directory>../../shared/lib</directory>
</include> </include>
<deprecationTrigger>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source> </source>
<extensions> <extensions>

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace KTXT\MailManager\Tests\Unit; namespace KTXT;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;

View File

@@ -41,16 +41,13 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
external: [ external: [
'pinia',
'vue', 'vue',
'vue-router', 'vue-router',
'pinia', // Externalize shared utilities from core to avoid duplication
'@KTXC', /^@KTXC\/utils\//,
], ],
output: { output: {
paths: (id) => {
if (id === '@KTXC') return '/js/ktxc.mjs'
return id
},
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) { if (assetInfo.name?.endsWith('.css')) {
return 'mail_manager-[hash].css' return 'mail_manager-[hash].css'