Compare commits

..

18 Commits

Author SHA1 Message Date
b617234b40 Merge pull request 'feat: entity move' (#12) from feat/entity-move into main
Some checks failed
Renovate / renovate (push) Failing after 1m20s
Reviewed-on: #12
2026-03-28 16:37:14 +00:00
1c918ca55c feat: entity move
All checks were successful
Build Test / test (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-28 09:32:04 -04:00
7322bb16da Merge pull request 'refactor: improvemets' (#11) from refactor/improvements into main
Some checks failed
Renovate / renovate (push) Failing after 1m22s
Reviewed-on: #11
2026-03-24 23:14:21 +00:00
78449a702b refactor: improvemets
All checks were successful
Build Test / test (pull_request) Successful in 1m58s
JS Unit Tests / test (pull_request) Successful in 1m58s
PHP Unit Tests / test (pull_request) Successful in 2m29s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-24 19:12:02 -04:00
4bcfbf6d0e Merge pull request 'refactor: unify streaming' (#10) from refactor/unify-streaming into main
Some checks failed
Renovate / renovate (push) Failing after 2m11s
Reviewed-on: #10
2026-03-07 03:57:09 +00:00
cceaf809d9 refactor: unify streaming
All checks were successful
Build Test / test (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Successful in 1m8s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:53:08 -05:00
5bfe5dd249 Merge pull request 'refactor: standardize manager design' (#9) from refactor/standardize-desing into main
Some checks failed
Renovate / renovate (push) Failing after 1m33s
Reviewed-on: #9
2026-02-25 05:20:38 +00:00
65435b526c refactor: standardize manager design
All checks were successful
Build Test / test (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 1m5s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-25 00:18:28 -05:00
4a7fe7aeb4 Merge pull request 'refactor: module federation' (#8) from refactor/module-federation into main
Some checks failed
Renovate / renovate (push) Failing after 1m44s
Reviewed-on: #8
2026-02-22 21:54:16 +00:00
ad0a20613e refactor: module federation
All checks were successful
JS Unit Tests / test (pull_request) Successful in 50s
Build Test / test (pull_request) Successful in 54s
PHP Unit Tests / test (pull_request) Successful in 57s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 16:54:00 -05:00
6e5627f83b Merge pull request 'feat/entity-streaming' (#7) from feat/entity-streaming into main
Some checks failed
Renovate / renovate (push) Failing after 1m47s
Reviewed-on: #7
2026-02-21 15:08:31 +00:00
83c04e659b fix: export all objects
All checks were successful
Build Test / test (pull_request) Successful in 52s
JS Unit Tests / test (pull_request) Successful in 50s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:07:49 -05:00
0310a30f22 chore: remove unneeded files
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:06:47 -05:00
f520b8e5ac feat: streaming entities
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:06:07 -05:00
1f8a6d2d07 Merge pull request 'chore: code cleanup' (#6) from chore/code-cleanup into main
Some checks failed
Renovate / renovate (push) Failing after 1m20s
Reviewed-on: #6
2026-02-20 05:06:11 +00:00
e5eeeeb546 chore: code cleanup
All checks were successful
Build Test / test (pull_request) Successful in 48s
JS Unit Tests / test (pull_request) Successful in 51s
PHP Unit Tests / test (pull_request) Successful in 1m3s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 00:16:33 -05:00
6716e507c4 Merge pull request 'chore: implemement basic tests' (#5) from feat/implement-basic-tests into main
Some checks failed
Renovate / renovate (push) Failing after 1m43s
Reviewed-on: #5
2026-02-16 03:38:45 +00:00
f704e2e392 chore: implemement basic tests
All checks were successful
JS Unit Tests / test (pull_request) Successful in 22s
Build Test / test (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 22:37:31 -05:00
23 changed files with 1054 additions and 910 deletions

View File

@@ -1,428 +0,0 @@
# 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,11 +11,17 @@ 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\Mail\Entity\Message; use KTXF\Json\JsonSerializable;
use KTXF\Mail\Queue\SendOptions; use KTXF\Resource\Identifier\CollectionIdentifier;
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;
@@ -30,23 +36,25 @@ 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_MISSING_MESSAGE = 'Missing parameter: message'; private const ERR_INVALID_OPERATION = 'Invalid operation: ';
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,
@@ -59,15 +67,14 @@ 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": {...}
* }
* *
* Batch operations: * @return Response
* { "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(
@@ -75,18 +82,22 @@ 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
): JsonResponse { ): Response {
// 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 ?? [], []); $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction);
if ($result instanceof Response) {
return $result;
}
return new JsonResponse([ return new JsonResponse([
'version' => $version, 'version' => $version,
'transaction' => $transaction, 'transaction' => $transaction,
@@ -96,21 +107,10 @@ class DefaultController extends ControllerAbstract {
], JsonResponse::HTTP_OK); ], JsonResponse::HTTP_OK);
} }
// Batch operations mode throw new InvalidArgumentException('Operation must be provided');
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 mail manager request', ['exception' => $t]); $this->logger->error('Error processing request', ['exception' => $t]);
return new JsonResponse([ return new JsonResponse([
'version' => $version, 'version' => $version,
'transaction' => $transaction, 'transaction' => $transaction,
@@ -124,105 +124,10 @@ 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, array $resultMap): mixed { private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): 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),
@@ -236,7 +141,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), 'service.discover' => $this->serviceDiscover($tenantId, $userId, $data, $version, $transaction),
'service.test' => $this->serviceTest($tenantId, $userId, $data), 'service.test' => $this->serviceTest($tenantId, $userId, $data),
// Collection operations // Collection operations
@@ -256,12 +161,13 @@ 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' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.move' => $this->entityMove($tenantId, $userId, $data),
'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('Unknown operation: ' . $operation) default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
}; };
} }
@@ -279,6 +185,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']);
}
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'])) {
@@ -294,18 +212,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']);
}
// ==================== Service Operations ===================== // ==================== Service Operations =====================
private function serviceList(string $tenantId, string $userId, array $data): mixed { private function serviceList(string $tenantId, string $userId, array $data): mixed {
@@ -320,20 +226,6 @@ 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'])) {
@@ -352,41 +244,18 @@ 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 serviceDiscover(string $tenantId, string $userId, array $data): mixed { private function serviceExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) { if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA); throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
} }
if (!is_array($data['sources'])) {
$provider = $data['provider'] ?? null; throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
$identity = $data['identity'];
$location = $data['location'] ?? null;
$secret = $data['secret'] ?? null;
return $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
} }
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
private function serviceTest(string $tenantId, string $userId, array $data): mixed { return $this->mailManager->serviceExtant($tenantId, $userId, $sources);
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 {
@@ -462,6 +331,73 @@ 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 {
@@ -640,34 +576,6 @@ 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);
@@ -704,6 +612,63 @@ 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);
@@ -729,4 +694,49 @@ 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,7 +21,12 @@ 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;
@@ -38,8 +43,7 @@ use Psr\Log\LoggerInterface;
/** /**
* Mail Manager * Mail Manager
* *
* Provides unified mail sending across multiple providers with context-aware * Provides unified mail sending across multiple providers
* service discovery and queued delivery support.
*/ */
class Manager { class Manager {
@@ -63,6 +67,25 @@ 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
* *
@@ -83,25 +106,6 @@ 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
* *
@@ -124,6 +128,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;
}
/** /**
* Confirm which services are available * Confirm which services are available
* *
@@ -151,27 +176,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;
}
/** /**
* Find a service that handles a specific email address * Find a service that handles a specific email address
* *
@@ -304,7 +308,7 @@ class Manager {
} }
/** /**
* Discover mail service settings from identity * Discover mail service settings from identity, yielding results as each provider completes
* *
* @since 2025.05.01 * @since 2025.05.01
* *
@@ -315,12 +319,7 @@ 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 array<string,ResourceServiceLocationInterface> Array of discovered service locations keyed by provider ID * @return \Generator Yields providerId => ResourceServiceLocationInterface pairs as each provider completes
* [
* 'jmap' => ResourceServiceLocationInterface,
* 'smtp' => ResourceServiceLocationInterface,
* // Only providers that successfully discovered (non-null)
* ]
*/ */
public function serviceDiscover( public function serviceDiscover(
string $tenantId, string $tenantId,
@@ -329,32 +328,28 @@ class Manager {
string $identity, string $identity,
string|null $location = null, string|null $location = null,
string|null $secret = null string|null $secret = null
): array { ): \Generator {
$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 $providerId => $provider) { foreach ($providers as $currentProviderId => $provider) {
if (!($provider instanceof ProviderServiceDiscoverInterface)) { if (!($provider instanceof ProviderServiceDiscoverInterface)) {
continue; continue;
} }
try { try {
$location = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret); $result = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret);
if ($location !== null) { if ($result !== null) {
$locations[$providerId] = $location; yield $currentProviderId => $result;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->warning('Provider autodiscovery failed', [ $this->logger->warning('Provider autodiscovery failed', [
'provider' => $providerId, 'provider' => $currentProviderId,
'identity' => $identity, 'identity' => $identity,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $locations;
} }
/** /**
@@ -766,52 +761,101 @@ class Manager {
} }
/** /**
* Get message delta/changes * Stream entities
*
* @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 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 collection
} 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;
} }
/** /**
@@ -889,24 +933,102 @@ class Manager {
} }
/** /**
* Fetch specific messages * Get message delta/changes
* *
* @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) {
$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;
}
// retrieve collection public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array {
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,23 +198,24 @@ async function handleDiscover() {
discoveryStatus.value[identifier].status = 'discovering' discoveryStatus.value[identifier].status = 'discovering'
try { try {
const services = await servicesStore.discover( let discoveredService: any = undefined
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
const service = services.find(s => s.provider === identifier) if (discoveredService && discoveredService.location) {
if (service && service.location) {
discoveryStatus.value[identifier] = { discoveryStatus.value[identifier] = {
provider: identifier, provider: identifier,
status: 'success', status: 'success',
location: service.location, location: discoveredService.location,
metadata: extractLocationMetadata(service.location) metadata: extractLocationMetadata(discoveredService.location)
} }
discoveredServices.value.push(service) discoveredServices.value.push(discoveredService)
} 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,10 +5,11 @@
*/ */
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';
interface SyncSource { export interface SyncSource {
provider: string; provider: string;
service: string | number; service: string | number;
collections: (string | number)[]; collections: (string | number)[];
@@ -23,7 +24,21 @@ interface SyncOptions {
fetchDetails?: boolean; fetchDetails?: boolean;
} }
export function useMailSync(options: SyncOptions = {}) { export interface MailSyncController {
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,21 +1,13 @@
import type { App as Vue } from 'vue'
import routes from '@/routes'
import integrations from '@/integrations'
import { useCollectionsStore } from '@/stores/collectionsStore'
import { useEntitiesStore } from '@/stores/entitiesStore'
import { useProvidersStore } from '@/stores/providersStore'
import { useServicesStore } from '@/stores/servicesStore'
/** /**
* Mail Manager Module Boot Script * Mail Manager Module Boot
*
* This script is executed when the mail_manager module is loaded.
* It initializes the stores which manage mail providers, services, collections, and messages.
*/ */
console.log('[MailManager] Booting Mail Manager module...') import routes from '@/routes'
import integrations from '@/integrations'
console.log('[MailManager] Mail Manager module booted successfully') console.log('[Mail Manager] Booting module...')
console.log('[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__']
@@ -23,12 +15,14 @@ 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 stores for external use if needed // Export services, stores and models for external use
export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore } export * from '@/services'
export * from '@/stores'
export * from '@/models'
// Default export for Vue plugin installation // Export composables for external use
export default { export { useMailSync } from '@/composables/useMailSync'
install(app: Vue) {
// Module initialization if needed // Export components for external use
} export { default as AddAccountDialog } from '@/components/AddAccountDialog.vue'
} export { default as EditAccountDialog } from '@/components/EditAccountDialog.vue'

View File

@@ -17,16 +17,7 @@ export class CollectionObject implements CollectionInterface {
signature: null, signature: null,
created: null, created: null,
modified: null, modified: null,
properties: { properties: new CollectionPropertiesObject(),
'@type': 'mail.collection',
version: 1,
total: 0,
unread: 0,
label: '',
role: null,
rank: 0,
subscribed: true,
},
}; };
} }
@@ -34,8 +25,6 @@ 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;
} }
@@ -114,12 +103,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,
label: '',
role: null, role: null,
label: '',
rank: 0, rank: 0,
subscribed: true, subscribed: true,
}; };

View File

@@ -1,13 +1,14 @@
/**
* 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 {
// Identity models CollectionObject,
CollectionPropertiesObject
} from './collection';
export { EntityObject } from './entity';
export {
MessageObject,
MessagePartObject
} from './message';
export { export {
Identity, Identity,
IdentityNone, IdentityNone,
@@ -16,8 +17,6 @@ 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 } from './transceive'; import { transceivePost, transceiveStream } from './transceive';
import type { import type {
EntityListRequest, EntityListRequest,
EntityListResponse, EntityListResponse,
@@ -18,9 +18,13 @@ 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';
@@ -147,6 +151,17 @@ 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
* *
@@ -157,6 +172,30 @@ 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,
ServiceExtantRequest,
ServiceExtantResponse,
ServiceFetchRequest, ServiceFetchRequest,
ServiceFetchResponse, ServiceFetchResponse,
ServiceDiscoverRequest, ServiceExtantRequest,
ServiceDiscoverResponse, ServiceExtantResponse,
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 } from './transceive'; import { transceivePost, transceiveStream } from './transceive';
import { ServiceObject } from '../models/service'; import { ServiceObject } from '../models/service';
/** /**
@@ -87,31 +87,32 @@ export const serviceService = {
}, },
/** /**
* Retrieve discoverable services for a given source selector, sorted by provider * Discover services, streaming results as each provider responds
* *
* @param request - discover request parameters * @param request - discover request parameters
* @param onService - called for each discovered service as it arrives
* *
* @returns Promise with array of discovered services sorted by provider * @returns Promise resolving to { total } when the stream completes
*/ */
async discover(request: ServiceDiscoverRequest): Promise<ServiceObject[]> { async discover(
const response = await transceivePost<ServiceDiscoverRequest, ServiceDiscoverResponse>('service.discover', request); request: ServiceDiscoverRequest,
onService: (service: ServiceObject) => void
// Convert discovery results to ServiceObjects ): Promise<{ total: number }> {
const services: ServiceObject[] = []; return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
Object.entries(response).forEach(([providerId, location]) => { 'service.discover',
request,
(service) => {
const serviceData: ServiceInterface = { const serviceData: ServiceInterface = {
'@type': 'mail:service', '@type': 'mail:service',
provider: providerId, provider: service.provider,
identifier: null, identifier: null,
label: null, label: null,
enabled: false, enabled: false,
location: location, location: service.location,
}; };
services.push(createServiceObject(serviceData)); onService(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/utils/helpers/fetch-wrapper-core'; import { createFetchWrapper } from '@KTXC';
import type { ApiRequest, ApiResponse } from '../types/common'; import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common';
const fetchWrapper = createFetchWrapper(); const fetchWrapper = createFetchWrapper();
const API_URL = '/m/mail_manager/v1'; const API_URL = '/m/mail_manager/v1';
@@ -48,3 +48,96 @@ 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,8 +9,13 @@ 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)
/** /**
@@ -34,12 +39,19 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
const collectionsByService = computed(() => { const collectionsByService = computed(() => {
const groups: Record<string, CollectionObject[]> = {} const groups: Record<string, CollectionObject[]> = {}
Object.values(_collections.value).forEach((collection) => { Object.keys(_collectionsByServiceIndex.value).forEach(serviceIndexKey => {
const serviceKey = `${collection.provider}:${collection.service}` const collectionKeys = _collectionsByServiceIndex.value[serviceIndexKey] ?? []
if (!groups[serviceKey]) { const collectionsForKey = collectionKeys
groups[serviceKey] = [] .map(collectionKey => _collections.value[collectionKey])
.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
@@ -75,10 +87,9 @@ 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 serviceKeyPrefix = `${provider}:${service}:` const serviceCollections = collectionObjectsForKeys(
const serviceCollections = Object.entries(_collections.value) _collectionsByServiceIndex.value[identifierKey(provider, service, SERVICE_INDEX_IDENTIFIER)] ?? [],
.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}"`)
@@ -93,19 +104,26 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
return serviceCollections return serviceCollections
} }
function collectionsInCollection(provider: string, service: string | number, collectionId: string | number, retrieve: boolean = false): CollectionObject[] { /**
const collectionKeyPrefix = `${provider}:${service}:${collectionId}:` * Get direct child collections for a parent collection, or root collections when parent is null.
const nestedCollections = Object.entries(_collections.value) *
.filter(([key]) => key.startsWith(collectionKeyPrefix)) * @param provider - provider identifier
.map(([_, collection]) => collection) * @param service - service identifier
* @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)]: { [String(service)]: true
[String(collectionId)]: true
}
} }
} }
list(sources) list(sources)
@@ -114,11 +132,66 @@ 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}:${service ?? ''}:${identifier ?? ''}` return `${provider}:${String(service ?? ROOT_IDENTIFIER)}:${String(identifier ?? ROOT_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
@@ -143,6 +216,12 @@ 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
}) })
}) })
@@ -150,6 +229,9 @@ 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
@@ -177,7 +259,14 @@ 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
@@ -234,6 +323,7 @@ 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
@@ -267,7 +357,14 @@ 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
@@ -295,6 +392,12 @@ 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)
@@ -317,6 +420,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
collectionsByService, collectionsByService,
collectionsForService, collectionsForService,
collectionsInCollection, collectionsInCollection,
hasChildrenInCollection,
// Actions // Actions
collection, collection,
list, list,

View File

@@ -6,8 +6,20 @@ 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 { EntityTransmitRequest, EntityTransmitResponse } from '../types/entity' import type {
import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common' EntityMoveResponse,
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
@@ -88,6 +100,24 @@ 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
/** /**
@@ -103,26 +133,16 @@ 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 response = await entityService.list({ sources, filter, sort, range }) const added: Record<string, EntityObject> = {}
// Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object await entityService.stream({ sources, filter, sort, range }, (entity: EntityObject) => {
const entities: Record<string, EntityObject> = {} const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier)
Object.entries(response).forEach(([providerId, providerServices]) => { _entities.value[key] = entity
Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { added[key] = entity
Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => {
Object.entries(collectionEntities).forEach(([entityId, entityData]) => {
const key = identifierKey(providerId, serviceId, collectionId, entityId)
entities[key] = entityData
})
})
})
}) })
// Merge retrieved entities into state console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(added).length, 'entities')
_entities.value = { ..._entities.value, ...entities } return added
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
@@ -325,6 +345,56 @@ 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
* *
@@ -346,6 +416,42 @@ 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)
@@ -364,6 +470,8 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
update, update,
delete: remove, delete: remove,
delta, delta,
move,
transmit, transmit,
stream,
} }
}) })

4
src/stores/index.ts Normal file
View File

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

View File

@@ -268,22 +268,29 @@ 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 with list of discovered service objects * @returns Promise resolving to { total } when the stream completes
*/ */
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,
): Promise<ServiceObject[]> { onService?: (service: ServiceObject) => void,
): Promise<{ total: number }> {
transceiving.value = true transceiving.value = true
try { try {
const services = await serviceService.discover({identity, secret, location, provider}) const result = await serviceService.discover(
{ identity, secret, location, provider },
(service: ServiceObject) => {
onService?.(service)
}
)
console.debug('[Mail Manager][Store] - Successfully discovered', services.length, 'services') console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
return services return result
} 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,6 +43,47 @@ 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.
* *
@@ -72,6 +113,10 @@ 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,7 +1,14 @@
/** /**
* Entity type definitions * Entity type definitions
*/ */
import type { SourceSelector, ListFilter, ListSort, ListRange } from './common'; import type {
CollectionIdentifier,
EntityIdentifier,
SourceSelector,
ListFilter,
ListRange,
ListSort,
} from './common';
import type { MessageInterface } from './message'; import type { MessageInterface } from './message';
/** /**
@@ -128,6 +135,28 @@ 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
*/ */
@@ -158,3 +187,15 @@ 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,7 +1,3 @@
/**
* 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;
ServiceModify?: boolean; ServiceUpdate?: boolean;
ServiceDestroy?: boolean; ServiceDelete?: boolean;
ServiceDiscover?: boolean; ServiceDiscover?: boolean;
ServiceTest?: boolean; ServiceTest?: boolean;
[key: string]: boolean | object | string[] | undefined; [key: string]: boolean | object | string[] | undefined;

View File

@@ -1,7 +1,10 @@
/** /**
* Service type definitions * Service type definitions
*/ */
import type { SourceSelector, ListFilterComparisonOperator } from './common'; import type {
ListFilterComparisonOperator,
SourceSelector,
} from './common';
/** /**
* Service capabilities * Service capabilities
@@ -16,6 +19,7 @@ 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;
@@ -129,7 +133,8 @@ export interface ServiceDiscoverRequest {
} }
export interface ServiceDiscoverResponse { export interface ServiceDiscoverResponse {
[provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union provider: string;
location: ServiceLocation;
} }
/** /**

View File

@@ -32,10 +32,6 @@
<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

@@ -41,13 +41,16 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
external: [ external: [
'pinia',
'vue', 'vue',
'vue-router', 'vue-router',
// Externalize shared utilities from core to avoid duplication 'pinia',
/^@KTXC\/utils\//, '@KTXC',
], ],
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'