Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
26
composer.json
Normal file
26
composer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ktxm/mail-manager",
|
||||||
|
"type": "project",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sebastian Krupinski",
|
||||||
|
"email": "krupinski01@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.2"
|
||||||
|
},
|
||||||
|
"autoloader-suffix": "MailManager",
|
||||||
|
"vendor-dir": "lib/vendor"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2 <=8.5"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\MailManager\\": "lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
428
docs/interfaces.md
Normal file
428
docs/interfaces.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# Mail Manager - Interface Relationships
|
||||||
|
|
||||||
|
This document visualizes all the interfaces in the mail_manager module and their relationships.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The mail manager uses a hierarchical structure where interfaces are organized by their domain responsibilities:
|
||||||
|
- **Common Types**: Base types and selectors
|
||||||
|
- **Providers**: Mail service providers (Gmail, IMAP, etc.)
|
||||||
|
- **Services**: Individual mail accounts/services
|
||||||
|
- **Collections**: Mailboxes and folders
|
||||||
|
- **Messages**: Email messages and their parts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Interface Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
%% Common/Base Types
|
||||||
|
class SourceSelector {
|
||||||
|
+string provider
|
||||||
|
+string service
|
||||||
|
+string collection
|
||||||
|
+string message
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiRequest~T~ {
|
||||||
|
+T data
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiResponse~T~ {
|
||||||
|
+T data
|
||||||
|
+Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListRange {
|
||||||
|
+number offset
|
||||||
|
+number limit
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Provider Interfaces
|
||||||
|
class ProviderInterface {
|
||||||
|
+string @type
|
||||||
|
+string identifier
|
||||||
|
+string label
|
||||||
|
+ProviderCapabilitiesInterface capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderCapabilitiesInterface {
|
||||||
|
+boolean ServiceList
|
||||||
|
+boolean ServiceFetch
|
||||||
|
+boolean ServiceExtant
|
||||||
|
+boolean ServiceCreate
|
||||||
|
+boolean ServiceModify
|
||||||
|
+boolean ServiceDestroy
|
||||||
|
+boolean ServiceDiscover
|
||||||
|
+boolean ServiceTest
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderListRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderListResponse {
|
||||||
|
+ProviderInterface[identifier] providers
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderFetchRequest {
|
||||||
|
+string identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderFetchResponse {
|
||||||
|
<<extends ProviderInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderExtantRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderExtantResponse {
|
||||||
|
+boolean[identifier] exists
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Service Interfaces
|
||||||
|
class ServiceInterface {
|
||||||
|
+string @type
|
||||||
|
+string identifier
|
||||||
|
+string provider
|
||||||
|
+string label
|
||||||
|
+ServiceCapabilitiesInterface capabilities
|
||||||
|
+object configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceCapabilitiesInterface {
|
||||||
|
+boolean CollectionList
|
||||||
|
+boolean CollectionFetch
|
||||||
|
+boolean CollectionExtant
|
||||||
|
+boolean CollectionCreate
|
||||||
|
+boolean CollectionModify
|
||||||
|
+boolean CollectionDestroy
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceListRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
+ListRange range
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceListResponse {
|
||||||
|
+ServiceInterface[identifier] services
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceFetchRequest {
|
||||||
|
+string provider
|
||||||
|
+string identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceFetchResponse {
|
||||||
|
<<extends ServiceInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceExtantRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceExtantResponse {
|
||||||
|
+boolean[identifier] exists
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceCreateRequest {
|
||||||
|
+string provider
|
||||||
|
+string label
|
||||||
|
+object configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceCreateResponse {
|
||||||
|
<<extends ServiceInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceModifyRequest {
|
||||||
|
+string provider
|
||||||
|
+string identifier
|
||||||
|
+string label
|
||||||
|
+object configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceModifyResponse {
|
||||||
|
<<extends ServiceInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceDestroyRequest {
|
||||||
|
+string provider
|
||||||
|
+string identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceDestroyResponse {
|
||||||
|
+boolean success
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Collection Interfaces
|
||||||
|
class CollectionInterface {
|
||||||
|
+string @type
|
||||||
|
+string identifier
|
||||||
|
+string service
|
||||||
|
+string provider
|
||||||
|
+string label
|
||||||
|
+CollectionCapabilitiesInterface capabilities
|
||||||
|
+string[] flags
|
||||||
|
+number messageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionCapabilitiesInterface {
|
||||||
|
+boolean MessageList
|
||||||
|
+boolean MessageFetch
|
||||||
|
+boolean MessageExtant
|
||||||
|
+boolean MessageCreate
|
||||||
|
+boolean MessageModify
|
||||||
|
+boolean MessageDestroy
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionListRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
+ListRange range
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionListResponse {
|
||||||
|
+CollectionInterface[identifier] collections
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionFetchRequest {
|
||||||
|
+string provider
|
||||||
|
+string service
|
||||||
|
+string identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionFetchResponse {
|
||||||
|
<<extends CollectionInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Message Interfaces
|
||||||
|
class MessageInterface {
|
||||||
|
+string @type
|
||||||
|
+string identifier
|
||||||
|
+string collection
|
||||||
|
+string service
|
||||||
|
+string provider
|
||||||
|
+string[] flags
|
||||||
|
+Date receivedDate
|
||||||
|
+Date internalDate
|
||||||
|
+MessageHeadersInterface headers
|
||||||
|
+MessagePartInterface[] parts
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageHeadersInterface {
|
||||||
|
+string from
|
||||||
|
+string[] to
|
||||||
|
+string[] cc
|
||||||
|
+string[] bcc
|
||||||
|
+string subject
|
||||||
|
+string messageId
|
||||||
|
+string[] references
|
||||||
|
+string inReplyTo
|
||||||
|
+Date date
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagePartInterface {
|
||||||
|
+string partId
|
||||||
|
+string mimeType
|
||||||
|
+string filename
|
||||||
|
+number size
|
||||||
|
+MessagePartInterface[] subParts
|
||||||
|
+object headers
|
||||||
|
+string body
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageListRequest {
|
||||||
|
+SourceSelector sources
|
||||||
|
+ListRange range
|
||||||
|
+string[] flags
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageListResponse {
|
||||||
|
+MessageInterface[identifier] messages
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageFetchRequest {
|
||||||
|
+string provider
|
||||||
|
+string service
|
||||||
|
+string collection
|
||||||
|
+string identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageFetchResponse {
|
||||||
|
<<extends MessageInterface>>
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Relationships
|
||||||
|
ProviderInterface --> ProviderCapabilitiesInterface
|
||||||
|
ProviderFetchResponse --|> ProviderInterface
|
||||||
|
ProviderListResponse --> ProviderInterface
|
||||||
|
|
||||||
|
ServiceInterface --> ServiceCapabilitiesInterface
|
||||||
|
ServiceFetchResponse --|> ServiceInterface
|
||||||
|
ServiceCreateResponse --|> ServiceInterface
|
||||||
|
ServiceModifyResponse --|> ServiceInterface
|
||||||
|
ServiceListResponse --> ServiceInterface
|
||||||
|
|
||||||
|
CollectionInterface --> CollectionCapabilitiesInterface
|
||||||
|
CollectionFetchResponse --|> CollectionInterface
|
||||||
|
CollectionListResponse --> CollectionInterface
|
||||||
|
|
||||||
|
MessageInterface --> MessageHeadersInterface
|
||||||
|
MessageInterface --> MessagePartInterface
|
||||||
|
MessagePartInterface --> MessagePartInterface : subParts
|
||||||
|
MessageFetchResponse --|> MessageInterface
|
||||||
|
MessageListResponse --> MessageInterface
|
||||||
|
|
||||||
|
%% Selector Usage
|
||||||
|
ProviderListRequest --> SourceSelector
|
||||||
|
ProviderExtantRequest --> SourceSelector
|
||||||
|
ServiceListRequest --> SourceSelector
|
||||||
|
ServiceExtantRequest --> SourceSelector
|
||||||
|
CollectionListRequest --> SourceSelector
|
||||||
|
MessageListRequest --> SourceSelector
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hierarchical Structure
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[SourceSelector] --> B[Provider Level]
|
||||||
|
B --> C[Service Level]
|
||||||
|
C --> D[Collection Level]
|
||||||
|
D --> E[Message Level]
|
||||||
|
|
||||||
|
B --> B1[ProviderInterface]
|
||||||
|
B --> B2[ProviderCapabilities]
|
||||||
|
|
||||||
|
C --> C1[ServiceInterface]
|
||||||
|
C --> C2[ServiceCapabilities]
|
||||||
|
|
||||||
|
D --> D1[CollectionInterface]
|
||||||
|
D --> D2[CollectionCapabilities]
|
||||||
|
|
||||||
|
E --> E1[MessageInterface]
|
||||||
|
E --> E2[MessageHeaders]
|
||||||
|
E --> E3[MessagePart]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request/Response Pattern
|
||||||
|
|
||||||
|
All operations follow a consistent request/response pattern:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant API
|
||||||
|
participant Provider
|
||||||
|
|
||||||
|
Client->>API: {Operation}Request
|
||||||
|
API->>Provider: Process Request
|
||||||
|
Provider->>API: Data
|
||||||
|
API->>Client: {Operation}Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operations by Level:
|
||||||
|
|
||||||
|
**Provider Level:**
|
||||||
|
- List, Fetch, Extant
|
||||||
|
|
||||||
|
**Service Level:**
|
||||||
|
- List, Fetch, Extant, Create, Modify, Destroy
|
||||||
|
|
||||||
|
**Collection Level:**
|
||||||
|
- List, Fetch, Extant, Create, Modify, Destroy
|
||||||
|
|
||||||
|
**Message Level:**
|
||||||
|
- List, Fetch, Extant, Create, Modify, Destroy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capability Inheritance
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[ProviderCapabilities] -->|enables| B[ServiceCapabilities]
|
||||||
|
B -->|enables| C[CollectionCapabilities]
|
||||||
|
C -->|enables| D[Message Operations]
|
||||||
|
```
|
||||||
|
|
||||||
|
Capabilities cascade down the hierarchy - if a provider doesn't support `ServiceList`, then no services can be listed for that provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### 1. **Extends Pattern**
|
||||||
|
Response interfaces extend their base interface:
|
||||||
|
- `ProviderFetchResponse extends ProviderInterface`
|
||||||
|
- `ServiceFetchResponse extends ServiceInterface`
|
||||||
|
|
||||||
|
### 2. **Dictionary Pattern**
|
||||||
|
List responses use identifier as key:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
[identifier: string]: Interface
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **SourceSelector Pattern**
|
||||||
|
Resources are selected hierarchically:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
provider: "gmail",
|
||||||
|
service: "user@example.com",
|
||||||
|
collection: "INBOX",
|
||||||
|
message: "msg123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Recursive Structure**
|
||||||
|
MessagePart can contain subParts:
|
||||||
|
```typescript
|
||||||
|
MessagePartInterface {
|
||||||
|
subParts?: MessagePartInterface[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Selecting a specific message:
|
||||||
|
```typescript
|
||||||
|
const selector: SourceSelector = {
|
||||||
|
provider: "gmail",
|
||||||
|
service: "user@example.com",
|
||||||
|
collection: "INBOX",
|
||||||
|
message: "12345"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing all services for a provider:
|
||||||
|
```typescript
|
||||||
|
const request: ServiceListRequest = {
|
||||||
|
sources: {
|
||||||
|
provider: "gmail"
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
offset: 0,
|
||||||
|
limit: 50
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interface Files
|
||||||
|
|
||||||
|
- `common.ts` - Base types and selectors
|
||||||
|
- `provider.ts` - Provider-level interfaces
|
||||||
|
- `service.ts` - Service-level interfaces
|
||||||
|
- `collection.ts` - Collection-level interfaces
|
||||||
|
- `message.ts` - Message-level interfaces
|
||||||
715
lib/Controllers/DefaultController.php
Normal file
715
lib/Controllers/DefaultController.php
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Controllers;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\SessionIdentity;
|
||||||
|
use KTXC\SessionTenant;
|
||||||
|
use KTXF\Controller\ControllerAbstract;
|
||||||
|
use KTXF\Mail\Entity\Message;
|
||||||
|
use KTXF\Mail\Queue\SendOptions;
|
||||||
|
use KTXF\Resource\Selector\SourceSelector;
|
||||||
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
use KTXM\MailManager\Manager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Controller - Unified Mail API
|
||||||
|
*
|
||||||
|
* Handles all mail operations in JMAP-style API pattern.
|
||||||
|
* Supports both single operations and batches with result references.
|
||||||
|
*/
|
||||||
|
class DefaultController extends ControllerAbstract {
|
||||||
|
|
||||||
|
// Error message constants
|
||||||
|
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
|
||||||
|
private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier';
|
||||||
|
private const ERR_MISSING_SERVICE = 'Missing parameter: service';
|
||||||
|
private const ERR_MISSING_COLLECTION = 'Missing parameter: collection';
|
||||||
|
private const ERR_MISSING_DATA = 'Missing parameter: data';
|
||||||
|
private const ERR_MISSING_SOURCES = 'Missing parameter: sources';
|
||||||
|
private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers';
|
||||||
|
private const ERR_MISSING_MESSAGE = 'Missing parameter: message';
|
||||||
|
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string';
|
||||||
|
private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string';
|
||||||
|
private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string';
|
||||||
|
private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer';
|
||||||
|
private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array';
|
||||||
|
private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array';
|
||||||
|
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
||||||
|
private const ERR_INVALID_MESSAGE = 'Invalid parameter: message must be an array';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionTenant $tenantIdentity,
|
||||||
|
private readonly SessionIdentity $userIdentity,
|
||||||
|
private Manager $mailManager,
|
||||||
|
private readonly LoggerInterface $logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main API endpoint for mail operations
|
||||||
|
*
|
||||||
|
* Single operation:
|
||||||
|
* { "version": 1, "transaction": "tx-1", "operation": "message.send", "data": {...} }
|
||||||
|
*
|
||||||
|
* Batch operations:
|
||||||
|
* { "version": 1, "transaction": "tx-1", "operations": [
|
||||||
|
* {"id": "op1", "operation": "message.send", "data": {...}},
|
||||||
|
* {"id": "op2", "operation": "message.destroy", "data": {"collection": "#op1.draftId"}}
|
||||||
|
* ]}
|
||||||
|
*
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])]
|
||||||
|
public function index(
|
||||||
|
int $version,
|
||||||
|
string $transaction,
|
||||||
|
string|null $operation = null,
|
||||||
|
array|null $data = null,
|
||||||
|
array|null $operations = null,
|
||||||
|
string|null $user = null
|
||||||
|
): JsonResponse {
|
||||||
|
|
||||||
|
// authorize request
|
||||||
|
$tenantId = $this->tenantIdentity->identifier();
|
||||||
|
$userId = $this->userIdentity->identifier();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Single operation mode
|
||||||
|
if ($operation !== null) {
|
||||||
|
$result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []);
|
||||||
|
return new JsonResponse([
|
||||||
|
'version' => $version,
|
||||||
|
'transaction' => $transaction,
|
||||||
|
'operation' => $operation,
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $result
|
||||||
|
], JsonResponse::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch operations mode
|
||||||
|
if ($operations !== null && is_array($operations)) {
|
||||||
|
$results = $this->processBatch($tenantId, $userId, $operations);
|
||||||
|
return new JsonResponse([
|
||||||
|
'version' => $version,
|
||||||
|
'transaction' => $transaction,
|
||||||
|
'status' => 'success',
|
||||||
|
'operations' => $results
|
||||||
|
], JsonResponse::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Either operation or operations must be provided');
|
||||||
|
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
$this->logger->error('Error processing mail manager request', ['exception' => $t]);
|
||||||
|
return new JsonResponse([
|
||||||
|
'version' => $version,
|
||||||
|
'transaction' => $transaction,
|
||||||
|
'operation' => $operation,
|
||||||
|
'status' => 'error',
|
||||||
|
'data' => [
|
||||||
|
'code' => $t->getCode(),
|
||||||
|
'message' => $t->getMessage()
|
||||||
|
]
|
||||||
|
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process batch operations with result references
|
||||||
|
*/
|
||||||
|
private function processBatch(string $tenantId, string $userId, array $operations): array {
|
||||||
|
$results = [];
|
||||||
|
$resultMap = []; // Store results by operation ID for references
|
||||||
|
|
||||||
|
foreach ($operations as $index => $op) {
|
||||||
|
$opId = $op['id'] ?? "op{$index}";
|
||||||
|
$operation = $op['operation'] ?? null;
|
||||||
|
$data = $op['data'] ?? [];
|
||||||
|
|
||||||
|
if ($operation === null) {
|
||||||
|
$results[] = [
|
||||||
|
'id' => $opId,
|
||||||
|
'status' => 'error',
|
||||||
|
'data' => ['message' => 'Missing operation name']
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve result references in data (e.g., "#op1.id")
|
||||||
|
$data = $this->resolveReferences($data, $resultMap);
|
||||||
|
|
||||||
|
$result = $this->processOperation($tenantId, $userId, $operation, $data, $resultMap);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'id' => $opId,
|
||||||
|
'operation' => $operation,
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $result
|
||||||
|
];
|
||||||
|
|
||||||
|
// Store result for future references
|
||||||
|
$resultMap[$opId] = $result;
|
||||||
|
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
$this->logger->warning('Batch operation failed', [
|
||||||
|
'operation' => $operation,
|
||||||
|
'opId' => $opId,
|
||||||
|
'error' => $t->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'id' => $opId,
|
||||||
|
'operation' => $operation,
|
||||||
|
'status' => 'error',
|
||||||
|
'data' => [
|
||||||
|
'code' => $t->getCode(),
|
||||||
|
'message' => $t->getMessage()
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve result references in operation data
|
||||||
|
*
|
||||||
|
* Transforms "#op1.id" into the actual value from previous operation results
|
||||||
|
*/
|
||||||
|
private function resolveReferences(mixed $data, array $resultMap): mixed {
|
||||||
|
if (is_string($data) && str_starts_with($data, '#')) {
|
||||||
|
// Parse reference like "#op1.id" or "#op1.collection.id"
|
||||||
|
$parts = explode('.', substr($data, 1));
|
||||||
|
$opId = array_shift($parts);
|
||||||
|
|
||||||
|
if (!isset($resultMap[$opId])) {
|
||||||
|
throw new InvalidArgumentException("Reference to undefined operation: #{$opId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $resultMap[$opId];
|
||||||
|
foreach ($parts as $key) {
|
||||||
|
if (is_array($value) && isset($value[$key])) {
|
||||||
|
$value = $value[$key];
|
||||||
|
} elseif (is_object($value) && isset($value->$key)) {
|
||||||
|
$value = $value->$key;
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException("Invalid reference path: {$data}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($data)) {
|
||||||
|
return array_map(fn($item) => $this->resolveReferences($item, $resultMap), $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single operation
|
||||||
|
*/
|
||||||
|
private function processOperation(string $tenantId, string $userId, string $operation, array $data, array $resultMap): mixed {
|
||||||
|
return match ($operation) {
|
||||||
|
// Provider operations
|
||||||
|
'provider.list' => $this->providerList($tenantId, $userId, $data),
|
||||||
|
'provider.extant' => $this->providerExtant($tenantId, $userId, $data),
|
||||||
|
|
||||||
|
// Service operations
|
||||||
|
'service.list' => $this->serviceList($tenantId, $userId, $data),
|
||||||
|
'service.extant' => $this->serviceExtant($tenantId, $userId, $data),
|
||||||
|
'service.fetch' => $this->serviceFetch($tenantId, $userId, $data),
|
||||||
|
'service.discover' => $this->serviceDiscover($tenantId, $userId, $data),
|
||||||
|
'service.test' => $this->serviceTest($tenantId, $userId, $data),
|
||||||
|
'service.create' => $this->serviceCreate($tenantId, $userId, $data),
|
||||||
|
'service.update' => $this->serviceUpdate($tenantId, $userId, $data),
|
||||||
|
'service.delete' => $this->serviceDelete($tenantId, $userId, $data),
|
||||||
|
|
||||||
|
// Collection operations
|
||||||
|
'collection.list' => $this->collectionList($tenantId, $userId, $data),
|
||||||
|
'collection.extant' => $this->collectionExtant($tenantId, $userId, $data),
|
||||||
|
'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data),
|
||||||
|
'collection.create' => $this->collectionCreate($tenantId, $userId, $data),
|
||||||
|
'collection.modify' => $this->collectionModify($tenantId, $userId, $data),
|
||||||
|
'collection.destroy' => $this->collectionDestroy($tenantId, $userId, $data),
|
||||||
|
|
||||||
|
// Entity operations
|
||||||
|
'entity.list' => $this->entityList($tenantId, $userId, $data),
|
||||||
|
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
|
||||||
|
'entity.extant' => $this->entityExtant($tenantId, $userId, $data),
|
||||||
|
'entity.fetch' => $this->entityFetch($tenantId, $userId, $data),
|
||||||
|
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
|
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
|
'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
|
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
|
||||||
|
|
||||||
|
default => throw new InvalidArgumentException('Unknown operation: ' . $operation)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Operations ====================
|
||||||
|
|
||||||
|
private function providerList(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
$sources = null;
|
||||||
|
if (isset($data['sources']) && is_array($data['sources'])) {
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->providerList($tenantId, $userId, $sources);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private function providerExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->providerExtant($tenantId, $userId, $sources);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Service Operations =====================
|
||||||
|
|
||||||
|
private function serviceList(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
$sources = null;
|
||||||
|
if (isset($data['sources']) && is_array($data['sources'])) {
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceList($tenantId, $userId, $sources);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->serviceExtant($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceDiscover(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $data['provider'] ?? null;
|
||||||
|
$identity = $data['identity'];
|
||||||
|
$location = $data['location'] ?? null;
|
||||||
|
$secret = $data['secret'] ?? null;
|
||||||
|
|
||||||
|
return $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceTest(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) {
|
||||||
|
throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceTest(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['identifier'] ?? null,
|
||||||
|
$data['location'] ?? null,
|
||||||
|
$data['identity'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['data'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['data'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceCreate(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['data']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceUpdate(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!isset($data['data'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['data'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceUpdate(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['identifier'],
|
||||||
|
$data['data']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceDelete(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceDelete(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['identifier']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Collection Operations ====================
|
||||||
|
|
||||||
|
private function collectionList(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
$sources = null;
|
||||||
|
if (isset($data['sources']) && is_array($data['sources'])) {
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = $data['filter'] ?? null;
|
||||||
|
$sort = $data['sort'] ?? null;
|
||||||
|
|
||||||
|
return $this->mailManager->collectionList($tenantId, $userId, $sources, $filter, $sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectionExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->collectionExtant($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectionFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier']) && !is_int($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->collectionFetch(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['identifier']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectionCreate(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
if (isset($data['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION);
|
||||||
|
}
|
||||||
|
if (!isset($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->collectionCreate(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['collection'] ?? null,
|
||||||
|
$data['properties']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectionModify(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier']) && !is_int($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION);
|
||||||
|
}
|
||||||
|
if (!isset($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
||||||
|
}
|
||||||
|
if (!is_array($data['properties'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->collectionModify(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['identifier'],
|
||||||
|
$data['properties']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectionDestroy(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier']) && !is_int($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->collectionDestroy(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['identifier'],
|
||||||
|
$data['options'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Entity Operations ====================
|
||||||
|
|
||||||
|
private function entityList(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
$filter = $data['filter'] ?? null;
|
||||||
|
$sort = $data['sort'] ?? null;
|
||||||
|
$range = $data['range'] ?? null;
|
||||||
|
|
||||||
|
return $this->mailManager->entityList($tenantId, $userId, $sources, $filter, $sort, $range);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityDelta(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->entityDelta($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->entityExtant($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
if (!isset($data['collection'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION);
|
||||||
|
}
|
||||||
|
if (!is_string($data['collection']) && !is_int($data['collection'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION);
|
||||||
|
}
|
||||||
|
if (!isset($data['identifiers'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS);
|
||||||
|
}
|
||||||
|
if (!is_array($data['identifiers'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->entityFetch(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['collection'],
|
||||||
|
$data['identifiers']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityTransmit(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!isset($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
|
||||||
|
}
|
||||||
|
if (!is_string($data['service'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $this->mailManager->entityTransmit(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['service'],
|
||||||
|
$data['data']
|
||||||
|
);
|
||||||
|
|
||||||
|
return ['jobId' => $jobId];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
237
lib/Daemon/MailDaemon.php
Normal file
237
lib/Daemon/MailDaemon.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Daemon;
|
||||||
|
|
||||||
|
use KTXM\MailManager\Manager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Queue Daemon
|
||||||
|
*
|
||||||
|
* Long-running worker process for processing queued mail messages.
|
||||||
|
* Supports graceful shutdown via signals and configurable batch processing.
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
class MailDaemon {
|
||||||
|
|
||||||
|
private bool $running = false;
|
||||||
|
private bool $shutdown = false;
|
||||||
|
private bool $reload = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Manager $manager Mail manager
|
||||||
|
* @param LoggerInterface $logger Logger
|
||||||
|
* @param int $pollInterval Seconds between queue polls when idle
|
||||||
|
* @param int $batchSize Messages to process per batch
|
||||||
|
* @param int|null $maxMemory Maximum memory usage in bytes before restart
|
||||||
|
* @param array<string>|null $tenants Specific tenants to process (null = all)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private Manager $manager,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private int $pollInterval = 5,
|
||||||
|
private int $batchSize = 50,
|
||||||
|
private ?int $maxMemory = null,
|
||||||
|
private ?array $tenants = null,
|
||||||
|
) {
|
||||||
|
// Set default max memory to 128MB
|
||||||
|
$this->maxMemory = $this->maxMemory ?? (128 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the daemon main loop
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
public function run(): void {
|
||||||
|
$this->running = true;
|
||||||
|
$this->shutdown = false;
|
||||||
|
|
||||||
|
$this->setupSignalHandlers();
|
||||||
|
|
||||||
|
$this->logger->info('Mail daemon starting', [
|
||||||
|
'pollInterval' => $this->pollInterval,
|
||||||
|
'batchSize' => $this->batchSize,
|
||||||
|
'maxMemory' => $this->formatBytes($this->maxMemory),
|
||||||
|
'tenants' => $this->tenants ?? 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consecutiveEmpty = 0;
|
||||||
|
|
||||||
|
while (!$this->shutdown) {
|
||||||
|
// Handle reload signal
|
||||||
|
if ($this->reload) {
|
||||||
|
$this->handleReload();
|
||||||
|
$this->reload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check memory usage
|
||||||
|
if ($this->isMemoryExceeded()) {
|
||||||
|
$this->logger->warning('Memory limit exceeded, shutting down for restart', [
|
||||||
|
'current' => $this->formatBytes(memory_get_usage(true)),
|
||||||
|
'limit' => $this->formatBytes($this->maxMemory),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process queues
|
||||||
|
$processed = $this->processTenants();
|
||||||
|
|
||||||
|
if ($processed === 0) {
|
||||||
|
$consecutiveEmpty++;
|
||||||
|
// Exponential backoff up to poll interval
|
||||||
|
$sleepTime = min($consecutiveEmpty, $this->pollInterval);
|
||||||
|
$this->sleep($sleepTime);
|
||||||
|
} else {
|
||||||
|
$consecutiveEmpty = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch pending signals
|
||||||
|
pcntl_signal_dispatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Mail daemon stopped');
|
||||||
|
$this->running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request graceful shutdown
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
public function stop(): void {
|
||||||
|
$this->shutdown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if daemon is running
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isRunning(): bool {
|
||||||
|
return $this->running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all tenants and return total messages processed
|
||||||
|
*/
|
||||||
|
private function processTenants(): int {
|
||||||
|
$totalProcessed = 0;
|
||||||
|
$tenants = $this->tenants ?? $this->discoverTenants();
|
||||||
|
|
||||||
|
foreach ($tenants as $tenantId) {
|
||||||
|
if ($this->shutdown) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->manager->queueProcess($tenantId, $this->batchSize);
|
||||||
|
$totalProcessed += $result['processed'] + $result['failed'];
|
||||||
|
|
||||||
|
if ($result['processed'] > 0 || $result['failed'] > 0) {
|
||||||
|
$this->logger->debug('Processed tenant queue', [
|
||||||
|
'tenant' => $tenantId,
|
||||||
|
'processed' => $result['processed'],
|
||||||
|
'failed' => $result['failed'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all tenants with mail queues
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
private function discoverTenants(): array {
|
||||||
|
// This would need to be implemented based on your tenant discovery mechanism
|
||||||
|
// For now, return empty array - specific tenants should be configured
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup signal handlers for graceful shutdown
|
||||||
|
*/
|
||||||
|
private function setupSignalHandlers(): void {
|
||||||
|
if (!function_exists('pcntl_signal')) {
|
||||||
|
$this->logger->warning('PCNTL extension not available, signal handling disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pcntl_signal(SIGTERM, function() {
|
||||||
|
$this->logger->info('Received SIGTERM, initiating graceful shutdown');
|
||||||
|
$this->shutdown = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
pcntl_signal(SIGINT, function() {
|
||||||
|
$this->logger->info('Received SIGINT, initiating graceful shutdown');
|
||||||
|
$this->shutdown = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
pcntl_signal(SIGHUP, function() {
|
||||||
|
$this->logger->info('Received SIGHUP, will reload configuration');
|
||||||
|
$this->reload = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle configuration reload
|
||||||
|
*/
|
||||||
|
private function handleReload(): void {
|
||||||
|
$this->logger->info('Reloading configuration');
|
||||||
|
// Configuration reload logic would go here
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if memory limit has been exceeded
|
||||||
|
*/
|
||||||
|
private function isMemoryExceeded(): bool {
|
||||||
|
if ($this->maxMemory === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return memory_get_usage(true) > $this->maxMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep with signal dispatch
|
||||||
|
*/
|
||||||
|
private function sleep(int $seconds): void {
|
||||||
|
for ($i = 0; $i < $seconds; $i++) {
|
||||||
|
if ($this->shutdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sleep(1);
|
||||||
|
pcntl_signal_dispatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable string
|
||||||
|
*/
|
||||||
|
private function formatBytes(?int $bytes): string {
|
||||||
|
if ($bytes === null) {
|
||||||
|
return 'unlimited';
|
||||||
|
}
|
||||||
|
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
$i = 0;
|
||||||
|
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
return round($bytes, 2) . ' ' . $units[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
246
lib/Daemon/MailQueueCli.php
Normal file
246
lib/Daemon/MailQueueCli.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Daemon;
|
||||||
|
|
||||||
|
use KTXM\MailManager\Queue\JobStatus;
|
||||||
|
use KTXM\MailManager\Queue\MailQueue;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Queue CLI
|
||||||
|
*
|
||||||
|
* Command-line interface for mail queue management operations.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php mail-queue.php list <tenant> [--status=pending]
|
||||||
|
* php mail-queue.php retry <jobId>
|
||||||
|
* php mail-queue.php retry-all <tenant> --status=failed
|
||||||
|
* php mail-queue.php purge <tenant> --status=complete --older-than=7d
|
||||||
|
* php mail-queue.php stats <tenant>
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
class MailQueueCli {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private MailQueue $queue,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run CLI command
|
||||||
|
*
|
||||||
|
* @param array<string> $args Command line arguments
|
||||||
|
*
|
||||||
|
* @return int Exit code
|
||||||
|
*/
|
||||||
|
public function run(array $args): int {
|
||||||
|
$command = $args[1] ?? 'help';
|
||||||
|
|
||||||
|
return match($command) {
|
||||||
|
'list' => $this->commandList($args),
|
||||||
|
'retry' => $this->commandRetry($args),
|
||||||
|
'retry-all' => $this->commandRetryAll($args),
|
||||||
|
'purge' => $this->commandPurge($args),
|
||||||
|
'stats' => $this->commandStats($args),
|
||||||
|
'help', '--help', '-h' => $this->commandHelp(),
|
||||||
|
default => $this->commandHelp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List jobs in queue
|
||||||
|
*/
|
||||||
|
private function commandList(array $args): int {
|
||||||
|
$tenantId = $args[2] ?? null;
|
||||||
|
if ($tenantId === null) {
|
||||||
|
echo "Error: tenant ID required\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->parseOption($args, 'status');
|
||||||
|
$statusEnum = $status !== null ? JobStatus::tryFrom($status) : null;
|
||||||
|
$limit = (int)($this->parseOption($args, 'limit') ?? 100);
|
||||||
|
|
||||||
|
$jobs = $this->queue->listJobs($tenantId, $statusEnum, $limit);
|
||||||
|
|
||||||
|
if (empty($jobs)) {
|
||||||
|
echo "No jobs found\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("%-36s %-12s %-8s %-20s %s\n",
|
||||||
|
'JOB ID', 'STATUS', 'ATTEMPTS', 'CREATED', 'SUBJECT');
|
||||||
|
echo str_repeat('-', 100) . "\n";
|
||||||
|
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
$subject = substr($job->message->getSubject(), 0, 30);
|
||||||
|
echo sprintf("%-36s %-12s %-8d %-20s %s\n",
|
||||||
|
$job->id,
|
||||||
|
$job->status->value,
|
||||||
|
$job->attempts,
|
||||||
|
$job->created?->format('Y-m-d H:i:s') ?? '-',
|
||||||
|
$subject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a specific job
|
||||||
|
*/
|
||||||
|
private function commandRetry(array $args): int {
|
||||||
|
$jobId = $args[2] ?? null;
|
||||||
|
if ($jobId === null) {
|
||||||
|
echo "Error: job ID required\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->queue->retry($jobId)) {
|
||||||
|
echo "Job $jobId queued for retry\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Failed to retry job $jobId (not found or not failed)\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry all failed jobs for a tenant
|
||||||
|
*/
|
||||||
|
private function commandRetryAll(array $args): int {
|
||||||
|
$tenantId = $args[2] ?? null;
|
||||||
|
if ($tenantId === null) {
|
||||||
|
echo "Error: tenant ID required\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = $this->queue->listJobs($tenantId, JobStatus::Failed);
|
||||||
|
$retried = 0;
|
||||||
|
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
if ($this->queue->retry($job->id)) {
|
||||||
|
$retried++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Retried $retried jobs\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge old jobs
|
||||||
|
*/
|
||||||
|
private function commandPurge(array $args): int {
|
||||||
|
$tenantId = $args[2] ?? null;
|
||||||
|
if ($tenantId === null) {
|
||||||
|
echo "Error: tenant ID required\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->parseOption($args, 'status') ?? 'complete';
|
||||||
|
$statusEnum = JobStatus::tryFrom($status);
|
||||||
|
if ($statusEnum === null) {
|
||||||
|
echo "Error: invalid status '$status'\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$olderThan = $this->parseOption($args, 'older-than') ?? '7d';
|
||||||
|
$seconds = $this->parseDuration($olderThan);
|
||||||
|
|
||||||
|
$purged = $this->queue->purge($tenantId, $statusEnum, $seconds);
|
||||||
|
echo "Purged $purged jobs\n";
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show queue statistics
|
||||||
|
*/
|
||||||
|
private function commandStats(array $args): int {
|
||||||
|
$tenantId = $args[2] ?? null;
|
||||||
|
if ($tenantId === null) {
|
||||||
|
echo "Error: tenant ID required\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $this->queue->stats($tenantId);
|
||||||
|
|
||||||
|
echo "Queue Statistics for $tenantId:\n";
|
||||||
|
echo " Pending: {$stats['pending']}\n";
|
||||||
|
echo " Processing: {$stats['processing']}\n";
|
||||||
|
echo " Complete: {$stats['complete']}\n";
|
||||||
|
echo " Failed: {$stats['failed']}\n";
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show help message
|
||||||
|
*/
|
||||||
|
private function commandHelp(): int {
|
||||||
|
echo <<<HELP
|
||||||
|
Mail Queue CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
mail-queue <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
list <tenant> List jobs in queue
|
||||||
|
--status=<status> Filter by status (pending, processing, complete, failed)
|
||||||
|
--limit=<n> Maximum jobs to show (default: 100)
|
||||||
|
|
||||||
|
retry <jobId> Retry a specific failed job
|
||||||
|
|
||||||
|
retry-all <tenant> Retry all failed jobs for a tenant
|
||||||
|
|
||||||
|
purge <tenant> Purge old jobs
|
||||||
|
--status=<status> Status to purge (default: complete)
|
||||||
|
--older-than=<duration> Age threshold (default: 7d, e.g., 1h, 30d)
|
||||||
|
|
||||||
|
stats <tenant> Show queue statistics
|
||||||
|
|
||||||
|
help Show this help message
|
||||||
|
|
||||||
|
HELP;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a command line option
|
||||||
|
*/
|
||||||
|
private function parseOption(array $args, string $name): ?string {
|
||||||
|
foreach ($args as $arg) {
|
||||||
|
if (str_starts_with($arg, "--$name=")) {
|
||||||
|
return substr($arg, strlen("--$name="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a duration string to seconds
|
||||||
|
*/
|
||||||
|
private function parseDuration(string $duration): int {
|
||||||
|
preg_match('/^(\d+)([smhd])?$/', $duration, $matches);
|
||||||
|
$value = (int)($matches[1] ?? 0);
|
||||||
|
$unit = $matches[2] ?? 's';
|
||||||
|
|
||||||
|
return match($unit) {
|
||||||
|
's' => $value,
|
||||||
|
'm' => $value * 60,
|
||||||
|
'h' => $value * 3600,
|
||||||
|
'd' => $value * 86400,
|
||||||
|
default => $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
1036
lib/Manager.php
Normal file
1036
lib/Manager.php
Normal file
File diff suppressed because it is too large
Load Diff
67
lib/Module.php
Normal file
67
lib/Module.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXM\MailManager;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Manager Module
|
||||||
|
*
|
||||||
|
* Provides unified mail sending and management across multiple providers
|
||||||
|
* with context-aware service discovery and queued delivery.
|
||||||
|
*/
|
||||||
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public function handle(): string
|
||||||
|
{
|
||||||
|
return 'mail_manager';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Mail Manager';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Mail management module for Ktrix - provides unified mail sending with provider abstraction and queue support';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mail_manager' => [
|
||||||
|
'label' => 'Access Mail Manager',
|
||||||
|
'description' => 'View and access the mail manager module',
|
||||||
|
'group' => 'Mail Management'
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBI(): array {
|
||||||
|
return [
|
||||||
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'MailManager',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
lib/Queue/JobStatus.php
Normal file
32
lib/Queue/JobStatus.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Queue;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Job Status
|
||||||
|
*
|
||||||
|
* Status states for queued mail jobs.
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
enum JobStatus: string implements JsonSerializable {
|
||||||
|
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Processing = 'processing';
|
||||||
|
case Complete = 'complete';
|
||||||
|
case Failed = 'failed';
|
||||||
|
|
||||||
|
public function jsonSerialize(): string {
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
115
lib/Queue/MailJob.php
Normal file
115
lib/Queue/MailJob.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Queue;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use KTXF\Mail\Entity\IMessageMutable;
|
||||||
|
use KTXF\Mail\Queue\SendOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Job
|
||||||
|
*
|
||||||
|
* Represents a queued mail job with metadata and message content.
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
class MailJob {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly string $tenantId,
|
||||||
|
public readonly string $providerId,
|
||||||
|
public readonly string|int $serviceId,
|
||||||
|
public readonly IMessageMutable $message,
|
||||||
|
public readonly SendOptions $options,
|
||||||
|
public JobStatus $status = JobStatus::Pending,
|
||||||
|
public int $attempts = 0,
|
||||||
|
public ?string $lastError = null,
|
||||||
|
public ?string $messageId = null,
|
||||||
|
public ?DateTimeImmutable $created = null,
|
||||||
|
public ?DateTimeImmutable $scheduled = null,
|
||||||
|
public ?DateTimeImmutable $lastAttempt = null,
|
||||||
|
public ?DateTimeImmutable $completed = null,
|
||||||
|
) {
|
||||||
|
$this->created = $this->created ?? new DateTimeImmutable();
|
||||||
|
$this->scheduled = $this->scheduled ?? $this->calculateScheduledTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate when this job should be processed
|
||||||
|
*
|
||||||
|
* @return DateTimeImmutable
|
||||||
|
*/
|
||||||
|
private function calculateScheduledTime(): DateTimeImmutable {
|
||||||
|
$scheduled = $this->created ?? new DateTimeImmutable();
|
||||||
|
|
||||||
|
if ($this->options->delaySeconds !== null && $this->options->delaySeconds > 0) {
|
||||||
|
$scheduled = $scheduled->modify("+{$this->options->delaySeconds} seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the job is ready to be processed
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isReady(): bool {
|
||||||
|
if ($this->status !== JobStatus::Pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->scheduled === null || $this->scheduled <= new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the job can be retried
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function canRetry(): bool {
|
||||||
|
return $this->attempts < $this->options->retryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retry delay in seconds based on attempt count (exponential backoff)
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getRetryDelay(): int {
|
||||||
|
// Exponential backoff: 30s, 60s, 120s, 240s, ...
|
||||||
|
return min(30 * (2 ** $this->attempts), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize job metadata for storage
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toMetaArray(): array {
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'providerId' => $this->providerId,
|
||||||
|
'serviceId' => $this->serviceId,
|
||||||
|
'options' => $this->options->jsonSerialize(),
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'attempts' => $this->attempts,
|
||||||
|
'lastError' => $this->lastError,
|
||||||
|
'messageId' => $this->messageId,
|
||||||
|
'created' => $this->created?->format('c'),
|
||||||
|
'scheduled' => $this->scheduled?->format('c'),
|
||||||
|
'lastAttempt' => $this->lastAttempt?->format('c'),
|
||||||
|
'completed' => $this->completed?->format('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
492
lib/Queue/MailQueueFile.php
Normal file
492
lib/Queue/MailQueueFile.php
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\MailManager\Queue;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DI\Attribute\Inject;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use KTXF\Mail\Entity\IMessageMutable;
|
||||||
|
use KTXF\Mail\Entity\Message;
|
||||||
|
use KTXF\Mail\Queue\SendOptions;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-Based Mail Queue
|
||||||
|
*
|
||||||
|
* Stores mail queue jobs on disk with atomic operations using file locks.
|
||||||
|
*
|
||||||
|
* Structure:
|
||||||
|
* storage/{tenantId}/mail/queue/
|
||||||
|
* pending/{jobId}/
|
||||||
|
* meta.json
|
||||||
|
* message.json
|
||||||
|
* processing/{jobId}/...
|
||||||
|
* complete/{jobId}/...
|
||||||
|
* failed/{jobId}/...
|
||||||
|
*
|
||||||
|
* @since 2025.05.01
|
||||||
|
*/
|
||||||
|
class MailQueueFile {
|
||||||
|
|
||||||
|
private const DIR_PENDING = 'pending';
|
||||||
|
private const DIR_PROCESSING = 'processing';
|
||||||
|
private const DIR_COMPLETE = 'complete';
|
||||||
|
private const DIR_FAILED = 'failed';
|
||||||
|
private string $storagePath;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
#[Inject('rootDir')] private readonly string $rootDir,
|
||||||
|
) {
|
||||||
|
$this->storagePath = $this->rootDir . '/var/cache/mail_manager/queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function enqueue(
|
||||||
|
string $tenantId,
|
||||||
|
string $providerId,
|
||||||
|
string|int $serviceId,
|
||||||
|
IMessageMutable $message,
|
||||||
|
SendOptions $options
|
||||||
|
): string {
|
||||||
|
$jobId = $this->generateJobId();
|
||||||
|
|
||||||
|
$job = new MailJob(
|
||||||
|
id: $jobId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
providerId: $providerId,
|
||||||
|
serviceId: $serviceId,
|
||||||
|
message: $message,
|
||||||
|
options: $options,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->writeJob($job, self::DIR_PENDING);
|
||||||
|
|
||||||
|
return $jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function dequeue(string $tenantId, int $limit = 50): array {
|
||||||
|
$pendingDir = $this->getQueueDir($tenantId, self::DIR_PENDING);
|
||||||
|
|
||||||
|
if (!is_dir($pendingDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
$entries = scandir($pendingDir);
|
||||||
|
|
||||||
|
// Sort by priority (read meta files)
|
||||||
|
$jobsWithPriority = [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobDir = $pendingDir . '/' . $entry;
|
||||||
|
if (!is_dir($jobDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metaFile = $jobDir . '/meta.json';
|
||||||
|
if (!file_exists($metaFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode(file_get_contents($metaFile), true);
|
||||||
|
if ($meta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduled = isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null;
|
||||||
|
if ($scheduled !== null && $scheduled > new DateTimeImmutable()) {
|
||||||
|
continue; // Not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobsWithPriority[] = [
|
||||||
|
'id' => $entry,
|
||||||
|
'priority' => $meta['options']['priority'] ?? 0,
|
||||||
|
'created' => $meta['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (desc) then by created (asc)
|
||||||
|
usort($jobsWithPriority, function($a, $b) {
|
||||||
|
if ($a['priority'] !== $b['priority']) {
|
||||||
|
return $b['priority'] <=> $a['priority'];
|
||||||
|
}
|
||||||
|
return $a['created'] <=> $b['created'];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Take up to limit and move to processing
|
||||||
|
$jobsWithPriority = array_slice($jobsWithPriority, 0, $limit);
|
||||||
|
|
||||||
|
foreach ($jobsWithPriority as $jobInfo) {
|
||||||
|
$job = $this->loadJob($tenantId, $jobInfo['id'], self::DIR_PENDING);
|
||||||
|
if ($job === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to processing
|
||||||
|
$this->moveJob($tenantId, $jobInfo['id'], self::DIR_PENDING, self::DIR_PROCESSING);
|
||||||
|
$job->status = JobStatus::Processing;
|
||||||
|
$job->lastAttempt = new DateTimeImmutable();
|
||||||
|
$job->attempts++;
|
||||||
|
$this->updateJobMeta($tenantId, $jobInfo['id'], $job, self::DIR_PROCESSING);
|
||||||
|
|
||||||
|
$jobs[] = $job;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function acknowledge(string $jobId, string $messageId): void {
|
||||||
|
$job = $this->findJobById($jobId);
|
||||||
|
if ($job === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->status = JobStatus::Complete;
|
||||||
|
$job->messageId = $messageId;
|
||||||
|
$job->completed = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_COMPLETE);
|
||||||
|
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_COMPLETE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function reject(string $jobId, string $error, bool $retry = true): void {
|
||||||
|
$job = $this->findJobById($jobId);
|
||||||
|
if ($job === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->lastError = $error;
|
||||||
|
|
||||||
|
if ($retry && $job->canRetry()) {
|
||||||
|
// Move back to pending with delay
|
||||||
|
$job->status = JobStatus::Pending;
|
||||||
|
$job->scheduled = (new DateTimeImmutable())->modify('+' . $job->getRetryDelay() . ' seconds');
|
||||||
|
|
||||||
|
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_PENDING);
|
||||||
|
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING);
|
||||||
|
} else {
|
||||||
|
// Move to failed
|
||||||
|
$job->status = JobStatus::Failed;
|
||||||
|
$job->completed = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->moveJob($job->tenantId, $jobId, self::DIR_PROCESSING, self::DIR_FAILED);
|
||||||
|
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function getJob(string $jobId): ?MailJob {
|
||||||
|
return $this->findJobById($jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function listJobs(string $tenantId, ?JobStatus $status = null, int $limit = 100, int $offset = 0): array {
|
||||||
|
$dirs = $status !== null
|
||||||
|
? [$this->statusToDir($status)]
|
||||||
|
: [self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED];
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
|
||||||
|
foreach ($dirs as $dir) {
|
||||||
|
$queueDir = $this->getQueueDir($tenantId, $dir);
|
||||||
|
if (!is_dir($queueDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($queueDir) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $this->loadJob($tenantId, $entry, $dir);
|
||||||
|
if ($job !== null) {
|
||||||
|
$jobs[] = $job;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created desc
|
||||||
|
usort($jobs, fn($a, $b) => ($b->created?->getTimestamp() ?? 0) <=> ($a->created?->getTimestamp() ?? 0));
|
||||||
|
|
||||||
|
return array_slice($jobs, $offset, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function retry(string $jobId): bool {
|
||||||
|
$job = $this->findJobById($jobId);
|
||||||
|
if ($job === null || $job->status !== JobStatus::Failed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->status = JobStatus::Pending;
|
||||||
|
$job->attempts = 0;
|
||||||
|
$job->lastError = null;
|
||||||
|
$job->scheduled = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->moveJob($job->tenantId, $jobId, self::DIR_FAILED, self::DIR_PENDING);
|
||||||
|
$this->updateJobMeta($job->tenantId, $jobId, $job, self::DIR_PENDING);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function purge(string $tenantId, JobStatus $status, int $olderThanSeconds): int {
|
||||||
|
$dir = $this->statusToDir($status);
|
||||||
|
$queueDir = $this->getQueueDir($tenantId, $dir);
|
||||||
|
|
||||||
|
if (!is_dir($queueDir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$threshold = new DateTimeImmutable("-{$olderThanSeconds} seconds");
|
||||||
|
$purged = 0;
|
||||||
|
|
||||||
|
foreach (scandir($queueDir) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobDir = $queueDir . '/' . $entry;
|
||||||
|
$metaFile = $jobDir . '/meta.json';
|
||||||
|
|
||||||
|
if (!file_exists($metaFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode(file_get_contents($metaFile), true);
|
||||||
|
$completed = isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null;
|
||||||
|
|
||||||
|
if ($completed !== null && $completed < $threshold) {
|
||||||
|
$this->deleteJobDir($jobDir);
|
||||||
|
$purged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $purged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function stats(string $tenantId): array {
|
||||||
|
$stats = [
|
||||||
|
'pending' => 0,
|
||||||
|
'processing' => 0,
|
||||||
|
'complete' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($stats as $status => $_) {
|
||||||
|
$dir = $this->getQueueDir($tenantId, $status);
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
$stats[$status] = count(array_filter(
|
||||||
|
scandir($dir),
|
||||||
|
fn($e) => $e !== '.' && $e !== '..'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique job ID
|
||||||
|
*/
|
||||||
|
private function generateJobId(): string {
|
||||||
|
return sprintf(
|
||||||
|
'%08x-%04x-%04x-%04x-%012x',
|
||||||
|
time(),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffffffffffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the queue directory path for a tenant and status
|
||||||
|
*/
|
||||||
|
private function getQueueDir(string $tenantId, string $status): string {
|
||||||
|
return $this->storagePath . '/' . $tenantId . '/mail/queue/' . $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a job to disk
|
||||||
|
*/
|
||||||
|
private function writeJob(MailJob $job, string $status): void {
|
||||||
|
$jobDir = $this->getQueueDir($job->tenantId, $status) . '/' . $job->id;
|
||||||
|
|
||||||
|
if (!is_dir($jobDir)) {
|
||||||
|
mkdir($jobDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write meta
|
||||||
|
$metaFile = $jobDir . '/meta.json';
|
||||||
|
file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Write message
|
||||||
|
$messageFile = $jobDir . '/message.json';
|
||||||
|
file_put_contents($messageFile, json_encode($job->message, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a job from disk
|
||||||
|
*/
|
||||||
|
private function loadJob(string $tenantId, string $jobId, string $status): ?MailJob {
|
||||||
|
$jobDir = $this->getQueueDir($tenantId, $status) . '/' . $jobId;
|
||||||
|
$metaFile = $jobDir . '/meta.json';
|
||||||
|
$messageFile = $jobDir . '/message.json';
|
||||||
|
|
||||||
|
if (!file_exists($metaFile) || !file_exists($messageFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode(file_get_contents($metaFile), true);
|
||||||
|
$messageData = json_decode(file_get_contents($messageFile), true);
|
||||||
|
|
||||||
|
if ($meta === null || $messageData === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = Message::fromArray($messageData);
|
||||||
|
|
||||||
|
return new MailJob(
|
||||||
|
id: $meta['id'],
|
||||||
|
tenantId: $meta['tenantId'],
|
||||||
|
providerId: $meta['providerId'],
|
||||||
|
serviceId: $meta['serviceId'],
|
||||||
|
message: $message,
|
||||||
|
options: new SendOptions(
|
||||||
|
immediate: $meta['options']['immediate'] ?? false,
|
||||||
|
priority: $meta['options']['priority'] ?? 0,
|
||||||
|
retryCount: $meta['options']['retryCount'] ?? 3,
|
||||||
|
delaySeconds: $meta['options']['delaySeconds'] ?? null,
|
||||||
|
),
|
||||||
|
status: JobStatus::from($meta['status']),
|
||||||
|
attempts: $meta['attempts'] ?? 0,
|
||||||
|
lastError: $meta['lastError'] ?? null,
|
||||||
|
messageId: $meta['messageId'] ?? null,
|
||||||
|
created: isset($meta['created']) ? new DateTimeImmutable($meta['created']) : null,
|
||||||
|
scheduled: isset($meta['scheduled']) ? new DateTimeImmutable($meta['scheduled']) : null,
|
||||||
|
lastAttempt: isset($meta['lastAttempt']) ? new DateTimeImmutable($meta['lastAttempt']) : null,
|
||||||
|
completed: isset($meta['completed']) ? new DateTimeImmutable($meta['completed']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a job between status directories
|
||||||
|
*/
|
||||||
|
private function moveJob(string $tenantId, string $jobId, string $fromStatus, string $toStatus): void {
|
||||||
|
$fromDir = $this->getQueueDir($tenantId, $fromStatus) . '/' . $jobId;
|
||||||
|
$toDir = $this->getQueueDir($tenantId, $toStatus) . '/' . $jobId;
|
||||||
|
|
||||||
|
if (!is_dir($fromDir)) {
|
||||||
|
throw new RuntimeException("Job directory not found: $fromDir");
|
||||||
|
}
|
||||||
|
|
||||||
|
$toParent = dirname($toDir);
|
||||||
|
if (!is_dir($toParent)) {
|
||||||
|
mkdir($toParent, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
rename($fromDir, $toDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update job metadata
|
||||||
|
*/
|
||||||
|
private function updateJobMeta(string $tenantId, string $jobId, MailJob $job, string $status): void {
|
||||||
|
$metaFile = $this->getQueueDir($tenantId, $status) . '/' . $jobId . '/meta.json';
|
||||||
|
file_put_contents($metaFile, json_encode($job->toMetaArray(), JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a job by ID across all status directories
|
||||||
|
*/
|
||||||
|
private function findJobById(string $jobId): ?MailJob {
|
||||||
|
// We need to search across all tenants and statuses
|
||||||
|
// This is inefficient - in production, consider caching or indexing
|
||||||
|
$tenantsDir = $this->storagePath;
|
||||||
|
|
||||||
|
if (!is_dir($tenantsDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($tenantsDir) as $tenantId) {
|
||||||
|
if ($tenantId === '.' || $tenantId === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([self::DIR_PENDING, self::DIR_PROCESSING, self::DIR_COMPLETE, self::DIR_FAILED] as $status) {
|
||||||
|
$job = $this->loadJob($tenantId, $jobId, $status);
|
||||||
|
if ($job !== null) {
|
||||||
|
return $job;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a job directory recursively
|
||||||
|
*/
|
||||||
|
private function deleteJobDir(string $dir): void {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($dir) as $file) {
|
||||||
|
if ($file === '.' || $file === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
is_dir($path) ? $this->deleteJobDir($path) : unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert JobStatus to directory name
|
||||||
|
*/
|
||||||
|
private function statusToDir(JobStatus $status): string {
|
||||||
|
return match($status) {
|
||||||
|
JobStatus::Pending => self::DIR_PENDING,
|
||||||
|
JobStatus::Processing => self::DIR_PROCESSING,
|
||||||
|
JobStatus::Complete => self::DIR_COMPLETE,
|
||||||
|
JobStatus::Failed => self::DIR_FAILED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "mail_manager",
|
||||||
|
"description": "Ktrix Mail Manager Module - Frontend Store",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Ktrix",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
568
src/components/AddAccountDialog.vue
Normal file
568
src/components/AddAccountDialog.vue
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useProvidersStore } from '@MailManager/stores/providersStore'
|
||||||
|
import type { ProviderDiscoveryStatus, ServiceLocation, ServiceIdentity } from '@MailManager/types'
|
||||||
|
import type { ServiceObject } from '@MailManager/models/service'
|
||||||
|
import DiscoveryStatusStep from '@MailManager/components/steps/DiscoveryStatusStep.vue'
|
||||||
|
import ProviderSelectionStep from '@MailManager/components/steps/ProviderSelectionStep.vue'
|
||||||
|
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
|
||||||
|
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
|
||||||
|
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
|
||||||
|
import DiscoveryEntryStep from '@MailManager/components/steps/DiscoveryEntryStep.vue'
|
||||||
|
|
||||||
|
// ==================== Step Constants ====================
|
||||||
|
// Discovery flow: Entry → Discovery → Auth → Test
|
||||||
|
const DISCOVERY_STEPS = {
|
||||||
|
ENTRY: 1,
|
||||||
|
DISCOVERY: 2,
|
||||||
|
AUTH: 3,
|
||||||
|
TEST: 4
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Manual flow: Entry → Provider Select → Config → Auth → Test
|
||||||
|
const MANUAL_STEPS = {
|
||||||
|
ENTRY: 1,
|
||||||
|
PROVIDER_SELECT: 2,
|
||||||
|
CONFIG: 3,
|
||||||
|
AUTH: 4,
|
||||||
|
TEST: 5
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const providersStore = useProvidersStore()
|
||||||
|
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentStep = ref<number>(DISCOVERY_STEPS.ENTRY)
|
||||||
|
const saving = ref(false)
|
||||||
|
const isManualMode = ref(false)
|
||||||
|
|
||||||
|
// Step 1: Entry
|
||||||
|
const discoverAddress = ref<string>('')
|
||||||
|
const discoverSecret = ref<string | null>(null)
|
||||||
|
const discoverHostname = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Step 2: Discovery Status / Provider Selection
|
||||||
|
const selectedProviderId = ref<string | undefined>(undefined)
|
||||||
|
const selectedProviderLabel = ref<string>('')
|
||||||
|
|
||||||
|
// Step 3: Config (manual only) OR Auth (both paths)
|
||||||
|
const configuredLocation = ref<ServiceLocation | null>(null)
|
||||||
|
|
||||||
|
// Step 4: Auth (both paths)
|
||||||
|
const configuredIdentity = ref<ServiceIdentity | null>(null)
|
||||||
|
const authValid = ref(false)
|
||||||
|
|
||||||
|
// Step 5: Test & Save
|
||||||
|
const accountLabel = ref<string>('')
|
||||||
|
const accountEnabled = ref(true)
|
||||||
|
const testAndSaveValid = ref(false)
|
||||||
|
|
||||||
|
// Local discovery state (not stored in global store)
|
||||||
|
const discoveredServices = ref<ServiceObject[]>([])
|
||||||
|
const discoveryStatus = ref<Record<string, ProviderDiscoveryStatus>>({})
|
||||||
|
|
||||||
|
// Load providers when dialog opens
|
||||||
|
watch(dialogOpen, async (isOpen) => {
|
||||||
|
if (isOpen && !providersStore.has) {
|
||||||
|
await providersStore.list()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stepper configuration
|
||||||
|
const stepperItems = computed(() => {
|
||||||
|
if (isManualMode.value) {
|
||||||
|
// Manual: Entry → Selection → Config → Auth → Test
|
||||||
|
return [
|
||||||
|
{ title: 'Email', value: MANUAL_STEPS.ENTRY },
|
||||||
|
{ title: 'Provider', value: MANUAL_STEPS.PROVIDER_SELECT },
|
||||||
|
{ title: 'Protocol', value: MANUAL_STEPS.CONFIG },
|
||||||
|
{ title: 'Authentication', value: MANUAL_STEPS.AUTH },
|
||||||
|
{ title: 'Test & Save', value: MANUAL_STEPS.TEST }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery: Entry → Discovery → Auth → Test
|
||||||
|
if (currentStep.value >= DISCOVERY_STEPS.AUTH) {
|
||||||
|
return [
|
||||||
|
{ title: 'Email', value: DISCOVERY_STEPS.ENTRY },
|
||||||
|
{ title: 'Discovery', value: DISCOVERY_STEPS.DISCOVERY },
|
||||||
|
{ title: 'Authentication', value: DISCOVERY_STEPS.AUTH },
|
||||||
|
{ title: 'Test & Save', value: DISCOVERY_STEPS.TEST }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ title: 'Email', value: DISCOVERY_STEPS.ENTRY },
|
||||||
|
{ title: 'Discovery', value: DISCOVERY_STEPS.DISCOVERY }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSave = computed(() => {
|
||||||
|
return testAndSaveValid.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation button visibility
|
||||||
|
const showNextButton = computed(() => {
|
||||||
|
if (isManualMode.value) {
|
||||||
|
// Manual: Show Next on Config (3) and Auth (4)
|
||||||
|
return currentStep.value === MANUAL_STEPS.CONFIG || currentStep.value === MANUAL_STEPS.AUTH
|
||||||
|
} else {
|
||||||
|
// Discovery: Show Next on Auth (3)
|
||||||
|
return currentStep.value === DISCOVERY_STEPS.AUTH
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSaveButton = computed(() => {
|
||||||
|
if (isManualMode.value) {
|
||||||
|
return currentStep.value === MANUAL_STEPS.TEST
|
||||||
|
} else {
|
||||||
|
return currentStep.value === DISCOVERY_STEPS.TEST
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canProceedToNext = computed(() => {
|
||||||
|
if (isManualMode.value) {
|
||||||
|
if (currentStep.value === MANUAL_STEPS.CONFIG) {
|
||||||
|
return !!configuredLocation.value
|
||||||
|
}
|
||||||
|
if (currentStep.value === MANUAL_STEPS.AUTH) {
|
||||||
|
return authValid.value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
|
||||||
|
return authValid.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation methods
|
||||||
|
function handlePreviousStep() {
|
||||||
|
if (currentStep.value > 1) {
|
||||||
|
currentStep.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNextStep() {
|
||||||
|
if (isManualMode.value) {
|
||||||
|
if (currentStep.value < MANUAL_STEPS.TEST) {
|
||||||
|
currentStep.value++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentStep.value < DISCOVERY_STEPS.TEST) {
|
||||||
|
currentStep.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiscover() {
|
||||||
|
// Move to discovery status screen
|
||||||
|
currentStep.value = DISCOVERY_STEPS.DISCOVERY
|
||||||
|
|
||||||
|
// Extract provider IDs
|
||||||
|
const providerIds = Object.values(providersStore.providers).map(p => p.identifier)
|
||||||
|
|
||||||
|
if (providerIds.length === 0) {
|
||||||
|
console.error('No providers available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize status for all providers
|
||||||
|
discoveryStatus.value = providerIds.reduce((acc, identifier) => {
|
||||||
|
acc[identifier] = {
|
||||||
|
provider: identifier,
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, ProviderDiscoveryStatus>)
|
||||||
|
|
||||||
|
discoveredServices.value = []
|
||||||
|
|
||||||
|
// Start discovery for each provider in parallel
|
||||||
|
const promises = providerIds.map(async (identifier) => {
|
||||||
|
// Mark as discovering
|
||||||
|
discoveryStatus.value[identifier].status = 'discovering'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const services = await servicesStore.discover(
|
||||||
|
discoverAddress.value,
|
||||||
|
discoverSecret.value || undefined,
|
||||||
|
discoverHostname.value || undefined,
|
||||||
|
identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
// Success - check if we got results for this provider
|
||||||
|
const service = services.find(s => s.provider === identifier)
|
||||||
|
if (service && service.location) {
|
||||||
|
discoveryStatus.value[identifier] = {
|
||||||
|
provider: identifier,
|
||||||
|
status: 'success',
|
||||||
|
location: service.location,
|
||||||
|
metadata: extractLocationMetadata(service.location)
|
||||||
|
}
|
||||||
|
discoveredServices.value.push(service)
|
||||||
|
} else {
|
||||||
|
// No configuration found for this provider
|
||||||
|
discoveryStatus.value[identifier].status = 'failed'
|
||||||
|
discoveryStatus.value[identifier].error = 'Not configured'
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Failed - update status with error
|
||||||
|
discoveryStatus.value[identifier] = {
|
||||||
|
provider: identifier,
|
||||||
|
status: 'failed',
|
||||||
|
error: error.message || 'Discovery failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for all discoveries to complete
|
||||||
|
await Promise.allSettled(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract display metadata from location for UI
|
||||||
|
*/
|
||||||
|
function extractLocationMetadata(location: ServiceLocation) {
|
||||||
|
switch (location.type) {
|
||||||
|
case 'URI':
|
||||||
|
return {
|
||||||
|
host: location.host,
|
||||||
|
port: location.port,
|
||||||
|
protocol: location.scheme
|
||||||
|
}
|
||||||
|
case 'SOCKET_SOLE':
|
||||||
|
return {
|
||||||
|
host: location.host,
|
||||||
|
port: location.port,
|
||||||
|
protocol: location.encryption
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProviderSelect(identifier: string) {
|
||||||
|
// User clicked "Select" on discovered provider - skip config, go to auth
|
||||||
|
const service = discoveredServices.value.find(s => s.provider === identifier)
|
||||||
|
if (!service || !service.location) return
|
||||||
|
|
||||||
|
selectedProviderId.value = identifier
|
||||||
|
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
|
||||||
|
configuredLocation.value = service.location
|
||||||
|
|
||||||
|
// Discovery path: Entry → Discovery → Auth → Test
|
||||||
|
currentStep.value = DISCOVERY_STEPS.AUTH // Go to auth step
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProviderAdvanced(identifier: string) {
|
||||||
|
// User clicked "Advanced" - show manual config with pre-filled values
|
||||||
|
selectedProviderId.value = identifier
|
||||||
|
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
|
||||||
|
const service = discoveredServices.value.find(s => s.provider === identifier)
|
||||||
|
|
||||||
|
configuredLocation.value = service?.location || null
|
||||||
|
isManualMode.value = true
|
||||||
|
|
||||||
|
// Manual path: Entry → Discovery → Config → Auth → Test
|
||||||
|
currentStep.value = MANUAL_STEPS.CONFIG // Go to config step
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleManualMode() {
|
||||||
|
// User clicked "Manual Configuration" - show provider picker
|
||||||
|
isManualMode.value = true
|
||||||
|
discoveredServices.value = []
|
||||||
|
discoveryStatus.value = {}
|
||||||
|
currentStep.value = MANUAL_STEPS.PROVIDER_SELECT // Go to provider selection
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProviderManualSelect(identifier: string) {
|
||||||
|
// User selected a provider in manual mode
|
||||||
|
selectedProviderId.value = identifier
|
||||||
|
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
|
||||||
|
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBackToIdentity() {
|
||||||
|
currentStep.value = DISCOVERY_STEPS.ENTRY
|
||||||
|
isManualMode.value = false
|
||||||
|
discoveredServices.value = []
|
||||||
|
discoveryStatus.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Missing configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testResult = await servicesStore.test(
|
||||||
|
selectedProviderId.value,
|
||||||
|
null,
|
||||||
|
configuredLocation.value,
|
||||||
|
configuredIdentity.value
|
||||||
|
)
|
||||||
|
|
||||||
|
return testResult
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = {
|
||||||
|
label: accountLabel.value || discoverAddress.value,
|
||||||
|
email: discoverAddress.value,
|
||||||
|
enabled: accountEnabled.value,
|
||||||
|
location: configuredLocation.value,
|
||||||
|
identity: configuredIdentity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
await servicesStore.create(
|
||||||
|
selectedProviderId.value,
|
||||||
|
accountData
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save account:', error)
|
||||||
|
// TODO: Show error message to user
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialogOpen.value = false
|
||||||
|
// Reset state after animation
|
||||||
|
setTimeout(resetForm, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
currentStep.value = DISCOVERY_STEPS.ENTRY
|
||||||
|
isManualMode.value = false
|
||||||
|
discoverAddress.value = ''
|
||||||
|
discoverSecret.value = null
|
||||||
|
discoverHostname.value = null
|
||||||
|
selectedProviderId.value = undefined
|
||||||
|
selectedProviderLabel.value = ''
|
||||||
|
configuredLocation.value = null
|
||||||
|
configuredIdentity.value = null
|
||||||
|
authValid.value = false
|
||||||
|
accountLabel.value = ''
|
||||||
|
accountEnabled.value = true
|
||||||
|
testAndSaveValid.value = false
|
||||||
|
discoveredServices.value = []
|
||||||
|
discoveryStatus.value = {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
max-width="900"
|
||||||
|
persistent
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center pa-6">
|
||||||
|
<span class="text-h5">Add Mail Account</span>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-stepper
|
||||||
|
v-model="currentStep"
|
||||||
|
:items="stepperItems"
|
||||||
|
alt-labels
|
||||||
|
flat
|
||||||
|
hide-actions
|
||||||
|
>
|
||||||
|
<!-- Step 1: Discovery Entry -->
|
||||||
|
<template #item.1>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<DiscoveryEntryStep
|
||||||
|
v-model:address="discoverAddress"
|
||||||
|
v-model:secret="discoverSecret"
|
||||||
|
v-model:hostname="discoverHostname"
|
||||||
|
@discover="handleDiscover"
|
||||||
|
@manual="handleManualMode"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2: Discovery Status OR Provider Selection -->
|
||||||
|
<template #item.2>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<!-- Discovery path -->
|
||||||
|
<DiscoveryStatusStep
|
||||||
|
v-if="!isManualMode"
|
||||||
|
:address="discoverAddress"
|
||||||
|
:status="discoveryStatus"
|
||||||
|
@select="handleProviderSelect"
|
||||||
|
@advanced="handleProviderAdvanced"
|
||||||
|
@manual="handleManualMode"
|
||||||
|
@back="goBackToIdentity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Manual path - provider picker -->
|
||||||
|
<ProviderSelectionStep
|
||||||
|
v-else
|
||||||
|
@select="handleProviderManualSelect"
|
||||||
|
@back="goBackToIdentity"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 3: Config (manual) OR Auth (discovery) -->
|
||||||
|
<template #item.3>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<!-- Manual path: Protocol Configuration -->
|
||||||
|
<ProviderConfigStep
|
||||||
|
v-if="isManualMode && selectedProviderId"
|
||||||
|
:provider-id="selectedProviderId"
|
||||||
|
:discovered-location="configuredLocation || undefined"
|
||||||
|
v-model="configuredLocation"
|
||||||
|
@valid="() => { /* Can proceed to next step */ }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProviderAuthStep
|
||||||
|
v-else-if="!isManualMode && selectedProviderId"
|
||||||
|
:provider-id="selectedProviderId"
|
||||||
|
:provider-label="selectedProviderLabel"
|
||||||
|
:email-address="discoverAddress"
|
||||||
|
:discovered-location="configuredLocation || undefined"
|
||||||
|
:prefilled-identity="discoverAddress"
|
||||||
|
:prefilled-secret="discoverSecret || undefined"
|
||||||
|
v-model="configuredIdentity"
|
||||||
|
@valid="(valid) => authValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 4: Auth (manual) OR Test (discovery) -->
|
||||||
|
<template #item.4>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<ProviderAuthStep
|
||||||
|
v-if="isManualMode && selectedProviderId"
|
||||||
|
:provider-id="selectedProviderId"
|
||||||
|
:provider-label="selectedProviderLabel"
|
||||||
|
:email-address="discoverAddress"
|
||||||
|
:discovered-location="configuredLocation || undefined"
|
||||||
|
:prefilled-identity="discoverAddress"
|
||||||
|
:prefilled-secret="discoverSecret || undefined"
|
||||||
|
v-model="configuredIdentity"
|
||||||
|
@valid="(valid) => authValid = valid"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Discovery path: Test & Save -->
|
||||||
|
<TestAndSaveStep
|
||||||
|
v-else-if="!isManualMode && selectedProviderId"
|
||||||
|
:provider-id="selectedProviderId"
|
||||||
|
:provider-label="selectedProviderLabel"
|
||||||
|
:email-address="discoverAddress"
|
||||||
|
:location="configuredLocation"
|
||||||
|
:identity="configuredIdentity"
|
||||||
|
:prefilled-label="discoverAddress"
|
||||||
|
:on-test="testConnection"
|
||||||
|
@update:label="(val) => accountLabel = val"
|
||||||
|
@update:enabled="(val) => accountEnabled = val"
|
||||||
|
@valid="(valid) => testAndSaveValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 5: Test & Save (manual only) -->
|
||||||
|
<template #item.5>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<TestAndSaveStep
|
||||||
|
v-if="selectedProviderId"
|
||||||
|
:provider-id="selectedProviderId"
|
||||||
|
:provider-label="selectedProviderLabel"
|
||||||
|
:email-address="discoverAddress"
|
||||||
|
:location="configuredLocation"
|
||||||
|
:identity="configuredIdentity"
|
||||||
|
:prefilled-label="discoverAddress"
|
||||||
|
:on-test="testConnection"
|
||||||
|
@update:label="(val) => accountLabel = val"
|
||||||
|
@update:enabled="(val) => accountEnabled = val"
|
||||||
|
@valid="(valid) => testAndSaveValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-stepper>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6">
|
||||||
|
<!-- Previous Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
@click="handlePreviousStep"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Next Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showNextButton"
|
||||||
|
color="primary"
|
||||||
|
append-icon="mdi-arrow-right"
|
||||||
|
:disabled="!canProceedToNext"
|
||||||
|
@click="handleNextStep"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showSaveButton"
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
@click="saveAccount"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-content-save</v-icon>
|
||||||
|
Save Account
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
355
src/components/EditAccountDialog.vue
Normal file
355
src/components/EditAccountDialog.vue
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useProvidersStore } from '@MailManager/stores/providersStore'
|
||||||
|
import type { ServiceLocation, ServiceIdentity } from '@MailManager/types'
|
||||||
|
import type { ServiceObject } from '@MailManager/models/service'
|
||||||
|
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
|
||||||
|
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
|
||||||
|
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
|
||||||
|
|
||||||
|
const EDIT_STEPS = {
|
||||||
|
CONFIG: 1,
|
||||||
|
AUTH: 2,
|
||||||
|
TEST: 3
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
serviceProvider: string
|
||||||
|
serviceIdentifier: string | number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const providersStore = useProvidersStore()
|
||||||
|
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentStep = ref<number>(EDIT_STEPS.CONFIG)
|
||||||
|
const saving = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Service data
|
||||||
|
const service = ref<ServiceObject | null>(null)
|
||||||
|
const providerLabel = ref<string>('')
|
||||||
|
|
||||||
|
// Editable fields
|
||||||
|
const accountLabel = ref<string>('')
|
||||||
|
const accountEnabled = ref(true)
|
||||||
|
const configuredLocation = ref<ServiceLocation | null>(null)
|
||||||
|
const configuredIdentity = ref<ServiceIdentity | null>(null)
|
||||||
|
|
||||||
|
// Validation states
|
||||||
|
const configValid = ref(false)
|
||||||
|
const authValid = ref(false)
|
||||||
|
const testAndSaveValid = ref(false)
|
||||||
|
|
||||||
|
// Load service data when dialog opens
|
||||||
|
watch(dialogOpen, async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
await loadService()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadService() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Load providers if not already loaded
|
||||||
|
if (!providersStore.has) {
|
||||||
|
await providersStore.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the service
|
||||||
|
service.value = await servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
|
||||||
|
|
||||||
|
// Set initial values
|
||||||
|
accountLabel.value = service.value.label || ''
|
||||||
|
accountEnabled.value = service.value.enabled
|
||||||
|
configuredLocation.value = service.value.location
|
||||||
|
configuredIdentity.value = service.value.identity
|
||||||
|
|
||||||
|
// Get provider label
|
||||||
|
const provider = providersStore.provider(props.serviceProvider)
|
||||||
|
providerLabel.value = provider?.label || props.serviceProvider
|
||||||
|
|
||||||
|
// Mark config as valid if location exists
|
||||||
|
configValid.value = !!configuredLocation.value
|
||||||
|
authValid.value = !!configuredIdentity.value
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load service:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stepper configuration
|
||||||
|
const stepperItems = [
|
||||||
|
{ title: 'Protocol', value: EDIT_STEPS.CONFIG },
|
||||||
|
{ title: 'Authentication', value: EDIT_STEPS.AUTH },
|
||||||
|
{ title: 'Test & Save', value: EDIT_STEPS.TEST }
|
||||||
|
]
|
||||||
|
|
||||||
|
const canSave = computed(() => {
|
||||||
|
return testAndSaveValid.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation button visibility
|
||||||
|
const showPreviousButton = computed(() => currentStep.value > EDIT_STEPS.CONFIG)
|
||||||
|
const showNextButton = computed(() => currentStep.value < EDIT_STEPS.TEST)
|
||||||
|
const showSaveButton = computed(() => currentStep.value === EDIT_STEPS.TEST)
|
||||||
|
|
||||||
|
const canProceedToNext = computed(() => {
|
||||||
|
if (currentStep.value === EDIT_STEPS.CONFIG) {
|
||||||
|
return configValid.value && !!configuredLocation.value
|
||||||
|
}
|
||||||
|
if (currentStep.value === EDIT_STEPS.AUTH) {
|
||||||
|
return authValid.value
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation methods
|
||||||
|
function handlePreviousStep() {
|
||||||
|
if (currentStep.value > EDIT_STEPS.CONFIG) {
|
||||||
|
currentStep.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNextStep() {
|
||||||
|
if (currentStep.value < EDIT_STEPS.TEST) {
|
||||||
|
currentStep.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
if (!service.value || !configuredLocation.value || !configuredIdentity.value) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Missing configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testResult = await servicesStore.test(
|
||||||
|
service.value.provider,
|
||||||
|
service.value.identifier,
|
||||||
|
configuredLocation.value,
|
||||||
|
configuredIdentity.value
|
||||||
|
)
|
||||||
|
|
||||||
|
return testResult
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
if (!service.value || !configuredLocation.value || !configuredIdentity.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = {
|
||||||
|
label: accountLabel.value || service.value.label,
|
||||||
|
enabled: accountEnabled.value,
|
||||||
|
location: configuredLocation.value,
|
||||||
|
identity: configuredIdentity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
await servicesStore.update(
|
||||||
|
service.value.provider,
|
||||||
|
service.value.identifier as string | number,
|
||||||
|
accountData
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save account:', error)
|
||||||
|
// TODO: Show error message to user
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialogOpen.value = false
|
||||||
|
// Reset state after animation
|
||||||
|
setTimeout(resetForm, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
currentStep.value = EDIT_STEPS.CONFIG
|
||||||
|
service.value = null
|
||||||
|
accountLabel.value = ''
|
||||||
|
accountEnabled.value = true
|
||||||
|
configuredLocation.value = null
|
||||||
|
configuredIdentity.value = null
|
||||||
|
configValid.value = false
|
||||||
|
authValid.value = false
|
||||||
|
testAndSaveValid.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for location changes
|
||||||
|
watch(configuredLocation, (newLocation) => {
|
||||||
|
configValid.value = !!newLocation
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
max-width="900"
|
||||||
|
persistent
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center pa-6">
|
||||||
|
<span class="text-h5">Edit Mail Account</span>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text v-if="loading" class="text-center py-8">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
<p class="text-caption text-medium-emphasis mt-2">Loading account...</p>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-text v-else class="pa-0">
|
||||||
|
<!-- Account Info Header -->
|
||||||
|
<div v-if="service" class="pa-6 bg-surface-variant">
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<v-avatar color="primary">
|
||||||
|
<v-icon>mdi-email</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-1 font-weight-medium">
|
||||||
|
{{ service.label || 'Unnamed Account' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ service.primaryAddress || service.identifier }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
Provider: {{ providerLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-stepper
|
||||||
|
v-model="currentStep"
|
||||||
|
:items="stepperItems"
|
||||||
|
alt-labels
|
||||||
|
flat
|
||||||
|
hide-actions
|
||||||
|
>
|
||||||
|
<!-- Step 1: Protocol Configuration -->
|
||||||
|
<template #item.1>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<ProviderConfigStep
|
||||||
|
v-if="service"
|
||||||
|
:provider-id="service.provider"
|
||||||
|
:discovered-location="configuredLocation || undefined"
|
||||||
|
v-model="configuredLocation"
|
||||||
|
@valid="(valid) => configValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2: Authentication -->
|
||||||
|
<template #item.2>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<ProviderAuthStep
|
||||||
|
v-if="service"
|
||||||
|
:provider-id="service.provider"
|
||||||
|
:provider-label="providerLabel"
|
||||||
|
:email-address="service.primaryAddress || ''"
|
||||||
|
:discovered-location="configuredLocation || undefined"
|
||||||
|
:prefilled-identity="service.primaryAddress || ''"
|
||||||
|
:prefilled-secret="undefined"
|
||||||
|
v-model="configuredIdentity"
|
||||||
|
@valid="(valid) => authValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 3: Test & Save -->
|
||||||
|
<template #item.3>
|
||||||
|
<v-card flat class="pa-6">
|
||||||
|
<TestAndSaveStep
|
||||||
|
v-if="service"
|
||||||
|
:provider-id="service.provider"
|
||||||
|
:provider-label="providerLabel"
|
||||||
|
:email-address="service.primaryAddress || ''"
|
||||||
|
:location="configuredLocation"
|
||||||
|
:identity="configuredIdentity"
|
||||||
|
:prefilled-label="accountLabel"
|
||||||
|
:on-test="testConnection"
|
||||||
|
@update:label="(val) => accountLabel = val"
|
||||||
|
@update:enabled="(val) => accountEnabled = val"
|
||||||
|
@valid="(valid) => testAndSaveValid = valid"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-stepper>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6">
|
||||||
|
<!-- Previous Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showPreviousButton"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
@click="handlePreviousStep"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Next Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showNextButton"
|
||||||
|
color="primary"
|
||||||
|
append-icon="mdi-arrow-right"
|
||||||
|
:disabled="!canProceedToNext"
|
||||||
|
@click="handleNextStep"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showSaveButton"
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
@click="saveAccount"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-content-save</v-icon>
|
||||||
|
Save Changes
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
158
src/components/steps/DiscoveryEntryStep.vue
Normal file
158
src/components/steps/DiscoveryEntryStep.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
address: string
|
||||||
|
secret?: string | null
|
||||||
|
hostname?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:address': [value: string]
|
||||||
|
'update:secret': [value: string | null]
|
||||||
|
'update:hostname': [value: string | null]
|
||||||
|
'discover': []
|
||||||
|
'manual': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localAddress = computed({
|
||||||
|
get: () => props.address,
|
||||||
|
set: (value) => emit('update:address', value?.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
const localSecret = computed({
|
||||||
|
get: () => props.secret,
|
||||||
|
set: (value) => emit('update:secret', value?.trim() || null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const localHostname = computed({
|
||||||
|
get: () => props.hostname || '',
|
||||||
|
set: (value) => emit('update:hostname', value?.trim() || null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSecret = ref(false)
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
required: (v: string) => !!v || 'Required',
|
||||||
|
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email address'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="identity-entry-step">
|
||||||
|
<h3 class="text-h6 mb-2">Email Account Setup</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Enter your email address to automatically discover your mail server settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="localAddress"
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
:rules="[rules.required, rules.email]"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Advanced Options -->
|
||||||
|
<v-expansion-panels variant="accordion" class="mb-6">
|
||||||
|
<v-expansion-panel>
|
||||||
|
<v-expansion-panel-title>
|
||||||
|
<v-icon start>mdi-cog</v-icon>
|
||||||
|
Advanced Options (Optional)
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="localSecret"
|
||||||
|
:type="showSecret ? 'text' : 'password'"
|
||||||
|
label="Password (Optional)"
|
||||||
|
hint="Provide password to validate credentials during discovery"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
variant="outlined"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<v-btn
|
||||||
|
:icon="showSecret ? 'mdi-eye-off' : 'mdi-eye'"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="showSecret = !showSecret"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-information</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption">
|
||||||
|
Your credentials are used only to discover and test server settings.
|
||||||
|
They are transmitted securely and not stored during discovery.
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="localHostname"
|
||||||
|
label="Server Hostname"
|
||||||
|
hint="If you know your mail server hostname, enter it here to skip DNS lookup (e.g., mail.example.com)"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-server"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="mail.example.com"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert type="info" variant="tonal" density="compact" class="mt-3">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon size="small">mdi-information</v-icon>
|
||||||
|
</template>
|
||||||
|
<div class="text-caption">
|
||||||
|
Providing a hostname will skip DNS SRV lookups and test the server directly.
|
||||||
|
Leave blank for automatic discovery.
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:disabled="!localAddress || !rules.email(localAddress)"
|
||||||
|
@click="$emit('discover')"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-magnify</v-icon>
|
||||||
|
Discover Settings
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
block
|
||||||
|
@click="$emit('manual')"
|
||||||
|
>
|
||||||
|
Manual Configuration
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gap-3 {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
src/components/steps/DiscoveryStatusStep.vue
Normal file
220
src/components/steps/DiscoveryStatusStep.vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ProviderDiscoveryStatus } from '@MailManager/types/service'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
address: string
|
||||||
|
status: Record<string, ProviderDiscoveryStatus>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select': [providerId: string]
|
||||||
|
'advanced': [providerId: string]
|
||||||
|
'manual': []
|
||||||
|
'back': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortedStatus = computed(() => {
|
||||||
|
const statusArray = Object.values(props.status)
|
||||||
|
const order = { success: 0, discovering: 1, pending: 2, failed: 3 }
|
||||||
|
return statusArray.sort((a, b) => order[a.status] - order[b.status])
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
const total = Object.keys(props.status).length
|
||||||
|
if (total === 0) return 0
|
||||||
|
|
||||||
|
const completed = Object.values(props.status).filter(
|
||||||
|
s => s.status === 'success' || s.status === 'failed'
|
||||||
|
).length
|
||||||
|
|
||||||
|
return (completed / total) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDiscovering = computed(() => {
|
||||||
|
return Object.values(props.status).some(
|
||||||
|
s => s.status === 'discovering' || s.status === 'pending'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const successCount = computed(() => {
|
||||||
|
return Object.values(props.status).filter(s => s.status === 'success').length
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
success: 'success',
|
||||||
|
failed: 'error',
|
||||||
|
discovering: 'primary',
|
||||||
|
pending: 'grey'
|
||||||
|
}
|
||||||
|
return colors[status] || 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderLabel(providerId: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
jmap: 'JMAP',
|
||||||
|
smtp: 'SMTP/IMAP',
|
||||||
|
imap: 'IMAP',
|
||||||
|
exchange: 'Microsoft Exchange'
|
||||||
|
}
|
||||||
|
return labels[providerId] || providerId.toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="discovery-status-step">
|
||||||
|
<h3 class="text-h6 mb-2">Discovering Mail Servers</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Testing {{ address }} across multiple providers...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Overall Progress -->
|
||||||
|
<v-progress-linear
|
||||||
|
:model-value="progressPercent"
|
||||||
|
color="primary"
|
||||||
|
height="8"
|
||||||
|
rounded
|
||||||
|
class="mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Provider Status List -->
|
||||||
|
<v-list class="provider-status-list">
|
||||||
|
<v-list-item
|
||||||
|
v-for="status in sortedStatus"
|
||||||
|
:key="status.provider"
|
||||||
|
class="provider-status-item mb-2"
|
||||||
|
:class="`status-${status.status}`"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar :color="getStatusColor(status.status)" size="40">
|
||||||
|
<v-icon v-if="status.status === 'success'" color="white">
|
||||||
|
mdi-check
|
||||||
|
</v-icon>
|
||||||
|
<v-icon v-else-if="status.status === 'failed'" color="white">
|
||||||
|
mdi-close
|
||||||
|
</v-icon>
|
||||||
|
<v-progress-circular
|
||||||
|
v-else-if="status.status === 'discovering'"
|
||||||
|
indeterminate
|
||||||
|
size="24"
|
||||||
|
width="3"
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
<v-icon v-else color="white">
|
||||||
|
mdi-clock-outline
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="d-flex align-center gap-2">
|
||||||
|
<span class="font-weight-medium">{{ getProviderLabel(status.provider) }}</span>
|
||||||
|
<v-chip
|
||||||
|
v-if="status.status === 'success'"
|
||||||
|
size="x-small"
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
Found
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-else-if="status.status === 'failed'"
|
||||||
|
size="x-small"
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ status.error || 'Failed' }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-else-if="status.status === 'discovering'"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
Testing...
|
||||||
|
</v-chip>
|
||||||
|
</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle v-if="status.status === 'success' && status.metadata">
|
||||||
|
<div class="text-caption">
|
||||||
|
<v-icon size="14" class="mr-1">mdi-server</v-icon>
|
||||||
|
{{ status.metadata.host }}{{ status.metadata.port ? ':' + status.metadata.port : '' }}
|
||||||
|
<span v-if="status.metadata.protocol" class="ml-2">
|
||||||
|
<v-icon size="14" class="mr-1">mdi-shield-lock</v-icon>
|
||||||
|
{{ status.metadata.protocol.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div v-if="status.status === 'success'" class="d-flex gap-2">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
@click="$emit('select', status.provider)"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</v-btn>
|
||||||
|
<v-tooltip text="Advanced configuration">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
icon="mdi-tune"
|
||||||
|
@click="$emit('advanced', status.provider)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<!-- No Results Message -->
|
||||||
|
<v-alert
|
||||||
|
v-if="!isDiscovering && successCount === 0"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-alert</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-medium mb-1">No configurations found</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
We couldn't automatically discover server settings for {{ address }}.
|
||||||
|
You can try manual configuration instead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-status-item {
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-status-item.status-success {
|
||||||
|
background-color: rgba(var(--v-theme-success), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-status-item.status-discovering {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
163
src/components/steps/ProviderAuthStep.vue
Normal file
163
src/components/steps/ProviderAuthStep.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="provider-auth-step">
|
||||||
|
<h3 class="text-h6 mb-2">Authentication</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Configure authentication for {{ providerLabel }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loadingPanel" class="text-center py-8">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
<p class="text-caption text-medium-emphasis mt-2">
|
||||||
|
Loading authentication panel...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Provider Auth Panel -->
|
||||||
|
<component
|
||||||
|
v-else-if="currentAuthPanel"
|
||||||
|
:is="currentAuthPanel"
|
||||||
|
:email-address="emailAddress"
|
||||||
|
:discovered-location="discoveredLocation"
|
||||||
|
:prefilled-identity="prefilledIdentity"
|
||||||
|
:prefilled-secret="prefilledSecret"
|
||||||
|
v-model="localIdentity"
|
||||||
|
@update:model-value="handleIdentityUpdate"
|
||||||
|
@valid="handleValidChange"
|
||||||
|
@error="handleAuthError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- No Panel Available -->
|
||||||
|
<v-alert v-else type="error" variant="tonal">
|
||||||
|
<v-icon start>mdi-alert-circle</v-icon>
|
||||||
|
No authentication method available for this provider.
|
||||||
|
Please contact support.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
<v-alert
|
||||||
|
v-if="authError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
closable
|
||||||
|
@click:close="authError = ''"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-alert</v-icon>
|
||||||
|
{{ authError }}
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||||
|
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
providerId: string
|
||||||
|
providerLabel: string
|
||||||
|
emailAddress: string
|
||||||
|
discoveredLocation?: ServiceLocation
|
||||||
|
prefilledIdentity?: string
|
||||||
|
prefilledSecret?: string
|
||||||
|
modelValue?: ServiceIdentity | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ServiceIdentity]
|
||||||
|
'valid': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
|
||||||
|
const loadedPanels = new Map<string, any>()
|
||||||
|
const currentAuthPanel = ref<any>(null)
|
||||||
|
const loadingPanel = ref(false)
|
||||||
|
const localIdentity = ref<ServiceIdentity | undefined>(props.modelValue)
|
||||||
|
const authError = ref('')
|
||||||
|
|
||||||
|
// The full integration ID (e.g., "jmap")
|
||||||
|
const effectiveIntegrationId = computed(() => {
|
||||||
|
return props.providerId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load provider auth panel dynamically
|
||||||
|
async function loadAuthPanel(integrationId: string) {
|
||||||
|
if (loadedPanels.has(integrationId)) {
|
||||||
|
currentAuthPanel.value = loadedPanels.get(integrationId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingPanel.value = true
|
||||||
|
|
||||||
|
// Try to find panel - integration IDs are prefixed with module handle
|
||||||
|
// so we need to search for panels that match the provider ID
|
||||||
|
const panels = integrationStore.getItems('mail_account_auth_panels')
|
||||||
|
const panelConfig = panels.find((panel: any) => {
|
||||||
|
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
|
||||||
|
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!panelConfig?.component) {
|
||||||
|
console.error(`No auth panel found for provider ID: ${integrationId}`)
|
||||||
|
console.error(`Available panels:`, panels.map((p: any) => p.id))
|
||||||
|
currentAuthPanel.value = null
|
||||||
|
loadingPanel.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const module = await panelConfig.component()
|
||||||
|
const component = module.default || module
|
||||||
|
loadedPanels.set(integrationId, component)
|
||||||
|
currentAuthPanel.value = component
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load auth panel for ${integrationId}:`, error)
|
||||||
|
currentAuthPanel.value = null
|
||||||
|
authError.value = `Failed to load authentication panel: ${error}`
|
||||||
|
} finally {
|
||||||
|
loadingPanel.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load panel when provider changes
|
||||||
|
watch(
|
||||||
|
effectiveIntegrationId,
|
||||||
|
(newIntegrationId, oldIntegrationId) => {
|
||||||
|
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
|
||||||
|
loadAuthPanel(newIntegrationId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleIdentityUpdate(identity: ServiceIdentity) {
|
||||||
|
localIdentity.value = identity
|
||||||
|
emit('update:modelValue', identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValidChange(valid: boolean) {
|
||||||
|
emit('valid', valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthError(error: string) {
|
||||||
|
authError.value = error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for prop changes
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
localIdentity.value = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-auth-step {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
src/components/steps/ProviderConfigStep.vue
Normal file
124
src/components/steps/ProviderConfigStep.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||||
|
import type { ServiceLocation } from '@MailManager/types/service'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
providerId: string
|
||||||
|
discoveredLocation?: ServiceLocation
|
||||||
|
modelValue?: ServiceLocation | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ServiceLocation]
|
||||||
|
'valid': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
|
||||||
|
const loadedPanels = new Map<string, any>()
|
||||||
|
const currentProviderPanel = ref<any>(null)
|
||||||
|
const loadingPanel = ref(false)
|
||||||
|
const localLocation = ref<ServiceLocation | undefined>(props.modelValue || props.discoveredLocation)
|
||||||
|
|
||||||
|
// The full integration ID (e.g., "provider_jmapc.jmap")
|
||||||
|
const effectiveIntegrationId = computed(() => {
|
||||||
|
return props.providerId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load provider panel dynamically using the integration ID
|
||||||
|
async function loadProviderPanel(integrationId: string) {
|
||||||
|
if (loadedPanels.has(integrationId)) {
|
||||||
|
currentProviderPanel.value = loadedPanels.get(integrationId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingPanel.value = true
|
||||||
|
|
||||||
|
// Try to find panel - integration IDs are prefixed with module handle
|
||||||
|
// so we need to search for panels that match the provider ID
|
||||||
|
const panels = integrationStore.getItems('mail_account_config_panels')
|
||||||
|
const panelConfig = panels.find((panel: any) => {
|
||||||
|
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
|
||||||
|
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!panelConfig?.component) {
|
||||||
|
console.warn(`No config panel found for provider ID: ${integrationId}`)
|
||||||
|
console.warn(`Available panels:`, panels.map((p: any) => p.id))
|
||||||
|
currentProviderPanel.value = null
|
||||||
|
loadingPanel.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const module = await panelConfig.component()
|
||||||
|
const component = module.default || module
|
||||||
|
loadedPanels.set(integrationId, component)
|
||||||
|
currentProviderPanel.value = component
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load panel for ${integrationId}:`, error)
|
||||||
|
currentProviderPanel.value = null
|
||||||
|
} finally {
|
||||||
|
loadingPanel.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(effectiveIntegrationId, (newIntegrationId, oldIntegrationId) => {
|
||||||
|
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
|
||||||
|
loadProviderPanel(newIntegrationId)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function handleLocationUpdate(location: ServiceLocation) {
|
||||||
|
localLocation.value = location
|
||||||
|
emit('update:modelValue', location)
|
||||||
|
// Emit valid when location is provided
|
||||||
|
emit('valid', !!location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for prop changes
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
localLocation.value = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.discoveredLocation, (newValue) => {
|
||||||
|
if (newValue && !props.modelValue) {
|
||||||
|
localLocation.value = newValue
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
emit('valid', true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="provider-config-step">
|
||||||
|
<h3 class="text-h6 mb-2">Protocol Configuration</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Configure the connection settings for your mail service
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Dynamic Provider Panel -->
|
||||||
|
<component
|
||||||
|
v-if="currentProviderPanel"
|
||||||
|
:is="currentProviderPanel"
|
||||||
|
v-model="localLocation"
|
||||||
|
:discovered-location="discoveredLocation"
|
||||||
|
@update:model-value="handleLocationUpdate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading state for panel -->
|
||||||
|
<div v-else-if="loadingPanel" class="text-center py-8">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
<p class="text-caption text-medium-emphasis mt-2">Loading configuration panel...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No panel available -->
|
||||||
|
<v-alert v-else type="info" variant="tonal">
|
||||||
|
<v-icon start>mdi-information</v-icon>
|
||||||
|
No configuration panel available for this provider
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
125
src/components/steps/ProviderSelectionStep.vue
Normal file
125
src/components/steps/ProviderSelectionStep.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useProvidersStore } from '@MailManager/stores/providersStore'
|
||||||
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select': [providerId: string]
|
||||||
|
'back': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const providersStore = useProvidersStore()
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
const selected = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Get provider metadata from integrations
|
||||||
|
const providerMetadata = computed(() => {
|
||||||
|
const metadata = integrationStore.getItems('mail_provider_metadata')
|
||||||
|
return metadata.reduce((acc: any, meta: any) => {
|
||||||
|
acc[meta.id] = meta
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Color palette for providers without specific colors
|
||||||
|
const defaultColors = ['blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink', 'cyan']
|
||||||
|
|
||||||
|
// Combine provider data with metadata
|
||||||
|
const availableProviders = computed(() => {
|
||||||
|
return providersStore.providers.map((provider, index) => {
|
||||||
|
const metadata = providerMetadata.value[provider.identifier] || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: provider.identifier,
|
||||||
|
name: provider.label || metadata.label || provider.identifier,
|
||||||
|
description: metadata.description || `${provider.label} mail provider`,
|
||||||
|
icon: metadata.icon || 'mdi-email',
|
||||||
|
color: metadata.color || defaultColors[index % defaultColors.length]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectProvider(providerId: string) {
|
||||||
|
selected.value = providerId
|
||||||
|
emit('select', providerId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="provider-selection-step">
|
||||||
|
<h3 class="text-h6 mb-2">Select Provider</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Choose the mail provider you want to configure manually.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="providersStore.transceiving" class="text-center py-8">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
<p class="text-caption text-medium-emphasis mt-2">
|
||||||
|
Loading providers...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Providers Available -->
|
||||||
|
<v-alert
|
||||||
|
v-else-if="availableProviders.length === 0"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-alert</v-icon>
|
||||||
|
No mail providers are currently available. Please contact your administrator.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Provider Grid -->
|
||||||
|
<v-row v-else>
|
||||||
|
<v-col
|
||||||
|
v-for="provider in availableProviders"
|
||||||
|
:key="provider.id"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
variant="outlined"
|
||||||
|
hover
|
||||||
|
class="provider-card"
|
||||||
|
:class="{ 'provider-card--selected': selected === provider.id }"
|
||||||
|
@click="selectProvider(provider.id)"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-center pa-6">
|
||||||
|
<v-avatar :color="provider.color" size="64" class="mb-4">
|
||||||
|
<v-icon :icon="provider.icon" size="32" color="white" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<h4 class="text-h6 mb-2">{{ provider.name }}</h4>
|
||||||
|
<p class="text-caption text-medium-emphasis">
|
||||||
|
{{ provider.description }}
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card:hover {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card--selected {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
295
src/components/steps/TestAndSaveStep.vue
Normal file
295
src/components/steps/TestAndSaveStep.vue
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<div class="test-and-save-step">
|
||||||
|
<h3 class="text-h6 mb-2">Test & Save</h3>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
Test your connection and save the account configuration
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Configuration Summary -->
|
||||||
|
<v-card variant="outlined" class="mb-4">
|
||||||
|
<v-card-subtitle>Configuration Summary</v-card-subtitle>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list density="compact" class="bg-transparent">
|
||||||
|
<!-- Account Label -->
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-label</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Account Name</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ accountLabel }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- Email Address -->
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-email</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Email Address</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ emailAddress }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- Provider -->
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-cloud</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Provider</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ providerLabel }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- Location Details -->
|
||||||
|
<template v-if="location">
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<v-list-item v-if="location.type === 'URI'">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-web</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Service URL</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ location.scheme }}://{{ location.host }}:{{ location.port }}{{ location.path || '' }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<template v-if="location.type === 'SOCKET_SOLE'">
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-server</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Server</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ location.host }}:{{ location.port }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-shield-lock</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Security</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ location.encryption.toUpperCase() }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="location.type === 'SOCKET_SPLIT'">
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-inbox-arrow-down</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Incoming Mail</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ location.inbound.protocol.toUpperCase() }} - {{ location.inbound.host }}:{{ location.inbound.port }} ({{ location.inbound.encryption.toUpperCase() }})
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>mdi-send</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Outgoing Mail</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ location.outbound.protocol.toUpperCase() }} - {{ location.outbound.host }}:{{ location.outbound.port }} ({{ location.outbound.encryption.toUpperCase() }})
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Authentication Method -->
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon>{{ getAuthIcon(identity?.type) }}</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Authentication</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ getAuthLabel(identity?.type) }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Account Label Input -->
|
||||||
|
<v-text-field
|
||||||
|
v-model="localAccountLabel"
|
||||||
|
label="Account Name"
|
||||||
|
variant="outlined"
|
||||||
|
hint="A friendly name for this account (e.g., Work Email)"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-label"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Enable Account Toggle -->
|
||||||
|
<v-switch
|
||||||
|
v-model="accountEnabled"
|
||||||
|
label="Enable this account"
|
||||||
|
color="primary"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Test Connection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="testing"
|
||||||
|
:disabled="testSuccess"
|
||||||
|
prepend-icon="mdi-connection"
|
||||||
|
@click="handleTest"
|
||||||
|
>
|
||||||
|
{{ testSuccess ? 'Connection Tested Successfully' : 'Test Connection' }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Test Result -->
|
||||||
|
<v-alert
|
||||||
|
v-if="testResult"
|
||||||
|
:type="testResult.success ? 'success' : 'error'"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon
|
||||||
|
:icon="testResult.success ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="font-weight-bold">{{ testResult.message }}</div>
|
||||||
|
<div v-if="testResult.details?.latency" class="text-caption">
|
||||||
|
Response time: {{ testResult.details.latency }}ms
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult.details?.protocols" class="text-caption">
|
||||||
|
Protocols: {{ Object.keys(testResult.details.protocols).join(', ') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult.details?.capabilities" class="text-caption mt-1">
|
||||||
|
Capabilities: {{ formatCapabilities(testResult.details.capabilities) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Warning -->
|
||||||
|
<v-alert
|
||||||
|
v-if="!testSuccess"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-alert</v-icon>
|
||||||
|
Please test the connection before saving
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
providerId: string
|
||||||
|
providerLabel: string
|
||||||
|
emailAddress: string
|
||||||
|
location: ServiceLocation | null
|
||||||
|
identity: ServiceIdentity | null
|
||||||
|
prefilledLabel?: string
|
||||||
|
onTest: () => Promise<any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:label': [value: string]
|
||||||
|
'update:enabled': [value: boolean]
|
||||||
|
'tested': [success: boolean]
|
||||||
|
'valid': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const localAccountLabel = ref(props.prefilledLabel || props.emailAddress || '')
|
||||||
|
const accountEnabled = ref(true)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref<any>(null)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const accountLabel = computed(() => localAccountLabel.value || props.emailAddress || 'New Account')
|
||||||
|
const testSuccess = computed(() => testResult.value?.success === true)
|
||||||
|
|
||||||
|
const isValid = computed(() => {
|
||||||
|
return testSuccess.value && !!localAccountLabel.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function getAuthIcon(type?: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'NA': return 'mdi-lock-open-variant'
|
||||||
|
case 'BA': return 'mdi-account-key'
|
||||||
|
case 'TA': return 'mdi-key'
|
||||||
|
case 'OA': return 'mdi-shield-account'
|
||||||
|
case 'CC': return 'mdi-certificate'
|
||||||
|
default: return 'mdi-help-circle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthLabel(type?: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'NA': return 'No Authentication'
|
||||||
|
case 'BA': return 'Username & Password'
|
||||||
|
case 'TA': return 'API Token'
|
||||||
|
case 'OA': return 'OAuth 2.0'
|
||||||
|
case 'CC': return 'Client Certificate'
|
||||||
|
default: return 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapabilities(capabilities: any): string {
|
||||||
|
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
|
||||||
|
|
||||||
|
const caps = Object.entries(capabilities)
|
||||||
|
.filter(([_, value]) => value === true)
|
||||||
|
.map(([key]) => key)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const total = caps.length
|
||||||
|
const display = caps.slice(0, 3).join(', ')
|
||||||
|
|
||||||
|
if (total > 3) {
|
||||||
|
return `${display}, +${total - 3} more`
|
||||||
|
}
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (!props.location || !props.identity) {
|
||||||
|
testResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: 'Missing configuration'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await props.onTest()
|
||||||
|
testResult.value = result
|
||||||
|
emit('tested', result.success)
|
||||||
|
} catch (error: any) {
|
||||||
|
testResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Connection test failed'
|
||||||
|
}
|
||||||
|
emit('tested', false)
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes and emit
|
||||||
|
watch(localAccountLabel, (value) => {
|
||||||
|
emit('update:label', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(accountEnabled, (value) => {
|
||||||
|
emit('update:enabled', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isValid, (value) => {
|
||||||
|
emit('valid', value)
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
245
src/composables/useMailSync.ts
Normal file
245
src/composables/useMailSync.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Background mail synchronization composable
|
||||||
|
*
|
||||||
|
* Periodically checks for changes in mailboxes using the delta method
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useEntitiesStore } from '../stores/entitiesStore';
|
||||||
|
import { useCollectionsStore } from '../stores/collectionsStore';
|
||||||
|
|
||||||
|
interface SyncSource {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collections: (string | number)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncOptions {
|
||||||
|
/** Polling interval in milliseconds (default: 30000 = 30 seconds) */
|
||||||
|
interval?: number;
|
||||||
|
/** Auto-start sync on mount (default: true) */
|
||||||
|
autoStart?: boolean;
|
||||||
|
/** Fetch full entity details after delta (default: true) */
|
||||||
|
fetchDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMailSync(options: SyncOptions = {}) {
|
||||||
|
const {
|
||||||
|
interval = 30000,
|
||||||
|
autoStart = true,
|
||||||
|
fetchDetails = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const entitiesStore = useEntitiesStore();
|
||||||
|
const collectionsStore = useCollectionsStore();
|
||||||
|
|
||||||
|
const isRunning = ref(false);
|
||||||
|
const lastSync = ref<Date | null>(null);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const sources = ref<SyncSource[]>([]);
|
||||||
|
|
||||||
|
let syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a source to sync (mailbox to monitor)
|
||||||
|
*/
|
||||||
|
function addSource(source: SyncSource) {
|
||||||
|
const exists = sources.value.some(
|
||||||
|
s => s.provider === source.provider
|
||||||
|
&& s.service === source.service
|
||||||
|
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
sources.value.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a source from sync
|
||||||
|
*/
|
||||||
|
function removeSource(source: SyncSource) {
|
||||||
|
const index = sources.value.findIndex(
|
||||||
|
s => s.provider === source.provider
|
||||||
|
&& s.service === source.service
|
||||||
|
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
sources.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all sources
|
||||||
|
*/
|
||||||
|
function clearSources() {
|
||||||
|
sources.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a single sync check
|
||||||
|
*/
|
||||||
|
async function sync() {
|
||||||
|
if (sources.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
// Build sources structure for delta request
|
||||||
|
const deltaSources: any = {};
|
||||||
|
|
||||||
|
sources.value.forEach(source => {
|
||||||
|
if (!deltaSources[source.provider]) {
|
||||||
|
deltaSources[source.provider] = {};
|
||||||
|
}
|
||||||
|
if (!deltaSources[source.provider][source.service]) {
|
||||||
|
deltaSources[source.provider][source.service] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add collections to check with their signatures
|
||||||
|
source.collections.forEach(collection => {
|
||||||
|
// Look up signature from entities store first (updated by delta), fallback to collections store
|
||||||
|
let signature = entitiesStore.signatures[source.provider]?.[String(source.service)]?.[String(collection)];
|
||||||
|
|
||||||
|
// Fallback to collection signature if not yet synced
|
||||||
|
if (!signature) {
|
||||||
|
const collectionData = collectionsStore.collections[source.provider]?.[String(source.service)]?.[String(collection)];
|
||||||
|
signature = collectionData?.signature || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Sync] Collection ${source.provider}/${source.service}/${collection} signature: "${signature}"`);
|
||||||
|
|
||||||
|
// Map collection identifier to signature string
|
||||||
|
deltaSources[source.provider][source.service][collection] = signature || '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get delta changes
|
||||||
|
const deltaResponse = await entitiesStore.getDelta(deltaSources);
|
||||||
|
// If fetchDetails is enabled, fetch full entity data for additions and modifications
|
||||||
|
if (fetchDetails) {
|
||||||
|
const fetchPromises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => {
|
||||||
|
Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => {
|
||||||
|
Object.entries(serviceData).forEach(([collection, collectionData]: [string, any]) => {
|
||||||
|
// Skip if no changes (server returns false or string signature)
|
||||||
|
if (collectionData === false || typeof collectionData === 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if signature actually changed (if not, skip fetching)
|
||||||
|
const oldSignature = deltaSources[provider]?.[service]?.[collection];
|
||||||
|
const newSignature = collectionData.signature;
|
||||||
|
|
||||||
|
if (oldSignature && newSignature && oldSignature === newSignature) {
|
||||||
|
// Signature unchanged - server bug returning additions anyway, skip fetch
|
||||||
|
console.log(`[Sync] Skipping fetch for ${provider}/${service}/${collection} - signature unchanged (${newSignature})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifiersToFetch = [
|
||||||
|
...(collectionData.additions || []),
|
||||||
|
...(collectionData.modifications || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (identifiersToFetch.length > 0) {
|
||||||
|
console.log(`[Sync] Fetching ${identifiersToFetch.length} entities for ${provider}/${service}/${collection}`);
|
||||||
|
fetchPromises.push(
|
||||||
|
entitiesStore.getMessages(
|
||||||
|
provider,
|
||||||
|
service,
|
||||||
|
collection,
|
||||||
|
identifiersToFetch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all in parallel
|
||||||
|
await Promise.allSettled(fetchPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSync.value = new Date();
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Sync failed';
|
||||||
|
console.error('Mail sync error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the background sync worker
|
||||||
|
*/
|
||||||
|
function start() {
|
||||||
|
if (isRunning.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning.value = true;
|
||||||
|
|
||||||
|
// Do initial sync
|
||||||
|
sync();
|
||||||
|
|
||||||
|
// Set up periodic sync
|
||||||
|
syncInterval = setInterval(() => {
|
||||||
|
sync();
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the background sync worker
|
||||||
|
*/
|
||||||
|
function stop() {
|
||||||
|
if (!isRunning.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning.value = false;
|
||||||
|
|
||||||
|
if (syncInterval) {
|
||||||
|
clearInterval(syncInterval);
|
||||||
|
syncInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the sync worker
|
||||||
|
*/
|
||||||
|
function restart() {
|
||||||
|
stop();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start/stop on component lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
if (autoStart && sources.value.length > 0) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isRunning,
|
||||||
|
lastSync,
|
||||||
|
error,
|
||||||
|
sources,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
addSource,
|
||||||
|
removeSource,
|
||||||
|
clearSources,
|
||||||
|
sync,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
restart,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/integrations.ts
Normal file
16
src/integrations.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
|
||||||
|
|
||||||
|
const integrations: ModuleIntegrations = {
|
||||||
|
user_settings_menu: [
|
||||||
|
{
|
||||||
|
id: 'mail_accounts',
|
||||||
|
label: 'Mail Accounts',
|
||||||
|
path: '/accounts',
|
||||||
|
icon: 'mdi-email-multiple',
|
||||||
|
priority: 40,
|
||||||
|
caption: 'Manage your mail accounts'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default integrations;
|
||||||
34
src/main.ts
Normal file
34
src/main.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { App as Vue } from 'vue'
|
||||||
|
import routes from '@/routes'
|
||||||
|
import integrations from '@/integrations'
|
||||||
|
import { useCollectionsStore } from '@/stores/collectionsStore'
|
||||||
|
import { useEntitiesStore } from '@/stores/entitiesStore'
|
||||||
|
import { useProvidersStore } from '@/stores/providersStore'
|
||||||
|
import { useServicesStore } from '@/stores/servicesStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail Manager Module Boot Script
|
||||||
|
*
|
||||||
|
* This script is executed when the mail_manager module is loaded.
|
||||||
|
* It initializes the stores which manage mail providers, services, collections, and messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
console.log('[MailManager] Booting Mail Manager module...')
|
||||||
|
|
||||||
|
console.log('[MailManager] Mail Manager module booted successfully')
|
||||||
|
|
||||||
|
// CSS will be injected by build process
|
||||||
|
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||||
|
|
||||||
|
// Export routes and integrations for module system
|
||||||
|
export { routes, integrations }
|
||||||
|
|
||||||
|
// Export stores for external use if needed
|
||||||
|
export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore }
|
||||||
|
|
||||||
|
// Default export for Vue plugin installation
|
||||||
|
export default {
|
||||||
|
install(app: Vue) {
|
||||||
|
// Module initialization if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/models/collection.ts
Normal file
191
src/models/collection.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Class model for Collection Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CollectionInterface, CollectionPropertiesInterface } from "@/types/collection";
|
||||||
|
|
||||||
|
export class CollectionObject implements CollectionInterface {
|
||||||
|
|
||||||
|
_data!: CollectionInterface;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._data = {
|
||||||
|
provider: '',
|
||||||
|
service: '',
|
||||||
|
collection: null,
|
||||||
|
identifier: '',
|
||||||
|
signature: null,
|
||||||
|
created: null,
|
||||||
|
modified: null,
|
||||||
|
properties: {
|
||||||
|
'@type': 'mail.collection',
|
||||||
|
version: 1,
|
||||||
|
total: 0,
|
||||||
|
unread: 0,
|
||||||
|
label: '',
|
||||||
|
role: null,
|
||||||
|
rank: 0,
|
||||||
|
subscribed: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: CollectionInterface): CollectionObject {
|
||||||
|
this._data = data;
|
||||||
|
if (data.properties) {
|
||||||
|
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
|
||||||
|
} else {
|
||||||
|
this._data.properties = new CollectionPropertiesObject();
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): CollectionInterface {
|
||||||
|
const json = { ...this._data };
|
||||||
|
if (this._data.properties instanceof CollectionPropertiesObject) {
|
||||||
|
json.properties = this._data.properties.toJson();
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): CollectionObject {
|
||||||
|
const cloned = new CollectionObject();
|
||||||
|
cloned._data = { ...this._data };
|
||||||
|
cloned._data.properties = this.properties.clone();
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable Properties */
|
||||||
|
|
||||||
|
get provider(): string {
|
||||||
|
return this._data.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
get service(): string | number {
|
||||||
|
return this._data.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
get collection(): string | number | null {
|
||||||
|
return this._data.collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identifier(): string | number {
|
||||||
|
return this._data.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signature(): string | null | undefined {
|
||||||
|
return this._data.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
get created(): string | null | undefined {
|
||||||
|
return this._data.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
get modified(): string | null | undefined {
|
||||||
|
return this._data.modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
get properties(): CollectionPropertiesObject {
|
||||||
|
if (this._data.properties instanceof CollectionPropertiesObject) {
|
||||||
|
return this._data.properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._data.properties) {
|
||||||
|
const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface);
|
||||||
|
this._data.properties = hydrated;
|
||||||
|
return hydrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CollectionPropertiesObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
set properties(value: CollectionPropertiesObject) {
|
||||||
|
if (value instanceof CollectionPropertiesObject) {
|
||||||
|
this._data.properties = value as any;
|
||||||
|
} else {
|
||||||
|
this._data.properties = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollectionPropertiesObject implements CollectionPropertiesInterface {
|
||||||
|
|
||||||
|
_data!: CollectionPropertiesInterface;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._data = {
|
||||||
|
'@type': 'mail.collection',
|
||||||
|
version: 1,
|
||||||
|
total: 0,
|
||||||
|
unread: 0,
|
||||||
|
label: '',
|
||||||
|
role: null,
|
||||||
|
rank: 0,
|
||||||
|
subscribed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): CollectionPropertiesInterface {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): CollectionPropertiesObject {
|
||||||
|
const cloned = new CollectionPropertiesObject();
|
||||||
|
cloned._data = { ...this._data };
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable Properties */
|
||||||
|
|
||||||
|
get '@type'(): string {
|
||||||
|
return this._data['@type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get version(): number {
|
||||||
|
return this._data.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
get role(): string | null | undefined {
|
||||||
|
return this._data.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
get total(): number | undefined {
|
||||||
|
return this._data.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
get unread(): number | undefined {
|
||||||
|
return this._data.unread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable Properties */
|
||||||
|
|
||||||
|
get label(): string {
|
||||||
|
return this._data.label || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
set label(value: string) {
|
||||||
|
this._data.label = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rank(): number | undefined {
|
||||||
|
return this._data.rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
set rank(value: number) {
|
||||||
|
this._data.rank = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscribed(): boolean | undefined {
|
||||||
|
return this._data.subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
set subscribed(value: boolean) {
|
||||||
|
this._data.subscribed = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
107
src/models/entity.ts
Normal file
107
src/models/entity.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Class model for Message/Entity Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EntityInterface } from "@/types/entity";
|
||||||
|
import type { MessageInterface, MessagePartInterface } from "@/types/message";
|
||||||
|
import { MessageObject } from "./message";
|
||||||
|
|
||||||
|
export class EntityObject {
|
||||||
|
|
||||||
|
_data!: EntityInterface<MessageInterface>;
|
||||||
|
_message!: MessageObject;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._data = {
|
||||||
|
'@type': 'mail.entity',
|
||||||
|
provider: '',
|
||||||
|
service: '',
|
||||||
|
collection: '',
|
||||||
|
identifier: '',
|
||||||
|
signature: null,
|
||||||
|
created: null,
|
||||||
|
modified: null,
|
||||||
|
properties: {
|
||||||
|
'@type': 'mail.message',
|
||||||
|
version: 1,
|
||||||
|
urid: '',
|
||||||
|
size: 0,
|
||||||
|
receivedDate: undefined,
|
||||||
|
date: undefined,
|
||||||
|
subject: '',
|
||||||
|
snippet: '',
|
||||||
|
from: undefined,
|
||||||
|
to: [],
|
||||||
|
cc: [],
|
||||||
|
bcc: [],
|
||||||
|
replyTo: [],
|
||||||
|
flags: {},
|
||||||
|
body: undefined,
|
||||||
|
attachments: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: EntityInterface<MessageInterface>): EntityObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): EntityInterface<MessageInterface> {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): EntityObject {
|
||||||
|
const cloned = new EntityObject();
|
||||||
|
cloned._data = {
|
||||||
|
...this._data,
|
||||||
|
properties: { ...this._data.properties }
|
||||||
|
};
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata Properties */
|
||||||
|
|
||||||
|
get provider(): string {
|
||||||
|
return this._data.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
get service(): string {
|
||||||
|
return this._data.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
get collection(): string|number {
|
||||||
|
return this._data.collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identifier(): string|number {
|
||||||
|
return this._data.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signature(): string | null {
|
||||||
|
return this._data.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
get created(): string | null {
|
||||||
|
return this._data.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
get modified(): string | null {
|
||||||
|
return this._data.modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message Object Properties */
|
||||||
|
|
||||||
|
get properties(): MessageObject {
|
||||||
|
if (!this._message) {
|
||||||
|
this._message = new MessageObject(this._data.properties);
|
||||||
|
}
|
||||||
|
return this._message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for backward compatibility
|
||||||
|
get object(): MessageObject {
|
||||||
|
return this.properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
196
src/models/identity.ts
Normal file
196
src/models/identity.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Identity implementation classes for Mail Manager services
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ServiceIdentity,
|
||||||
|
ServiceIdentityNone,
|
||||||
|
ServiceIdentityBasic,
|
||||||
|
ServiceIdentityToken,
|
||||||
|
ServiceIdentityOAuth,
|
||||||
|
ServiceIdentityCertificate
|
||||||
|
} from '@/types/service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Identity class
|
||||||
|
*/
|
||||||
|
export abstract class Identity {
|
||||||
|
abstract toJson(): ServiceIdentity;
|
||||||
|
|
||||||
|
static fromJson(data: ServiceIdentity): Identity {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'NA':
|
||||||
|
return IdentityNone.fromJson(data);
|
||||||
|
case 'BA':
|
||||||
|
return IdentityBasic.fromJson(data);
|
||||||
|
case 'TA':
|
||||||
|
return IdentityToken.fromJson(data);
|
||||||
|
case 'OA':
|
||||||
|
return IdentityOAuth.fromJson(data);
|
||||||
|
case 'CC':
|
||||||
|
return IdentityCertificate.fromJson(data);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown identity type: ${(data as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No authentication
|
||||||
|
*/
|
||||||
|
export class IdentityNone extends Identity {
|
||||||
|
readonly type = 'NA' as const;
|
||||||
|
|
||||||
|
static fromJson(_data: ServiceIdentityNone): IdentityNone {
|
||||||
|
return new IdentityNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceIdentityNone {
|
||||||
|
return {
|
||||||
|
type: this.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic authentication (username/password)
|
||||||
|
*/
|
||||||
|
export class IdentityBasic extends Identity {
|
||||||
|
readonly type = 'BA' as const;
|
||||||
|
identity: string;
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
constructor(identity: string = '', secret: string = '') {
|
||||||
|
super();
|
||||||
|
this.identity = identity;
|
||||||
|
this.secret = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceIdentityBasic): IdentityBasic {
|
||||||
|
return new IdentityBasic(data.identity, data.secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceIdentityBasic {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
identity: this.identity,
|
||||||
|
secret: this.secret
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token authentication (API key, static token)
|
||||||
|
*/
|
||||||
|
export class IdentityToken extends Identity {
|
||||||
|
readonly type = 'TA' as const;
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
constructor(token: string = '') {
|
||||||
|
super();
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceIdentityToken): IdentityToken {
|
||||||
|
return new IdentityToken(data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceIdentityToken {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
token: this.token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth authentication
|
||||||
|
*/
|
||||||
|
export class IdentityOAuth extends Identity {
|
||||||
|
readonly type = 'OA' as const;
|
||||||
|
accessToken: string;
|
||||||
|
accessScope?: string[];
|
||||||
|
accessExpiry?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
refreshLocation?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
accessToken: string = '',
|
||||||
|
accessScope?: string[],
|
||||||
|
accessExpiry?: number,
|
||||||
|
refreshToken?: string,
|
||||||
|
refreshLocation?: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.accessScope = accessScope;
|
||||||
|
this.accessExpiry = accessExpiry;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.refreshLocation = refreshLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceIdentityOAuth): IdentityOAuth {
|
||||||
|
return new IdentityOAuth(
|
||||||
|
data.accessToken,
|
||||||
|
data.accessScope,
|
||||||
|
data.accessExpiry,
|
||||||
|
data.refreshToken,
|
||||||
|
data.refreshLocation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceIdentityOAuth {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
accessToken: this.accessToken,
|
||||||
|
...(this.accessScope && { accessScope: this.accessScope }),
|
||||||
|
...(this.accessExpiry && { accessExpiry: this.accessExpiry }),
|
||||||
|
...(this.refreshToken && { refreshToken: this.refreshToken }),
|
||||||
|
...(this.refreshLocation && { refreshLocation: this.refreshLocation })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpired(): boolean {
|
||||||
|
if (!this.accessExpiry) return false;
|
||||||
|
return Date.now() / 1000 >= this.accessExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresIn(): number {
|
||||||
|
if (!this.accessExpiry) return Infinity;
|
||||||
|
return Math.max(0, this.accessExpiry - Date.now() / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client certificate authentication (mTLS)
|
||||||
|
*/
|
||||||
|
export class IdentityCertificate extends Identity {
|
||||||
|
readonly type = 'CC' as const;
|
||||||
|
certificate: string;
|
||||||
|
privateKey: string;
|
||||||
|
passphrase?: string;
|
||||||
|
|
||||||
|
constructor(certificate: string = '', privateKey: string = '', passphrase?: string) {
|
||||||
|
super();
|
||||||
|
this.certificate = certificate;
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
this.passphrase = passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceIdentityCertificate): IdentityCertificate {
|
||||||
|
return new IdentityCertificate(
|
||||||
|
data.certificate,
|
||||||
|
data.privateKey,
|
||||||
|
data.passphrase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceIdentityCertificate {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
certificate: this.certificate,
|
||||||
|
privateKey: this.privateKey,
|
||||||
|
...(this.passphrase && { passphrase: this.passphrase })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/models/index.ts
Normal file
27
src/models/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Central export point for all Mail Manager models
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { CollectionObject } from './collection';
|
||||||
|
export { EntityObject } from './entity';
|
||||||
|
export { ProviderObject } from './provider';
|
||||||
|
export { ServiceObject } from './service';
|
||||||
|
|
||||||
|
// Identity models
|
||||||
|
export {
|
||||||
|
Identity,
|
||||||
|
IdentityNone,
|
||||||
|
IdentityBasic,
|
||||||
|
IdentityToken,
|
||||||
|
IdentityOAuth,
|
||||||
|
IdentityCertificate
|
||||||
|
} from './identity';
|
||||||
|
|
||||||
|
// Location models
|
||||||
|
export {
|
||||||
|
Location,
|
||||||
|
LocationUri,
|
||||||
|
LocationSocketSole,
|
||||||
|
LocationSocketSplit,
|
||||||
|
LocationFile
|
||||||
|
} from './location';
|
||||||
240
src/models/location.ts
Normal file
240
src/models/location.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Location implementation classes for Mail Manager services
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ServiceLocation,
|
||||||
|
ServiceLocationUri,
|
||||||
|
ServiceLocationSocketSole,
|
||||||
|
ServiceLocationSocketSplit,
|
||||||
|
ServiceLocationFile
|
||||||
|
} from '@/types/service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Location class
|
||||||
|
*/
|
||||||
|
export abstract class Location {
|
||||||
|
abstract toJson(): ServiceLocation;
|
||||||
|
|
||||||
|
static fromJson(data: ServiceLocation): Location {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'URI':
|
||||||
|
return LocationUri.fromJson(data);
|
||||||
|
case 'SOCKET_SOLE':
|
||||||
|
return LocationSocketSole.fromJson(data);
|
||||||
|
case 'SOCKET_SPLIT':
|
||||||
|
return LocationSocketSplit.fromJson(data);
|
||||||
|
case 'FILE':
|
||||||
|
return LocationFile.fromJson(data);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown location type: ${(data as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URI-based service location for API and web services
|
||||||
|
* Used by: JMAP, Gmail API, etc.
|
||||||
|
*/
|
||||||
|
export class LocationUri extends Location {
|
||||||
|
readonly type = 'URI' as const;
|
||||||
|
scheme: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
verifyPeer: boolean;
|
||||||
|
verifyHost: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scheme: string = 'https',
|
||||||
|
host: string = '',
|
||||||
|
port: number = 443,
|
||||||
|
path?: string,
|
||||||
|
verifyPeer: boolean = true,
|
||||||
|
verifyHost: boolean = true
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.scheme = scheme;
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.path = path;
|
||||||
|
this.verifyPeer = verifyPeer;
|
||||||
|
this.verifyHost = verifyHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceLocationUri): LocationUri {
|
||||||
|
return new LocationUri(
|
||||||
|
data.scheme,
|
||||||
|
data.host,
|
||||||
|
data.port,
|
||||||
|
data.path,
|
||||||
|
data.verifyPeer ?? true,
|
||||||
|
data.verifyHost ?? true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceLocationUri {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
scheme: this.scheme,
|
||||||
|
host: this.host,
|
||||||
|
port: this.port,
|
||||||
|
...(this.path && { path: this.path }),
|
||||||
|
...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }),
|
||||||
|
...(this.verifyHost !== undefined && { verifyHost: this.verifyHost })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrl(): string {
|
||||||
|
const path = this.path || '';
|
||||||
|
return `${this.scheme}://${this.host}:${this.port}${path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single socket-based service location
|
||||||
|
* Used by: services using a single host/port combination
|
||||||
|
*/
|
||||||
|
export class LocationSocketSole extends Location {
|
||||||
|
readonly type = 'SOCKET_SOLE' as const;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
encryption: 'none' | 'ssl' | 'tls' | 'starttls';
|
||||||
|
verifyPeer: boolean;
|
||||||
|
verifyHost: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: string = '',
|
||||||
|
port: number = 993,
|
||||||
|
encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl',
|
||||||
|
verifyPeer: boolean = true,
|
||||||
|
verifyHost: boolean = true
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.encryption = encryption;
|
||||||
|
this.verifyPeer = verifyPeer;
|
||||||
|
this.verifyHost = verifyHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceLocationSocketSole): LocationSocketSole {
|
||||||
|
return new LocationSocketSole(
|
||||||
|
data.host,
|
||||||
|
data.port,
|
||||||
|
data.encryption,
|
||||||
|
data.verifyPeer ?? true,
|
||||||
|
data.verifyHost ?? true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceLocationSocketSole {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
host: this.host,
|
||||||
|
port: this.port,
|
||||||
|
encryption: this.encryption,
|
||||||
|
...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }),
|
||||||
|
...(this.verifyHost !== undefined && { verifyHost: this.verifyHost })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split socket-based service location
|
||||||
|
* Used by: traditional IMAP/SMTP configurations
|
||||||
|
*/
|
||||||
|
export class LocationSocketSplit extends Location {
|
||||||
|
readonly type = 'SOCKET_SPLIT' as const;
|
||||||
|
inboundHost: string;
|
||||||
|
inboundPort: number;
|
||||||
|
inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls';
|
||||||
|
outboundHost: string;
|
||||||
|
outboundPort: number;
|
||||||
|
outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls';
|
||||||
|
inboundVerifyPeer: boolean;
|
||||||
|
inboundVerifyHost: boolean;
|
||||||
|
outboundVerifyPeer: boolean;
|
||||||
|
outboundVerifyHost: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
inboundHost: string = '',
|
||||||
|
inboundPort: number = 993,
|
||||||
|
inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl',
|
||||||
|
outboundHost: string = '',
|
||||||
|
outboundPort: number = 465,
|
||||||
|
outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl',
|
||||||
|
inboundVerifyPeer: boolean = true,
|
||||||
|
inboundVerifyHost: boolean = true,
|
||||||
|
outboundVerifyPeer: boolean = true,
|
||||||
|
outboundVerifyHost: boolean = true
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.inboundHost = inboundHost;
|
||||||
|
this.inboundPort = inboundPort;
|
||||||
|
this.inboundEncryption = inboundEncryption;
|
||||||
|
this.outboundHost = outboundHost;
|
||||||
|
this.outboundPort = outboundPort;
|
||||||
|
this.outboundEncryption = outboundEncryption;
|
||||||
|
this.inboundVerifyPeer = inboundVerifyPeer;
|
||||||
|
this.inboundVerifyHost = inboundVerifyHost;
|
||||||
|
this.outboundVerifyPeer = outboundVerifyPeer;
|
||||||
|
this.outboundVerifyHost = outboundVerifyHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit {
|
||||||
|
return new LocationSocketSplit(
|
||||||
|
data.inboundHost,
|
||||||
|
data.inboundPort,
|
||||||
|
data.inboundEncryption,
|
||||||
|
data.outboundHost,
|
||||||
|
data.outboundPort,
|
||||||
|
data.outboundEncryption,
|
||||||
|
data.inboundVerifyPeer ?? true,
|
||||||
|
data.inboundVerifyHost ?? true,
|
||||||
|
data.outboundVerifyPeer ?? true,
|
||||||
|
data.outboundVerifyHost ?? true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceLocationSocketSplit {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
inboundHost: this.inboundHost,
|
||||||
|
inboundPort: this.inboundPort,
|
||||||
|
inboundEncryption: this.inboundEncryption,
|
||||||
|
outboundHost: this.outboundHost,
|
||||||
|
outboundPort: this.outboundPort,
|
||||||
|
outboundEncryption: this.outboundEncryption,
|
||||||
|
...(this.inboundVerifyPeer !== undefined && { inboundVerifyPeer: this.inboundVerifyPeer }),
|
||||||
|
...(this.inboundVerifyHost !== undefined && { inboundVerifyHost: this.inboundVerifyHost }),
|
||||||
|
...(this.outboundVerifyPeer !== undefined && { outboundVerifyPeer: this.outboundVerifyPeer }),
|
||||||
|
...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-based service location
|
||||||
|
* Used by: local file system providers
|
||||||
|
*/
|
||||||
|
export class LocationFile extends Location {
|
||||||
|
readonly type = 'FILE' as const;
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
constructor(path: string = '') {
|
||||||
|
super();
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(data: ServiceLocationFile): LocationFile {
|
||||||
|
return new LocationFile(data.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceLocationFile {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
path: this.path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
376
src/models/message.ts
Normal file
376
src/models/message.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* Message and MessagePart model classes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MessageInterface, MessagePartInterface } from "@/types/message";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MessagePart class for working with message body parts
|
||||||
|
*/
|
||||||
|
export class MessagePartObject {
|
||||||
|
|
||||||
|
_data: MessagePartInterface;
|
||||||
|
|
||||||
|
constructor(data?: Partial<MessagePartInterface>) {
|
||||||
|
this._data = {
|
||||||
|
partId: data?.partId ?? null,
|
||||||
|
blobId: data?.blobId ?? null,
|
||||||
|
size: data?.size ?? null,
|
||||||
|
name: data?.name ?? null,
|
||||||
|
type: data?.type ?? undefined,
|
||||||
|
charset: data?.charset ?? null,
|
||||||
|
disposition: data?.disposition ?? null,
|
||||||
|
cid: data?.cid ?? null,
|
||||||
|
language: data?.language ?? null,
|
||||||
|
location: data?.location ?? null,
|
||||||
|
content: data?.content ?? undefined,
|
||||||
|
subParts: data?.subParts ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: MessagePartInterface): MessagePartObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): MessagePartInterface {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): MessagePartObject {
|
||||||
|
return new MessagePartObject(JSON.parse(JSON.stringify(this._data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Properties */
|
||||||
|
|
||||||
|
get partId(): string | null | undefined {
|
||||||
|
return this._data.partId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get blobId(): string | null | undefined {
|
||||||
|
return this._data.blobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number | null | undefined {
|
||||||
|
return this._data.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string | null | undefined {
|
||||||
|
return this._data.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): string | undefined {
|
||||||
|
return this._data.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
get charset(): string | null | undefined {
|
||||||
|
return this._data.charset;
|
||||||
|
}
|
||||||
|
|
||||||
|
get disposition(): string | null | undefined {
|
||||||
|
return this._data.disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cid(): string | null | undefined {
|
||||||
|
return this._data.cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
get language(): string | null | undefined {
|
||||||
|
return this._data.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
get location(): string | null | undefined {
|
||||||
|
return this._data.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
get content(): string | undefined {
|
||||||
|
return this._data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subParts(): MessagePartInterface[] | undefined {
|
||||||
|
return this._data.subParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper methods */
|
||||||
|
|
||||||
|
hasContent(): boolean {
|
||||||
|
return !!this._data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSubParts(): boolean {
|
||||||
|
return !!this._data.subParts && this._data.subParts.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMultipart(): boolean {
|
||||||
|
return this._data.type?.startsWith('multipart/') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isText(): boolean {
|
||||||
|
return this._data.type === 'text/plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
isHtml(): boolean {
|
||||||
|
return this._data.type === 'text/html';
|
||||||
|
}
|
||||||
|
|
||||||
|
isAttachment(): boolean {
|
||||||
|
return this._data.disposition === 'attachment';
|
||||||
|
}
|
||||||
|
|
||||||
|
isInline(): boolean {
|
||||||
|
return this._data.disposition === 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a part by partId (recursive search)
|
||||||
|
*/
|
||||||
|
findPartById(partId: string): MessagePartInterface | null {
|
||||||
|
if (this._data.partId === partId) {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._data.subParts) {
|
||||||
|
for (const subPart of this._data.subParts) {
|
||||||
|
const part = new MessagePartObject(subPart);
|
||||||
|
const found = part.findPartById(partId);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all parts of a specific type (recursive search)
|
||||||
|
*/
|
||||||
|
findPartsByType(type: string): MessagePartInterface[] {
|
||||||
|
const parts: MessagePartInterface[] = [];
|
||||||
|
|
||||||
|
if (this._data.type === type) {
|
||||||
|
parts.push(this._data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._data.subParts) {
|
||||||
|
for (const subPart of this._data.subParts) {
|
||||||
|
const part = new MessagePartObject(subPart);
|
||||||
|
parts.push(...part.findPartsByType(type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from body structure
|
||||||
|
*/
|
||||||
|
extractTextContent(): string | null {
|
||||||
|
if (this._data.type === 'text/plain' && this._data.content) {
|
||||||
|
return this._data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._data.subParts) {
|
||||||
|
for (const subPart of this._data.subParts) {
|
||||||
|
const part = new MessagePartObject(subPart);
|
||||||
|
const content = part.extractTextContent();
|
||||||
|
if (content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract HTML content from body structure
|
||||||
|
*/
|
||||||
|
extractHtmlContent(): string | null {
|
||||||
|
if (this._data.type === 'text/html' && this._data.content) {
|
||||||
|
return this._data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._data.subParts) {
|
||||||
|
for (const subPart of this._data.subParts) {
|
||||||
|
const part = new MessagePartObject(subPart);
|
||||||
|
const content = part.extractHtmlContent();
|
||||||
|
if (content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message class for working with message objects
|
||||||
|
*/
|
||||||
|
export class MessageObject {
|
||||||
|
|
||||||
|
_data: MessageInterface;
|
||||||
|
_body: MessagePartObject | null = null;
|
||||||
|
|
||||||
|
constructor(data?: Partial<MessageInterface>) {
|
||||||
|
this._data = {
|
||||||
|
urid: data?.urid ?? undefined,
|
||||||
|
size: data?.size ?? undefined,
|
||||||
|
receivedDate: data?.receivedDate ?? undefined,
|
||||||
|
date: data?.date ?? undefined,
|
||||||
|
subject: data?.subject ?? undefined,
|
||||||
|
snippet: data?.snippet ?? undefined,
|
||||||
|
from: data?.from ?? undefined,
|
||||||
|
to: data?.to ?? [],
|
||||||
|
cc: data?.cc ?? [],
|
||||||
|
bcc: data?.bcc ?? [],
|
||||||
|
replyTo: data?.replyTo ?? [],
|
||||||
|
flags: data?.flags ?? {},
|
||||||
|
body: data?.body ?? undefined,
|
||||||
|
attachments: data?.attachments ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: MessageInterface): MessageObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): MessageInterface {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): MessageObject {
|
||||||
|
return new MessageObject(JSON.parse(JSON.stringify(this._data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Properties */
|
||||||
|
|
||||||
|
get urid(): string | undefined {
|
||||||
|
return this._data.urid;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number | undefined {
|
||||||
|
return this._data.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get receivedDate(): string | undefined {
|
||||||
|
return this._data.receivedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
get date(): string | undefined {
|
||||||
|
return this._data.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subject(): string | undefined {
|
||||||
|
return this._data.subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
get snippet(): string | undefined {
|
||||||
|
return this._data.snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
get from(): { address: string; label?: string } | undefined {
|
||||||
|
return this._data.from;
|
||||||
|
}
|
||||||
|
|
||||||
|
get to(): Array<{ address: string; label?: string }> | undefined {
|
||||||
|
return this._data.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cc(): Array<{ address: string; label?: string }> | undefined {
|
||||||
|
return this._data.cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bcc(): Array<{ address: string; label?: string }> | undefined {
|
||||||
|
return this._data.bcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
get replyTo(): Array<{ address: string; label?: string }> | undefined {
|
||||||
|
return this._data.replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | undefined {
|
||||||
|
return this._data.flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
get body(): MessagePartInterface | undefined {
|
||||||
|
return this._data.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
get attachments(): MessageInterface['attachments'] {
|
||||||
|
return this._data.attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper methods */
|
||||||
|
|
||||||
|
get isRead(): boolean {
|
||||||
|
return this._data.flags?.read ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFlagged(): boolean {
|
||||||
|
return this._data.flags?.flagged ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAnswered(): boolean {
|
||||||
|
return this._data.flags?.answered ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDraft(): boolean {
|
||||||
|
return this._data.flags?.draft ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasAttachments(): boolean {
|
||||||
|
return (this._data.attachments?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRecipients(): boolean {
|
||||||
|
return (this._data.to?.length ?? 0) > 0
|
||||||
|
|| (this._data.cc?.length ?? 0) > 0
|
||||||
|
|| (this._data.bcc?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body content helpers */
|
||||||
|
|
||||||
|
getBody(): MessagePartObject | null {
|
||||||
|
if (!this._body && this._data.body) {
|
||||||
|
this._body = new MessagePartObject(this._data.body);
|
||||||
|
}
|
||||||
|
return this._body;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent(): boolean {
|
||||||
|
return !!this.getTextContent() || !!this.getHtmlContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTextContent(): boolean {
|
||||||
|
return !!this.getTextContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextContent(): string | null {
|
||||||
|
const bodyPart = this.getBody();
|
||||||
|
return bodyPart ? bodyPart.extractTextContent() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasHtmlContent(): boolean {
|
||||||
|
return !!this.getHtmlContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHtmlContent(): string | null {
|
||||||
|
const bodyPart = this.getBody();
|
||||||
|
return bodyPart ? bodyPart.extractHtmlContent() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPartById(partId: string): MessagePartInterface | null {
|
||||||
|
const bodyPart = this.getBody();
|
||||||
|
return bodyPart ? bodyPart.findPartById(partId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPartsByType(type: string): MessagePartInterface[] {
|
||||||
|
const bodyPart = this.getBody();
|
||||||
|
return bodyPart ? bodyPart.findPartsByType(type) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
62
src/models/provider.ts
Normal file
62
src/models/provider.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Class model for Provider Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderInterface,
|
||||||
|
ProviderCapabilitiesInterface
|
||||||
|
} from "@/types/provider";
|
||||||
|
|
||||||
|
export class ProviderObject implements ProviderInterface {
|
||||||
|
|
||||||
|
_data!: ProviderInterface;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._data = {
|
||||||
|
'@type': 'mail.provider',
|
||||||
|
identifier: '',
|
||||||
|
label: '',
|
||||||
|
capabilities: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: ProviderInterface): ProviderObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ProviderInterface {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
capable(capability: keyof ProviderCapabilitiesInterface): boolean {
|
||||||
|
const value = this._data.capabilities?.[capability];
|
||||||
|
return value !== undefined && value !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
capability(capability: keyof ProviderCapabilitiesInterface): any | null {
|
||||||
|
if (this._data.capabilities) {
|
||||||
|
return this._data.capabilities[capability];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable Properties */
|
||||||
|
|
||||||
|
get '@type'(): string {
|
||||||
|
return this._data['@type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get identifier(): string {
|
||||||
|
return this._data.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get label(): string {
|
||||||
|
return this._data.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
get capabilities(): ProviderCapabilitiesInterface {
|
||||||
|
return this._data.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
136
src/models/service.ts
Normal file
136
src/models/service.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Class model for Service Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ServiceInterface,
|
||||||
|
ServiceCapabilitiesInterface,
|
||||||
|
ServiceIdentity,
|
||||||
|
ServiceLocation
|
||||||
|
} from "@/types/service";
|
||||||
|
import { Identity } from './identity';
|
||||||
|
import { Location } from './location';
|
||||||
|
|
||||||
|
export class ServiceObject implements ServiceInterface {
|
||||||
|
|
||||||
|
_data!: ServiceInterface;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._data = {
|
||||||
|
'@type': 'mail:service',
|
||||||
|
provider: '',
|
||||||
|
identifier: null,
|
||||||
|
label: null,
|
||||||
|
enabled: false,
|
||||||
|
capabilities: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJson(data: ServiceInterface): ServiceObject {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): ServiceInterface {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
capable(capability: keyof ServiceCapabilitiesInterface): boolean {
|
||||||
|
const value = this._data.capabilities?.[capability];
|
||||||
|
return value !== undefined && value !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
capability(capability: keyof ServiceCapabilitiesInterface): any | null {
|
||||||
|
if (this._data.capabilities) {
|
||||||
|
return this._data.capabilities[capability];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable Properties */
|
||||||
|
|
||||||
|
get '@type'(): string {
|
||||||
|
return this._data['@type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get provider(): string {
|
||||||
|
return this._data.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identifier(): string | number | null {
|
||||||
|
return this._data.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get capabilities(): ServiceCapabilitiesInterface | undefined {
|
||||||
|
return this._data.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
get primaryAddress(): string | null {
|
||||||
|
return this._data.primaryAddress ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get secondaryAddresses(): string[] {
|
||||||
|
return this._data.secondaryAddresses ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable Properties */
|
||||||
|
|
||||||
|
get label(): string | null {
|
||||||
|
return this._data.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
set label(value: string | null) {
|
||||||
|
this._data.label = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this._data.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
this._data.enabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get location(): ServiceLocation | null {
|
||||||
|
return this._data.location ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set location(value: ServiceLocation | null) {
|
||||||
|
this._data.location = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identity(): ServiceIdentity | null {
|
||||||
|
return this._data.identity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set identity(value: ServiceIdentity | null) {
|
||||||
|
this._data.identity = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get auxiliary(): Record<string, any> {
|
||||||
|
return this._data.auxiliary ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
set auxiliary(value: Record<string, any>) {
|
||||||
|
this._data.auxiliary = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper Methods */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get identity as a class instance for easier manipulation
|
||||||
|
*/
|
||||||
|
getIdentity(): Identity | null {
|
||||||
|
if (!this._data.identity) return null;
|
||||||
|
return Identity.fromJson(this._data.identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location as a class instance for easier manipulation
|
||||||
|
*/
|
||||||
|
getLocation(): Location | null {
|
||||||
|
if (!this._data.location) return null;
|
||||||
|
return Location.fromJson(this._data.location);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
375
src/pages/AccountsPage.vue
Normal file
375
src/pages/AccountsPage.vue
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useServicesStore } from '@/stores/servicesStore'
|
||||||
|
import AddAccountDialog from '@/components/AddAccountDialog.vue'
|
||||||
|
import type { ServiceObject } from '@/models'
|
||||||
|
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const showTestResult = ref(false)
|
||||||
|
const selectedAccount = ref<any>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const testingId = ref<string | null>(null)
|
||||||
|
const testResult = ref<any>(null)
|
||||||
|
|
||||||
|
const groupedServices = computed(() => servicesStore.servicesByProvider)
|
||||||
|
|
||||||
|
const hasAccounts = computed(() => servicesStore.has)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await servicesStore.list()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getProviderIcon(providerId: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'smtp': 'mdi-email-multiple',
|
||||||
|
'jmap': 'mdi-api',
|
||||||
|
'exchange': 'mdi-microsoft',
|
||||||
|
}
|
||||||
|
return icons[providerId] || 'mdi-email'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderLabel(providerId: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'smtp': 'SMTP/IMAP',
|
||||||
|
'jmap': 'JMAP',
|
||||||
|
'exchange': 'Microsoft Exchange',
|
||||||
|
}
|
||||||
|
return labels[providerId] || providerId.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function editAccount(account: any) {
|
||||||
|
selectedAccount.value = { ...account }
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await servicesStore.update(
|
||||||
|
selectedAccount.value.provider,
|
||||||
|
selectedAccount.value.identifier,
|
||||||
|
selectedAccount.value
|
||||||
|
)
|
||||||
|
showEditDialog.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update account:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(account: any) {
|
||||||
|
selectedAccount.value = account
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await servicesStore.delete(
|
||||||
|
selectedAccount.value.provider,
|
||||||
|
selectedAccount.value.identifier
|
||||||
|
)
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
selectedAccount.value = {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete account:', error)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAccount(service: ServiceObject) {
|
||||||
|
try {
|
||||||
|
const result = await servicesStore.test(
|
||||||
|
service.provider,
|
||||||
|
service.identifier,
|
||||||
|
service.location,
|
||||||
|
service.identity
|
||||||
|
)
|
||||||
|
testResult.value = result
|
||||||
|
showTestResult.value = true
|
||||||
|
} catch (error: any) {
|
||||||
|
testResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Connection test failed'
|
||||||
|
}
|
||||||
|
showTestResult.value = true
|
||||||
|
} finally {
|
||||||
|
testingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccountSaved() {
|
||||||
|
await servicesStore.list()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid>
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-1">Mail Accounts</h1>
|
||||||
|
<p class="text-body-2 text-medium-emphasis">
|
||||||
|
Manage your email accounts across all providers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
@click="showAddDialog = true"
|
||||||
|
>
|
||||||
|
Add Account
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<v-row v-if="loading" class="mt-8">
|
||||||
|
<v-col
|
||||||
|
v-for="i in 3"
|
||||||
|
:key="i"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<v-skeleton-loader type="card" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<v-card
|
||||||
|
v-else-if="!hasAccounts"
|
||||||
|
class="text-center pa-12"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="80"
|
||||||
|
color="grey-lighten-1"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
mdi-email-off-outline
|
||||||
|
</v-icon>
|
||||||
|
<h2 class="text-h5 mb-2">No Mail Accounts</h2>
|
||||||
|
<p class="text-body-1 text-medium-emphasis mb-6">
|
||||||
|
Add your first mail account to start sending and receiving emails
|
||||||
|
</p>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
@click="showAddDialog = true"
|
||||||
|
>
|
||||||
|
Add Your First Account
|
||||||
|
</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Accounts by Provider -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="(services, providerId) in groupedServices"
|
||||||
|
:key="providerId"
|
||||||
|
class="mb-8"
|
||||||
|
>
|
||||||
|
<!-- Provider Header -->
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-icon
|
||||||
|
:icon="getProviderIcon(providerId)"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
<h2 class="text-h6">
|
||||||
|
{{ getProviderLabel(providerId) }}
|
||||||
|
</h2>
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
{{ services.length }} account{{ services.length !== 1 ? 's' : '' }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Cards -->
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
v-for="service in services"
|
||||||
|
:key="`${service.provider}:${service.identifier}`"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
:class="{ 'border-error': !service.enabled }"
|
||||||
|
variant="outlined"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-2">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar
|
||||||
|
size="40"
|
||||||
|
:color="service.enabled ? 'primary' : 'grey'"
|
||||||
|
class="mr-3"
|
||||||
|
>
|
||||||
|
<v-icon color="white">
|
||||||
|
{{ service.enabled ? 'mdi-email' : 'mdi-email-off' }}
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-h6">{{ service.label }}</h3>
|
||||||
|
<p class="text-caption text-medium-emphasis">
|
||||||
|
{{ service.primaryAddress || (service.identity?.type === 'BA' ? service.identity.identity : 'No email configured') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-chip
|
||||||
|
:color="service.enabled ? 'success' : 'error'"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ service.enabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Stats/Info -->
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
<div class="d-flex align-center mb-1">
|
||||||
|
<v-icon size="small" class="mr-1">mdi-identifier</v-icon>
|
||||||
|
ID: {{ service.identifier }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
@click="editAccount(service)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-connection"
|
||||||
|
:loading="testingId === service.identifier"
|
||||||
|
@click="testAccount(service)"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
icon="mdi-delete"
|
||||||
|
@click="confirmDelete(service)"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Add Account Dialog -->
|
||||||
|
<AddAccountDialog
|
||||||
|
v-model="showAddDialog"
|
||||||
|
@saved="handleAccountSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit Account Dialog -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showEditDialog"
|
||||||
|
max-width="600"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Edit Account</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="selectedAccount.label"
|
||||||
|
label="Account Name"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-switch
|
||||||
|
v-model="selectedAccount.enabled"
|
||||||
|
label="Enable this account"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showEditDialog = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="saveEdit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDeleteConfirm"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6">Delete Account?</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Are you sure you want to delete <strong>{{ selectedAccount?.label }}</strong>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showDeleteConfirm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
:loading="deleting"
|
||||||
|
@click="deleteAccount"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Test Result Snackbar -->
|
||||||
|
<v-snackbar
|
||||||
|
v-model="showTestResult"
|
||||||
|
:color="testResult?.success ? 'success' : 'error'"
|
||||||
|
:timeout="5000"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ testResult?.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
|
||||||
|
</v-icon>
|
||||||
|
{{ testResult?.message || 'Connection test completed' }}
|
||||||
|
</v-snackbar>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
13
src/routes.ts
Normal file
13
src/routes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
name: 'mail-accounts',
|
||||||
|
path: '/accounts',
|
||||||
|
component: () => import('@/pages/AccountsPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Mail Accounts',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default routes
|
||||||
84
src/services/collectionService.ts
Normal file
84
src/services/collectionService.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Collection management service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { transceivePost } from './transceive';
|
||||||
|
import type {
|
||||||
|
CollectionListRequest,
|
||||||
|
CollectionListResponse,
|
||||||
|
CollectionExtantRequest,
|
||||||
|
CollectionExtantResponse,
|
||||||
|
CollectionFetchRequest,
|
||||||
|
CollectionFetchResponse,
|
||||||
|
CollectionCreateRequest,
|
||||||
|
CollectionCreateResponse,
|
||||||
|
CollectionModifyRequest,
|
||||||
|
CollectionModifyResponse,
|
||||||
|
CollectionDestroyRequest,
|
||||||
|
CollectionDestroyResponse
|
||||||
|
} from '../types/collection';
|
||||||
|
|
||||||
|
export const collectionService = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available collections
|
||||||
|
*
|
||||||
|
* @param request - Collection list request parameters
|
||||||
|
* @returns Promise with collection list grouped by provider and service
|
||||||
|
*/
|
||||||
|
async list(request: CollectionListRequest = {}): Promise<CollectionListResponse> {
|
||||||
|
return await transceivePost<CollectionListRequest, CollectionListResponse>('collection.list', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which collections exist/are available
|
||||||
|
*
|
||||||
|
* @param request - Collection extant request with source selector
|
||||||
|
* @returns Promise with collection availability status
|
||||||
|
*/
|
||||||
|
async extant(request: CollectionExtantRequest): Promise<CollectionExtantResponse> {
|
||||||
|
return await transceivePost<CollectionExtantRequest, CollectionExtantResponse>('collection.extant', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific collection
|
||||||
|
*
|
||||||
|
* @param request - Collection fetch request
|
||||||
|
* @returns Promise with collection details
|
||||||
|
*/
|
||||||
|
async fetch(request: CollectionFetchRequest): Promise<CollectionFetchResponse> {
|
||||||
|
return await transceivePost<CollectionFetchRequest, CollectionFetchResponse>('collection.fetch', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new collection/folder
|
||||||
|
*
|
||||||
|
* @param request - Collection creation parameters
|
||||||
|
* @returns Promise with created collection details
|
||||||
|
*/
|
||||||
|
async create(request: CollectionCreateRequest): Promise<CollectionCreateResponse> {
|
||||||
|
return await transceivePost<CollectionCreateRequest, CollectionCreateResponse>('collection.create', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify an existing collection/folder
|
||||||
|
*
|
||||||
|
* @param request - Collection modification parameters
|
||||||
|
* @returns Promise with modified collection details
|
||||||
|
*/
|
||||||
|
async modify(request: CollectionModifyRequest): Promise<CollectionModifyResponse> {
|
||||||
|
return await transceivePost<CollectionModifyRequest, CollectionModifyResponse>('collection.modify', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy/delete a collection/folder
|
||||||
|
*
|
||||||
|
* @param request - Collection destroy parameters
|
||||||
|
* @returns Promise with destroy operation result
|
||||||
|
*/
|
||||||
|
async destroy(request: CollectionDestroyRequest): Promise<CollectionDestroyResponse> {
|
||||||
|
return await transceivePost<CollectionDestroyRequest, CollectionDestroyResponse>('collection.destroy', request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default collectionService;
|
||||||
120
src/services/entityService.ts
Normal file
120
src/services/entityService.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Message/Entity management service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { transceivePost } from './transceive';
|
||||||
|
import type {
|
||||||
|
MessageListRequest,
|
||||||
|
MessageListResponse,
|
||||||
|
MessageDeltaRequest,
|
||||||
|
MessageDeltaResponse,
|
||||||
|
MessageExtantRequest,
|
||||||
|
MessageExtantResponse,
|
||||||
|
MessageFetchRequest,
|
||||||
|
MessageFetchResponse,
|
||||||
|
MessageSearchRequest,
|
||||||
|
MessageSearchResponse,
|
||||||
|
MessageSendRequest,
|
||||||
|
MessageSendResponse,
|
||||||
|
MessageCreateRequest,
|
||||||
|
MessageCreateResponse,
|
||||||
|
MessageUpdateRequest,
|
||||||
|
MessageUpdateResponse,
|
||||||
|
MessageDestroyRequest,
|
||||||
|
MessageDestroyResponse,
|
||||||
|
} from '../types/entity';
|
||||||
|
|
||||||
|
export const entityService = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available messages
|
||||||
|
*
|
||||||
|
* @param request - Message list request parameters
|
||||||
|
* @returns Promise with message list grouped by provider, service, and collection
|
||||||
|
*/
|
||||||
|
async list(request: MessageListRequest = {}): Promise<MessageListResponse> {
|
||||||
|
return await transceivePost<MessageListRequest, MessageListResponse>('entity.list', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delta changes for messages
|
||||||
|
*
|
||||||
|
* @param request - Message delta request with source selector
|
||||||
|
* @returns Promise with delta changes (created, modified, deleted)
|
||||||
|
*/
|
||||||
|
async delta(request: MessageDeltaRequest): Promise<MessageDeltaResponse> {
|
||||||
|
return await transceivePost('entity.delta', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which messages exist/are available
|
||||||
|
*
|
||||||
|
* @param request - Message extant request with source selector
|
||||||
|
* @returns Promise with message availability status
|
||||||
|
*/
|
||||||
|
async extant(request: MessageExtantRequest): Promise<MessageExtantResponse> {
|
||||||
|
return await transceivePost('entity.extant', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch specific messages
|
||||||
|
*
|
||||||
|
* @param request - Message fetch request
|
||||||
|
* @returns Promise with message details
|
||||||
|
*/
|
||||||
|
async fetch(request: MessageFetchRequest): Promise<MessageFetchResponse> {
|
||||||
|
return await transceivePost('entity.fetch', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search messages
|
||||||
|
*
|
||||||
|
* @param request - Message search request
|
||||||
|
* @returns Promise with search results
|
||||||
|
*/
|
||||||
|
async search(request: MessageSearchRequest): Promise<MessageSearchResponse> {
|
||||||
|
return await transceivePost('entity.search', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message
|
||||||
|
*
|
||||||
|
* @param request - Message send request
|
||||||
|
* @returns Promise with send result
|
||||||
|
*/
|
||||||
|
async send(request: MessageSendRequest): Promise<MessageSendResponse> {
|
||||||
|
return await transceivePost('entity.send', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new message (draft)
|
||||||
|
*
|
||||||
|
* @param request - Message create request
|
||||||
|
* @returns Promise with created message details
|
||||||
|
*/
|
||||||
|
async create(request: MessageCreateRequest): Promise<MessageCreateResponse> {
|
||||||
|
return await transceivePost('entity.create', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing message (flags, labels, etc.)
|
||||||
|
*
|
||||||
|
* @param request - Message update request
|
||||||
|
* @returns Promise with update result
|
||||||
|
*/
|
||||||
|
async update(request: MessageUpdateRequest): Promise<MessageUpdateResponse> {
|
||||||
|
return await transceivePost('entity.update', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete/destroy a message
|
||||||
|
*
|
||||||
|
* @param request - Message destroy request
|
||||||
|
* @returns Promise with destroy result
|
||||||
|
*/
|
||||||
|
async destroy(request: MessageDestroyRequest): Promise<MessageDestroyResponse> {
|
||||||
|
return await transceivePost('entity.destroy', request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default entityService;
|
||||||
16
src/services/index.ts
Normal file
16
src/services/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Central export point for all Mail Manager services
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export { providerService } from './providerService';
|
||||||
|
export { serviceService } from './serviceService';
|
||||||
|
export { collectionService } from './collectionService';
|
||||||
|
export { entityService } from './entityService';
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type * from '../types/common';
|
||||||
|
export type * from '../types/provider';
|
||||||
|
export type * from '../types/service';
|
||||||
|
export type * from '../types/collection';
|
||||||
|
export type * from '../types/entity';
|
||||||
62
src/services/providerService.ts
Normal file
62
src/services/providerService.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Provider management service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderListRequest,
|
||||||
|
ProviderListResponse,
|
||||||
|
ProviderExtantRequest,
|
||||||
|
ProviderExtantResponse,
|
||||||
|
ProviderFetchRequest,
|
||||||
|
ProviderFetchResponse,
|
||||||
|
} from '../types/provider';
|
||||||
|
import { transceivePost } from './transceive';
|
||||||
|
import { ProviderObject } from '../models/provider';
|
||||||
|
|
||||||
|
|
||||||
|
export const providerService = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available providers
|
||||||
|
*
|
||||||
|
* @param request - List request parameters
|
||||||
|
*
|
||||||
|
* @returns Promise with provider object list keyed by provider identifier
|
||||||
|
*/
|
||||||
|
async list(request: ProviderListRequest = {}): Promise<Record<string, ProviderObject>> {
|
||||||
|
const response = await transceivePost<ProviderListRequest, ProviderListResponse>('provider.list', request);
|
||||||
|
|
||||||
|
// Convert response to ProviderObject instances
|
||||||
|
const list: Record<string, ProviderObject> = {};
|
||||||
|
Object.entries(response).forEach(([providerId, providerData]) => {
|
||||||
|
list[providerId] = new ProviderObject().fromJson(providerData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific provider
|
||||||
|
*
|
||||||
|
* @param request - Fetch request parameters
|
||||||
|
*
|
||||||
|
* @returns Promise with provider object
|
||||||
|
*/
|
||||||
|
async fetch(request: ProviderFetchRequest): Promise<ProviderObject> {
|
||||||
|
const response = await transceivePost<ProviderFetchRequest, ProviderFetchResponse>('provider.fetch', request);
|
||||||
|
return new ProviderObject().fromJson(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which providers exist/are available
|
||||||
|
*
|
||||||
|
* @param request - Extant request parameters
|
||||||
|
*
|
||||||
|
* @returns Promise with provider availability status
|
||||||
|
*/
|
||||||
|
async extant(request: ProviderExtantRequest): Promise<ProviderExtantResponse> {
|
||||||
|
return await transceivePost<ProviderExtantRequest, ProviderExtantResponse>('provider.extant', request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default providerService;
|
||||||
154
src/services/serviceService.ts
Normal file
154
src/services/serviceService.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Service management service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ServiceListRequest,
|
||||||
|
ServiceListResponse,
|
||||||
|
ServiceExtantRequest,
|
||||||
|
ServiceExtantResponse,
|
||||||
|
ServiceFetchRequest,
|
||||||
|
ServiceFetchResponse,
|
||||||
|
ServiceDiscoverRequest,
|
||||||
|
ServiceDiscoverResponse,
|
||||||
|
ServiceTestRequest,
|
||||||
|
ServiceTestResponse,
|
||||||
|
ServiceInterface,
|
||||||
|
ServiceCreateResponse,
|
||||||
|
ServiceCreateRequest,
|
||||||
|
ServiceUpdateResponse,
|
||||||
|
ServiceUpdateRequest,
|
||||||
|
} from '../types/service';
|
||||||
|
import { transceivePost } from './transceive';
|
||||||
|
import { ServiceObject } from '../models/service';
|
||||||
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create the right service model class based on provider
|
||||||
|
* Uses provider-specific factory if available, otherwise returns base ServiceObject
|
||||||
|
*/
|
||||||
|
function createServiceObject(data: ServiceInterface): ServiceObject {
|
||||||
|
const integrationStore = useIntegrationStore();
|
||||||
|
const factoryItem = integrationStore.getItemById('mail_service_factory', data.provider) as any;
|
||||||
|
const factory = factoryItem?.factory;
|
||||||
|
|
||||||
|
// Use provider factory if available, otherwise base class
|
||||||
|
return factory ? factory(data) : new ServiceObject().fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serviceService = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available services
|
||||||
|
*
|
||||||
|
* @param request - Service list request parameters
|
||||||
|
*
|
||||||
|
* @returns Promise with service object list grouped by provider and keyed by service identifier
|
||||||
|
*/
|
||||||
|
async list(request: ServiceListRequest = {}): Promise<Record<string, Record<string, ServiceObject>>> {
|
||||||
|
const response = await transceivePost<ServiceListRequest, ServiceListResponse>('service.list', request);
|
||||||
|
|
||||||
|
// Convert nested response to ServiceObject instances
|
||||||
|
const list: Record<string, Record<string, ServiceObject>> = {};
|
||||||
|
Object.entries(response).forEach(([providerId, providerServices]) => {
|
||||||
|
list[providerId] = {};
|
||||||
|
Object.entries(providerServices).forEach(([serviceId, serviceData]) => {
|
||||||
|
list[providerId][serviceId] = createServiceObject(serviceData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which services exist/are available
|
||||||
|
*
|
||||||
|
* @param request - Service extant request with source selector
|
||||||
|
* @returns Promise with service availability status
|
||||||
|
*/
|
||||||
|
async extant(request: ServiceExtantRequest): Promise<ServiceExtantResponse> {
|
||||||
|
return await transceivePost<ServiceExtantRequest, ServiceExtantResponse>('service.extant', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific service
|
||||||
|
*
|
||||||
|
* @param request - Service fetch request with provider and service IDs
|
||||||
|
* @returns Promise with service object
|
||||||
|
*/
|
||||||
|
async fetch(request: ServiceFetchRequest): Promise<ServiceObject> {
|
||||||
|
const response = await transceivePost<ServiceFetchRequest, ServiceFetchResponse>('service.fetch', request);
|
||||||
|
return createServiceObject(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover mail service configuration from identity
|
||||||
|
*
|
||||||
|
* @param request - Discovery request with identity and optional hints
|
||||||
|
* @returns Promise with array of discovered services sorted by provider
|
||||||
|
*/
|
||||||
|
async discover(request: ServiceDiscoverRequest): Promise<ServiceObject[]> {
|
||||||
|
const response = await transceivePost<ServiceDiscoverRequest, ServiceDiscoverResponse>('service.discover', request);
|
||||||
|
|
||||||
|
// Convert discovery results to ServiceObjects
|
||||||
|
const services: ServiceObject[] = [];
|
||||||
|
Object.entries(response).forEach(([providerId, location]) => {
|
||||||
|
const serviceData: ServiceInterface = {
|
||||||
|
'@type': 'mail:service',
|
||||||
|
provider: providerId,
|
||||||
|
identifier: null,
|
||||||
|
label: null,
|
||||||
|
enabled: false,
|
||||||
|
location: location,
|
||||||
|
};
|
||||||
|
services.push(createServiceObject(serviceData));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by provider
|
||||||
|
return services.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a mail service connection
|
||||||
|
*
|
||||||
|
* @param request - Service test request
|
||||||
|
* @returns Promise with test results
|
||||||
|
*/
|
||||||
|
async test(request: ServiceTestRequest): Promise<ServiceTestResponse> {
|
||||||
|
return await transceivePost<ServiceTestRequest, ServiceTestResponse>('service.test', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new service
|
||||||
|
*
|
||||||
|
* @param request - Service create request with provider ID and service data
|
||||||
|
* @returns Promise with created service object
|
||||||
|
*/
|
||||||
|
async create(request: ServiceCreateRequest): Promise<ServiceObject> {
|
||||||
|
const response = await transceivePost<ServiceCreateRequest, ServiceCreateResponse>('service.create', request);
|
||||||
|
return createServiceObject(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a existing service
|
||||||
|
*
|
||||||
|
* @param request - Service update request with provider ID, service ID, and updated data
|
||||||
|
* @returns Promise with updated service object
|
||||||
|
*/
|
||||||
|
async update(request: ServiceUpdateRequest): Promise<ServiceObject> {
|
||||||
|
const response = await transceivePost<ServiceUpdateRequest, ServiceUpdateResponse>('service.update', request);
|
||||||
|
return createServiceObject(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a service
|
||||||
|
*
|
||||||
|
* @param request - Service delete request with provider ID and service ID
|
||||||
|
* @returns Promise with deletion result
|
||||||
|
*/
|
||||||
|
async delete(request: { provider: string; identifier: string | number }): Promise<any> {
|
||||||
|
return await transceivePost('service.delete', request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default serviceService;
|
||||||
50
src/services/transceive.ts
Normal file
50
src/services/transceive.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* API Client for Mail Manager
|
||||||
|
* Provides a centralized way to make API calls with envelope wrapping/unwrapping
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core';
|
||||||
|
import type { ApiRequest, ApiResponse } from '../types/common';
|
||||||
|
|
||||||
|
const fetchWrapper = createFetchWrapper();
|
||||||
|
const API_URL = '/m/mail_manager/v1';
|
||||||
|
const API_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique transaction ID
|
||||||
|
*/
|
||||||
|
export function generateTransactionId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an API call with automatic envelope wrapping and unwrapping
|
||||||
|
*
|
||||||
|
* @param operation - Operation name (e.g., 'provider.list', 'service.autodiscover')
|
||||||
|
* @param data - Operation-specific request data
|
||||||
|
* @param user - Optional user identifier override
|
||||||
|
* @returns Promise with unwrapped response data
|
||||||
|
* @throws Error if the API returns an error status
|
||||||
|
*/
|
||||||
|
export async function transceivePost<TRequest, TResponse>(
|
||||||
|
operation: string,
|
||||||
|
data: TRequest,
|
||||||
|
user?: string
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const request: ApiRequest<TRequest> = {
|
||||||
|
version: API_VERSION,
|
||||||
|
transaction: generateTransactionId(),
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: ApiResponse<TResponse> = await fetchWrapper.post(API_URL, request);
|
||||||
|
|
||||||
|
if (response.status === 'error') {
|
||||||
|
const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
169
src/stores/collectionsStore.ts
Normal file
169
src/stores/collectionsStore.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { collectionService } from '../services'
|
||||||
|
import type { CollectionInterface, CollectionCreateRequest } from '../types'
|
||||||
|
import { CollectionObject, CollectionPropertiesObject } from '../models/collection'
|
||||||
|
|
||||||
|
export const useCollectionsStore = defineStore('mail-collections', {
|
||||||
|
state: () => ({
|
||||||
|
collections: {} as Record<string, Record<string, Record<string, CollectionObject>>>,
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadCollections(sources?: any) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await collectionService.list({ sources })
|
||||||
|
|
||||||
|
// Response is already in nested object format: provider -> service -> collection
|
||||||
|
// Transform to CollectionObject instances
|
||||||
|
const transformed: Record<string, Record<string, Record<string, CollectionObject>>> = {}
|
||||||
|
|
||||||
|
for (const [providerId, providerData] of Object.entries(response)) {
|
||||||
|
transformed[providerId] = {}
|
||||||
|
|
||||||
|
for (const [serviceId, collections] of Object.entries(providerData as any)) {
|
||||||
|
transformed[providerId][serviceId] = {}
|
||||||
|
|
||||||
|
// Collections come as an object keyed by identifier
|
||||||
|
for (const [collectionId, collection] of Object.entries(collections as any)) {
|
||||||
|
// Create CollectionObject instance with provider and service set
|
||||||
|
const collectionData = {
|
||||||
|
...collection,
|
||||||
|
provider: providerId,
|
||||||
|
service: serviceId,
|
||||||
|
} as CollectionInterface
|
||||||
|
|
||||||
|
transformed[providerId][serviceId][collectionId] = new CollectionObject().fromJson(collectionData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.collections = transformed
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(provider: string, service: string | number, collectionId: string | number) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await collectionService.fetch({
|
||||||
|
provider,
|
||||||
|
service,
|
||||||
|
collection: collectionId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create CollectionObject instance
|
||||||
|
const collectionObject = new CollectionObject().fromJson(response)
|
||||||
|
|
||||||
|
// Update in store
|
||||||
|
if (!this.collections[provider]) {
|
||||||
|
this.collections[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.collections[provider][String(service)]) {
|
||||||
|
this.collections[provider][String(service)] = {}
|
||||||
|
}
|
||||||
|
this.collections[provider][String(service)][String(collectionId)] = collectionObject
|
||||||
|
|
||||||
|
return collectionObject
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCollection(params: {
|
||||||
|
provider: string
|
||||||
|
service: string | number
|
||||||
|
collection?: string | number | null
|
||||||
|
properties: CollectionPropertiesObject
|
||||||
|
}): Promise<CollectionObject> {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare request data from CollectionPropertiesObject
|
||||||
|
const requestData: CollectionCreateRequest = {
|
||||||
|
provider: params.provider,
|
||||||
|
service: params.service,
|
||||||
|
collection: params.collection ?? null,
|
||||||
|
properties: {
|
||||||
|
'@type': 'mail.collection',
|
||||||
|
label: params.properties.label,
|
||||||
|
role: params.properties.role ?? null,
|
||||||
|
rank: params.properties.rank ?? 0,
|
||||||
|
subscribed: params.properties.subscribed ?? true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service to create collection
|
||||||
|
const response = await collectionService.create(requestData)
|
||||||
|
|
||||||
|
// Create CollectionObject instance
|
||||||
|
const collectionObject = new CollectionObject().fromJson(response)
|
||||||
|
|
||||||
|
// Update store with new collection
|
||||||
|
const provider = response.provider
|
||||||
|
const service = String(response.service)
|
||||||
|
const identifier = String(response.identifier)
|
||||||
|
|
||||||
|
if (!this.collections[provider]) {
|
||||||
|
this.collections[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.collections[provider][service]) {
|
||||||
|
this.collections[provider][service] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.collections[provider][service][identifier] = collectionObject
|
||||||
|
|
||||||
|
return collectionObject
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
collectionList: (state) => {
|
||||||
|
const list: CollectionObject[] = []
|
||||||
|
Object.values(state.collections).forEach(providerCollections => {
|
||||||
|
Object.values(providerCollections).forEach(serviceCollections => {
|
||||||
|
Object.values(serviceCollections).forEach(collection => {
|
||||||
|
list.push(collection)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
|
||||||
|
collectionCount: (state) => {
|
||||||
|
let count = 0
|
||||||
|
Object.values(state.collections).forEach(providerCollections => {
|
||||||
|
Object.values(providerCollections).forEach(serviceCollections => {
|
||||||
|
count += Object.keys(serviceCollections).length
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
},
|
||||||
|
|
||||||
|
hasCollections: (state) => {
|
||||||
|
return Object.values(state.collections).some(providerCollections =>
|
||||||
|
Object.values(providerCollections).some(serviceCollections =>
|
||||||
|
Object.keys(serviceCollections).length > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
261
src/stores/entitiesStore.ts
Normal file
261
src/stores/entitiesStore.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { entityService } from '../services'
|
||||||
|
import type { MessageObject, EntityWrapper, MessageSendRequest } from '../types'
|
||||||
|
|
||||||
|
export const useEntitiesStore = defineStore('mail-entities', {
|
||||||
|
state: () => ({
|
||||||
|
messages: {} as Record<string, Record<string, Record<string, Record<string, EntityWrapper<MessageObject>>>>>,
|
||||||
|
signatures: {} as Record<string, Record<string, Record<string, string>>>, // Track delta signatures
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadMessages(sources?: any, filter?: any, sort?: any, range?: any) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await entityService.list({ sources, filter, sort, range })
|
||||||
|
|
||||||
|
// Entities come as objects keyed by identifier
|
||||||
|
Object.entries(response).forEach(([provider, providerData]) => {
|
||||||
|
Object.entries(providerData).forEach(([service, serviceData]) => {
|
||||||
|
Object.entries(serviceData).forEach(([collection, entities]) => {
|
||||||
|
if (!this.messages[provider]) {
|
||||||
|
this.messages[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][service]) {
|
||||||
|
this.messages[provider][service] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][service][collection]) {
|
||||||
|
this.messages[provider][service][collection] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities are already keyed by identifier
|
||||||
|
this.messages[provider][service][collection] = entities as Record<string, EntityWrapper<MessageObject>>
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessages(
|
||||||
|
provider: string,
|
||||||
|
service: string | number,
|
||||||
|
collection: string | number,
|
||||||
|
identifiers: (string | number)[],
|
||||||
|
properties?: string[]
|
||||||
|
) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await entityService.fetch({
|
||||||
|
provider,
|
||||||
|
service,
|
||||||
|
collection,
|
||||||
|
identifiers,
|
||||||
|
properties
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update in store
|
||||||
|
if (!this.messages[provider]) {
|
||||||
|
this.messages[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][String(service)]) {
|
||||||
|
this.messages[provider][String(service)] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][String(service)][String(collection)]) {
|
||||||
|
this.messages[provider][String(service)][String(collection)] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index fetched entities by identifier
|
||||||
|
response.entities.forEach((entity: EntityWrapper<MessageObject>) => {
|
||||||
|
this.messages[provider][String(service)][String(collection)][entity.identifier] = entity
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchMessages(
|
||||||
|
provider: string,
|
||||||
|
service: string | number,
|
||||||
|
query: string,
|
||||||
|
collections?: (string | number)[],
|
||||||
|
filter?: any,
|
||||||
|
sort?: any,
|
||||||
|
range?: any
|
||||||
|
) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await entityService.search({
|
||||||
|
provider,
|
||||||
|
service,
|
||||||
|
query,
|
||||||
|
collections,
|
||||||
|
filter,
|
||||||
|
sort,
|
||||||
|
range
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(request: MessageSendRequest) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const response = await entityService.send(request)
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDelta(sources: any) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
// Sources are already in correct format: { provider: { service: { collection: signature } } }
|
||||||
|
const response = await entityService.delta({ sources })
|
||||||
|
|
||||||
|
// Process delta and update store
|
||||||
|
Object.entries(response).forEach(([provider, providerData]) => {
|
||||||
|
Object.entries(providerData).forEach(([service, serviceData]) => {
|
||||||
|
Object.entries(serviceData).forEach(([collection, collectionData]) => {
|
||||||
|
// Skip if no changes (server returns false or string signature)
|
||||||
|
if (collectionData === false || typeof collectionData === 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.messages[provider]) {
|
||||||
|
this.messages[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][service]) {
|
||||||
|
this.messages[provider][service] = {}
|
||||||
|
}
|
||||||
|
if (!this.messages[provider][service][collection]) {
|
||||||
|
this.messages[provider][service][collection] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionMessages = this.messages[provider][service][collection]
|
||||||
|
|
||||||
|
// Update signature if provided
|
||||||
|
if (typeof collectionData === 'object' && collectionData.signature) {
|
||||||
|
if (!this.signatures[provider]) {
|
||||||
|
this.signatures[provider] = {}
|
||||||
|
}
|
||||||
|
if (!this.signatures[provider][service]) {
|
||||||
|
this.signatures[provider][service] = {}
|
||||||
|
}
|
||||||
|
this.signatures[provider][service][collection] = collectionData.signature
|
||||||
|
console.log(`[Store] Updated signature for ${provider}/${service}/${collection}: "${collectionData.signature}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process additions (from delta response format)
|
||||||
|
if (collectionData.additions) {
|
||||||
|
// Note: additions are just identifiers, need to fetch full entities separately
|
||||||
|
// This is handled by the sync composable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process modifications
|
||||||
|
if (collectionData.modifications) {
|
||||||
|
// Note: modifications are just identifiers, need to fetch full entities separately
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deleted messages
|
||||||
|
if (collectionData.deletions) {
|
||||||
|
collectionData.deletions.forEach((id: string | number) => {
|
||||||
|
delete collectionMessages[String(id)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy support: Also handle created/modified/deleted format
|
||||||
|
if (collectionData.created) {
|
||||||
|
collectionData.created.forEach((entity: EntityWrapper<MessageObject>) => {
|
||||||
|
collectionMessages[entity.identifier] = entity
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionData.modified) {
|
||||||
|
collectionData.modified.forEach((entity: EntityWrapper<MessageObject>) => {
|
||||||
|
collectionMessages[entity.identifier] = entity
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionData.deleted) {
|
||||||
|
collectionData.deleted.forEach((id: string | number) => {
|
||||||
|
delete collectionMessages[String(id)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error.message
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
messageList: (state) => {
|
||||||
|
const list: EntityWrapper<MessageObject>[] = []
|
||||||
|
Object.values(state.messages).forEach(providerMessages => {
|
||||||
|
Object.values(providerMessages).forEach(serviceMessages => {
|
||||||
|
Object.values(serviceMessages).forEach(collectionMessages => {
|
||||||
|
Object.values(collectionMessages).forEach(message => {
|
||||||
|
list.push(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
|
||||||
|
messageCount: (state) => {
|
||||||
|
let count = 0
|
||||||
|
Object.values(state.messages).forEach(providerMessages => {
|
||||||
|
Object.values(providerMessages).forEach(serviceMessages => {
|
||||||
|
Object.values(serviceMessages).forEach(collectionMessages => {
|
||||||
|
count += Object.keys(collectionMessages).length
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
},
|
||||||
|
|
||||||
|
hasMessages: (state) => {
|
||||||
|
return Object.values(state.messages).some(providerMessages =>
|
||||||
|
Object.values(providerMessages).some(serviceMessages =>
|
||||||
|
Object.values(serviceMessages).some(collectionMessages =>
|
||||||
|
Object.keys(collectionMessages).length > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
108
src/stores/providersStore.ts
Normal file
108
src/stores/providersStore.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Providers Store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, readonly } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { providerService } from '../services'
|
||||||
|
import { ProviderObject } from '../models/provider'
|
||||||
|
import type { SourceSelector } from '../types'
|
||||||
|
|
||||||
|
export const useProvidersStore = defineStore('mailProvidersStore', () => {
|
||||||
|
// State
|
||||||
|
const _providers = ref<Record<string, ProviderObject>>({})
|
||||||
|
const transceiving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const count = computed(() => Object.keys(_providers.value).length)
|
||||||
|
const has = computed(() => count.value > 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get providers as an array
|
||||||
|
* @returns Array of provider objects
|
||||||
|
*/
|
||||||
|
const providers = computed(() => Object.values(_providers.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific provider by identifier from cache
|
||||||
|
* @param identifier - Provider identifier
|
||||||
|
* @returns Provider object or null
|
||||||
|
*/
|
||||||
|
function provider(identifier: string): ProviderObject | null {
|
||||||
|
return _providers.value[identifier] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
/**
|
||||||
|
* Retrieve all or specific providers
|
||||||
|
*/
|
||||||
|
async function list(sources?: SourceSelector): Promise<Record<string, ProviderObject>> {
|
||||||
|
transceiving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await providerService.list({ sources })
|
||||||
|
|
||||||
|
console.debug('[Mail Manager](Store) - Successfully retrieved', Object.keys(response).length, 'providers')
|
||||||
|
|
||||||
|
_providers.value = response
|
||||||
|
return response
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to retrieve providers:', err)
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific provider
|
||||||
|
*/
|
||||||
|
async function fetch(identifier: string): Promise<ProviderObject> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
return await providerService.fetch({ identifier })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to fetch provider:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which providers exist/are available
|
||||||
|
*/
|
||||||
|
async function extant(sources: SourceSelector) {
|
||||||
|
transceiving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await providerService.extant({ sources })
|
||||||
|
console.debug('[Mail Manager](Store) - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers')
|
||||||
|
return response
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to check providers:', err)
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return public API
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
transceiving: readonly(transceiving),
|
||||||
|
error: readonly(error),
|
||||||
|
// computed
|
||||||
|
count,
|
||||||
|
has,
|
||||||
|
providers,
|
||||||
|
provider,
|
||||||
|
// functions
|
||||||
|
list,
|
||||||
|
fetch,
|
||||||
|
extant,
|
||||||
|
}
|
||||||
|
})
|
||||||
209
src/stores/servicesStore.ts
Normal file
209
src/stores/servicesStore.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Services Store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, readonly } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { serviceService } from '../services'
|
||||||
|
import { ServiceObject } from '../models/service'
|
||||||
|
import type {
|
||||||
|
ServiceLocation,
|
||||||
|
SourceSelector,
|
||||||
|
ServiceIdentity,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||||
|
// State
|
||||||
|
const _services = ref<Record<string, ServiceObject>>({})
|
||||||
|
const transceiving = ref(false)
|
||||||
|
const lastTestResult = ref<any>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const count = computed(() => Object.keys(_services.value).length)
|
||||||
|
const has = computed(() => count.value > 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get services as an array
|
||||||
|
* @returns Array of service objects
|
||||||
|
*/
|
||||||
|
const services = computed(() => Object.values(_services.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get services grouped by provider
|
||||||
|
* @returns Services grouped by provider ID
|
||||||
|
*/
|
||||||
|
const servicesByProvider = computed(() => {
|
||||||
|
const groups: Record<string, ServiceObject[]> = {}
|
||||||
|
|
||||||
|
Object.values(_services.value).forEach((service) => {
|
||||||
|
if (!groups[service.provider]) {
|
||||||
|
groups[service.provider] = []
|
||||||
|
}
|
||||||
|
groups[service.provider].push(service)
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
/**
|
||||||
|
* Retrieve for all or specific services
|
||||||
|
*/
|
||||||
|
async function list(sources?: SourceSelector): Promise<Record<string, ServiceObject>> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const response = await serviceService.list({ sources })
|
||||||
|
|
||||||
|
// Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object
|
||||||
|
const flattened: Record<string, ServiceObject> = {}
|
||||||
|
Object.entries(response).forEach(([_providerId, providerServices]) => {
|
||||||
|
Object.entries(providerServices).forEach(([_serviceId, serviceObj]) => {
|
||||||
|
const key = `${serviceObj.provider}:${serviceObj.identifier}`
|
||||||
|
flattened[key] = serviceObj
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.debug('[Mail Manager](Store) - Successfully retrieved', Object.keys(flattened).length, 'services')
|
||||||
|
|
||||||
|
_services.value = flattened
|
||||||
|
return flattened
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to retrieve services:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific service
|
||||||
|
*/
|
||||||
|
async function fetch(provider: string, identifier: string | number): Promise<ServiceObject> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
return await serviceService.fetch({ provider, identifier })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to fetch service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover service configuration
|
||||||
|
*
|
||||||
|
* @returns Array of discovered services sorted by provider
|
||||||
|
*/
|
||||||
|
async function discover(
|
||||||
|
identity: string,
|
||||||
|
secret: string | undefined,
|
||||||
|
location: string | undefined,
|
||||||
|
provider: string | undefined,
|
||||||
|
): Promise<ServiceObject[]> {
|
||||||
|
transceiving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const services = await serviceService.discover({identity, secret, location, provider})
|
||||||
|
console.debug('[Mail Manager](Store) - Successfully discovered', services.length, 'services')
|
||||||
|
return services
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to discover service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(
|
||||||
|
provider: string,
|
||||||
|
identifier?: string | number | null,
|
||||||
|
location?: ServiceLocation | null,
|
||||||
|
identity?: ServiceIdentity | null,
|
||||||
|
): Promise<any> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const response = await serviceService.test({ provider, identifier, location, identity })
|
||||||
|
lastTestResult.value = response
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to test service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(provider: string, data: any) {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const serviceObj = await serviceService.create({ provider, data })
|
||||||
|
|
||||||
|
// Add to store with composite key
|
||||||
|
const key = `${serviceObj.provider}:${serviceObj.identifier}`
|
||||||
|
_services.value[key] = serviceObj
|
||||||
|
|
||||||
|
return serviceObj
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to create service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(provider: string, identifier: string | number, data: any) {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const serviceObj = await serviceService.update({ provider, identifier, data })
|
||||||
|
|
||||||
|
// Update in store with composite key
|
||||||
|
const key = `${serviceObj.provider}:${serviceObj.identifier}`
|
||||||
|
_services.value[key] = serviceObj
|
||||||
|
|
||||||
|
return serviceObj
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to update service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(provider: string, identifier: string | number) {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
await serviceService.delete({ provider, identifier })
|
||||||
|
|
||||||
|
// Remove from store using composite key
|
||||||
|
const key = `${provider}:${identifier}`
|
||||||
|
delete _services.value[key]
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager](Store) - Failed to delete service:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return public API
|
||||||
|
return {
|
||||||
|
// State (readonly)
|
||||||
|
transceiving: readonly(transceiving),
|
||||||
|
lastTestResult: readonly(lastTestResult),
|
||||||
|
// Getters
|
||||||
|
count,
|
||||||
|
has,
|
||||||
|
services,
|
||||||
|
servicesByProvider,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
list,
|
||||||
|
fetch,
|
||||||
|
discover,
|
||||||
|
test,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
delete: remove,
|
||||||
|
}
|
||||||
|
})
|
||||||
148
src/types/collection.ts
Normal file
148
src/types/collection.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Collection-related type definitions for Mail Manager
|
||||||
|
*/
|
||||||
|
import type { SourceSelector } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection interface (mailbox/folder)
|
||||||
|
*/
|
||||||
|
export interface CollectionInterface {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number | null;
|
||||||
|
identifier: string | number;
|
||||||
|
signature?: string | null;
|
||||||
|
created?: string | null;
|
||||||
|
modified?: string | null;
|
||||||
|
properties: CollectionPropertiesInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionBaseProperties {
|
||||||
|
'@type': string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable collection properties (computed by server)
|
||||||
|
*/
|
||||||
|
export interface CollectionImmutableProperties extends CollectionBaseProperties {
|
||||||
|
total?: number;
|
||||||
|
unread?: number;
|
||||||
|
role?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable collection properties (can be modified by user)
|
||||||
|
*/
|
||||||
|
export interface CollectionMutableProperties extends CollectionBaseProperties {
|
||||||
|
label: string;
|
||||||
|
rank?: number;
|
||||||
|
subscribed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full collection properties (what server returns)
|
||||||
|
*/
|
||||||
|
export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection list request
|
||||||
|
*/
|
||||||
|
export interface CollectionListRequest {
|
||||||
|
sources?: SourceSelector;
|
||||||
|
filter?: any;
|
||||||
|
sort?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection list response
|
||||||
|
*/
|
||||||
|
export interface CollectionListResponse {
|
||||||
|
[providerId: string]: {
|
||||||
|
[serviceId: string]: {
|
||||||
|
[collectionId: string]: CollectionInterface;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection extant request
|
||||||
|
*/
|
||||||
|
export interface CollectionExtantRequest {
|
||||||
|
sources: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection extant response
|
||||||
|
*/
|
||||||
|
export interface CollectionExtantResponse {
|
||||||
|
[providerId: string]: {
|
||||||
|
[serviceId: string]: {
|
||||||
|
[collectionId: string]: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection fetch request
|
||||||
|
*/
|
||||||
|
export interface CollectionFetchRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection fetch response
|
||||||
|
*/
|
||||||
|
export interface CollectionFetchResponse extends CollectionInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection create request
|
||||||
|
*/
|
||||||
|
export interface CollectionCreateRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection?: string | number | null; // Parent Collection Identifier
|
||||||
|
properties: CollectionMutableProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection create response
|
||||||
|
*/
|
||||||
|
export interface CollectionCreateResponse extends CollectionInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection modify request
|
||||||
|
*/
|
||||||
|
export interface CollectionModifyRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
identifier: string | number;
|
||||||
|
properties: CollectionMutableProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection modify response
|
||||||
|
*/
|
||||||
|
export interface CollectionModifyResponse extends CollectionInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection destroy request
|
||||||
|
*/
|
||||||
|
export interface CollectionDestroyRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
identifier: string | number;
|
||||||
|
options?: {
|
||||||
|
force?: boolean; // Whether to force destroy even if collection is not empty
|
||||||
|
recursive?: boolean; // Whether to destroy child collections/items as well
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection destroy response
|
||||||
|
*/
|
||||||
|
export interface CollectionDestroyResponse {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
93
src/types/common.ts
Normal file
93
src/types/common.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Common types shared across Mail Manager services
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base API request envelope
|
||||||
|
*/
|
||||||
|
export interface ApiRequest<T = any> {
|
||||||
|
version: number;
|
||||||
|
transaction: string;
|
||||||
|
operation: string;
|
||||||
|
data: T;
|
||||||
|
user?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success response envelope
|
||||||
|
*/
|
||||||
|
export interface ApiSuccessResponse<T = any> {
|
||||||
|
version: number;
|
||||||
|
transaction: string;
|
||||||
|
operation: string;
|
||||||
|
status: 'success';
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response envelope
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
version: number;
|
||||||
|
transaction: string;
|
||||||
|
operation: string;
|
||||||
|
status: 'error';
|
||||||
|
data: {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined response type
|
||||||
|
*/
|
||||||
|
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source selector structure for hierarchical resource selection
|
||||||
|
* Structure: Provider -> Service -> Collection -> Message
|
||||||
|
*/
|
||||||
|
export type SourceSelector = {
|
||||||
|
[provider: string]: boolean | ServiceSelector;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceSelector = {
|
||||||
|
[service: string]: boolean | CollectionSelector;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectionSelector = {
|
||||||
|
[collection: string | number]: boolean | MessageSelector;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageSelector = (string | number)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter condition for building complex queries
|
||||||
|
*/
|
||||||
|
export interface FilterCondition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter criteria for list operations
|
||||||
|
*/
|
||||||
|
export interface ListFilter {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort options for list operations
|
||||||
|
*/
|
||||||
|
export interface ListSort {
|
||||||
|
[key: string]: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range specification for pagination/limiting results
|
||||||
|
*/
|
||||||
|
export interface ListRange {
|
||||||
|
start: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
190
src/types/entity.ts
Normal file
190
src/types/entity.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Entity type definitions for mail
|
||||||
|
*/
|
||||||
|
import type { SourceSelector } from './common';
|
||||||
|
import type { MessageInterface } from './message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity wrapper with metadata
|
||||||
|
*/
|
||||||
|
export interface EntityInterface<T = MessageInterface> {
|
||||||
|
provider: string;
|
||||||
|
service: string;
|
||||||
|
collection: string | number;
|
||||||
|
identifier: string | number;
|
||||||
|
signature: string | null;
|
||||||
|
created: string | null;
|
||||||
|
modified: string | null;
|
||||||
|
properties: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity list request
|
||||||
|
*/
|
||||||
|
export interface EntityListRequest {
|
||||||
|
sources?: SourceSelector;
|
||||||
|
filter?: any;
|
||||||
|
sort?: any;
|
||||||
|
range?: { start: number; limit: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity list response
|
||||||
|
*/
|
||||||
|
export interface EntityListResponse {
|
||||||
|
[providerId: string]: {
|
||||||
|
[serviceId: string]: {
|
||||||
|
[collectionId: string]: {
|
||||||
|
[identifier: string]: EntityInterface<MessageInterface>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity delta request
|
||||||
|
*/
|
||||||
|
export interface EntityDeltaRequest {
|
||||||
|
sources: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity delta response
|
||||||
|
*/
|
||||||
|
export interface EntityDeltaResponse {
|
||||||
|
[providerId: string]: {
|
||||||
|
[serviceId: string]: {
|
||||||
|
[collectionId: string]: {
|
||||||
|
signature: string;
|
||||||
|
created?: EntityInterface<MessageInterface>[];
|
||||||
|
modified?: EntityInterface<MessageInterface>[];
|
||||||
|
deleted?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity extant request
|
||||||
|
*/
|
||||||
|
export interface EntityExtantRequest {
|
||||||
|
sources: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity extant response
|
||||||
|
*/
|
||||||
|
export interface EntityExtantResponse {
|
||||||
|
[providerId: string]: {
|
||||||
|
[serviceId: string]: {
|
||||||
|
[collectionId: string]: {
|
||||||
|
[messageId: string]: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity fetch request
|
||||||
|
*/
|
||||||
|
export interface EntityFetchRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number;
|
||||||
|
identifiers: (string | number)[];
|
||||||
|
properties?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity fetch response
|
||||||
|
*/
|
||||||
|
export interface EntityFetchResponse {
|
||||||
|
entities: EntityInterface<MessageInterface>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity create request
|
||||||
|
*/
|
||||||
|
export interface EntityCreateRequest<T = MessageInterface> {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number;
|
||||||
|
properties: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity create response
|
||||||
|
*/
|
||||||
|
export interface EntityCreateResponse<T = MessageInterface> {
|
||||||
|
entity: EntityInterface<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity modify request
|
||||||
|
*/
|
||||||
|
export interface EntityModifyRequest<T = MessageInterface> {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number;
|
||||||
|
identifier: string | number;
|
||||||
|
properties: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity modify response
|
||||||
|
*/
|
||||||
|
export interface EntityModifyResponse<T = MessageInterface> {
|
||||||
|
success: boolean;
|
||||||
|
entity?: EntityInterface<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity destroy request
|
||||||
|
*/
|
||||||
|
export interface EntityDestroyRequest {
|
||||||
|
provider: string;
|
||||||
|
service: string | number;
|
||||||
|
collection: string | number;
|
||||||
|
identifier: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity destroy response
|
||||||
|
*/
|
||||||
|
export interface EntityDestroyResponse {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity send request
|
||||||
|
*/
|
||||||
|
export interface EntitySendRequest {
|
||||||
|
message: {
|
||||||
|
from?: string;
|
||||||
|
to: string[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
subject?: string;
|
||||||
|
body?: {
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
attachments?: Array<{
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
content: string; // base64 encoded
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
options?: {
|
||||||
|
queue?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity send response
|
||||||
|
*/
|
||||||
|
export interface EntitySendResponse {
|
||||||
|
id: string;
|
||||||
|
status: 'queued' | 'sent';
|
||||||
|
}
|
||||||
10
src/types/index.ts
Normal file
10
src/types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Central export point for all Mail Manager types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type * from './collection';
|
||||||
|
export type * from './common';
|
||||||
|
export type * from './entity';
|
||||||
|
export type * from './integration';
|
||||||
|
export type * from './provider';
|
||||||
|
export type * from './service';
|
||||||
58
src/types/integration.ts
Normal file
58
src/types/integration.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Integration and panel contract type definitions
|
||||||
|
* Defines standardized interfaces for provider panels
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Provider Panel Contracts ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props all provider CONFIG panels receive
|
||||||
|
* Config panels handle protocol/location settings only
|
||||||
|
*/
|
||||||
|
export interface ProviderConfigPanelProps {
|
||||||
|
/** Pre-filled location from discovery (if available) */
|
||||||
|
discoveredLocation?: import('./service').ServiceLocation;
|
||||||
|
/** Current location value for v-model binding */
|
||||||
|
modelValue?: import('./service').ServiceLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events all provider CONFIG panels emit
|
||||||
|
* Config panels emit location configuration and validation state
|
||||||
|
*/
|
||||||
|
export interface ProviderConfigPanelEmits {
|
||||||
|
/** Emit updated location configuration */
|
||||||
|
'update:modelValue': [value: import('./service').ServiceLocation];
|
||||||
|
/** Emit validation state (true = valid, false = invalid) */
|
||||||
|
'valid': [value: boolean];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props all provider AUTH panels receive
|
||||||
|
* Auth panels handle credentials/authentication only
|
||||||
|
*/
|
||||||
|
export interface ProviderAuthPanelProps {
|
||||||
|
/** Email address from discovery entry (for pre-filling username) */
|
||||||
|
emailAddress?: string;
|
||||||
|
/** Discovered or configured location (for context/auth decisions) */
|
||||||
|
discoveredLocation?: import('./service').ServiceLocation;
|
||||||
|
/** Pre-filled identity/username from discovery */
|
||||||
|
prefilledIdentity?: string;
|
||||||
|
/** Pre-filled secret/password if user entered during discovery */
|
||||||
|
prefilledSecret?: string;
|
||||||
|
/** Current identity value for v-model binding */
|
||||||
|
modelValue?: import('./service').ServiceIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events all provider AUTH panels emit
|
||||||
|
* Auth panels emit identity configuration, validation state, and errors
|
||||||
|
*/
|
||||||
|
export interface ProviderAuthPanelEmits {
|
||||||
|
/** Emit updated identity configuration */
|
||||||
|
'update:modelValue': [value: import('./service').ServiceIdentity];
|
||||||
|
/** Emit validation state (true = valid, false = invalid) */
|
||||||
|
'valid': [value: boolean];
|
||||||
|
/** Emit authentication errors for user feedback */
|
||||||
|
'error': [error: string];
|
||||||
|
}
|
||||||
68
src/types/message.ts
Normal file
68
src/types/message.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Message Part Interface
|
||||||
|
*/
|
||||||
|
export interface MessagePartInterface {
|
||||||
|
partId?: string | null;
|
||||||
|
blobId?: string | null;
|
||||||
|
size?: number | null;
|
||||||
|
name?: string | null;
|
||||||
|
type?: string;
|
||||||
|
charset?: string | null;
|
||||||
|
disposition?: string | null;
|
||||||
|
cid?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
content?: string;
|
||||||
|
subParts?: MessagePartInterface[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message object interface
|
||||||
|
*/
|
||||||
|
export interface MessageInterface {
|
||||||
|
urid?: string;
|
||||||
|
size?: number;
|
||||||
|
receivedDate?: string;
|
||||||
|
date?: string;
|
||||||
|
subject?: string;
|
||||||
|
snippet?: string;
|
||||||
|
from?: {
|
||||||
|
address: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
to?: Array<{
|
||||||
|
address: string;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
cc?: Array<{
|
||||||
|
address: string;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
bcc?: Array<{
|
||||||
|
address: string;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
replyTo?: Array<{
|
||||||
|
address: string;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
flags?: {
|
||||||
|
read?: boolean;
|
||||||
|
flagged?: boolean;
|
||||||
|
answered?: boolean;
|
||||||
|
draft?: boolean;
|
||||||
|
};
|
||||||
|
body?: MessagePartInterface;
|
||||||
|
attachments?: Array<{
|
||||||
|
partId?: string;
|
||||||
|
blobId?: string;
|
||||||
|
size?: number;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
charset?: string | null;
|
||||||
|
disposition?: string;
|
||||||
|
cid?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
69
src/types/provider.ts
Normal file
69
src/types/provider.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Provider type definitions
|
||||||
|
*/
|
||||||
|
import type { SourceSelector } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider capabilities
|
||||||
|
*/
|
||||||
|
export interface ProviderCapabilitiesInterface {
|
||||||
|
ServiceList?: boolean;
|
||||||
|
ServiceFetch?: boolean;
|
||||||
|
ServiceExtant?: boolean;
|
||||||
|
ServiceCreate?: boolean;
|
||||||
|
ServiceModify?: boolean;
|
||||||
|
ServiceDestroy?: boolean;
|
||||||
|
ServiceDiscover?: boolean;
|
||||||
|
ServiceTest?: boolean;
|
||||||
|
[key: string]: boolean | object | string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider information
|
||||||
|
*/
|
||||||
|
export interface ProviderInterface {
|
||||||
|
'@type': string;
|
||||||
|
identifier: string;
|
||||||
|
label: string;
|
||||||
|
capabilities: ProviderCapabilitiesInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider list request
|
||||||
|
*/
|
||||||
|
export interface ProviderListRequest {
|
||||||
|
sources?: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider list response
|
||||||
|
*/
|
||||||
|
export interface ProviderListResponse {
|
||||||
|
[identifier: string]: ProviderInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider fetch request
|
||||||
|
*/
|
||||||
|
export interface ProviderFetchRequest {
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider fetch response
|
||||||
|
*/
|
||||||
|
export interface ProviderFetchResponse extends ProviderInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider extant request
|
||||||
|
*/
|
||||||
|
export interface ProviderExtantRequest {
|
||||||
|
sources: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider extant response
|
||||||
|
*/
|
||||||
|
export interface ProviderExtantResponse {
|
||||||
|
[identifier: string]: boolean;
|
||||||
|
}
|
||||||
344
src/types/service.ts
Normal file
344
src/types/service.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Service-related type definitions
|
||||||
|
*/
|
||||||
|
import type { SourceSelector } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service capabilities
|
||||||
|
*/
|
||||||
|
export interface ServiceCapabilitiesInterface {
|
||||||
|
// Collection capabilities
|
||||||
|
CollectionList?: boolean;
|
||||||
|
CollectionListFilter?: boolean | { [field: string]: string };
|
||||||
|
CollectionListSort?: boolean | string[];
|
||||||
|
CollectionExtant?: boolean;
|
||||||
|
CollectionFetch?: boolean;
|
||||||
|
CollectionCreate?: boolean;
|
||||||
|
CollectionModify?: boolean;
|
||||||
|
CollectionDelete?: boolean;
|
||||||
|
// Message capabilities
|
||||||
|
EntityList?: boolean;
|
||||||
|
EntityListFilter?: boolean | { [field: string]: string };
|
||||||
|
EntityListSort?: boolean | string[];
|
||||||
|
EntityListRange?: boolean | { tally?: string[] };
|
||||||
|
EntityDelta?: boolean;
|
||||||
|
EntityExtant?: boolean;
|
||||||
|
EntityFetch?: boolean;
|
||||||
|
EntityCreate?: boolean;
|
||||||
|
EntityModify?: boolean;
|
||||||
|
EntityDelete?: boolean;
|
||||||
|
EntityMove?: boolean;
|
||||||
|
EntityCopy?: boolean;
|
||||||
|
// Send capability
|
||||||
|
EntityTransmit?: boolean;
|
||||||
|
[key: string]: boolean | object | string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service information
|
||||||
|
*/
|
||||||
|
export interface ServiceInterface {
|
||||||
|
'@type': string;
|
||||||
|
provider: string;
|
||||||
|
identifier: string | number | null;
|
||||||
|
label: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
capabilities?: ServiceCapabilitiesInterface;
|
||||||
|
location?: ServiceLocation | null;
|
||||||
|
identity?: ServiceIdentity | null;
|
||||||
|
primaryAddress?: string | null;
|
||||||
|
secondaryAddresses?: string[] | null;
|
||||||
|
auxiliary?: Record<string, any>; // Provider-specific extension data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service list request
|
||||||
|
*/
|
||||||
|
export interface ServiceListRequest {
|
||||||
|
sources?: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service list response
|
||||||
|
*/
|
||||||
|
export interface ServiceListResponse {
|
||||||
|
[provider: string]: {
|
||||||
|
[identifier: string]: ServiceInterface;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service extant request
|
||||||
|
*/
|
||||||
|
export interface ServiceExtantRequest {
|
||||||
|
sources: SourceSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service extant response
|
||||||
|
*/
|
||||||
|
export interface ServiceExtantResponse {
|
||||||
|
[provider: string]: {
|
||||||
|
[identifier: string]: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service fetch request
|
||||||
|
*/
|
||||||
|
export interface ServiceFetchRequest {
|
||||||
|
provider: string;
|
||||||
|
identifier: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service fetch response
|
||||||
|
*/
|
||||||
|
export interface ServiceFetchResponse extends ServiceInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service find by address request
|
||||||
|
*/
|
||||||
|
export interface ServiceFindByAddressRequest {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service find by address response
|
||||||
|
*/
|
||||||
|
export interface ServiceFindByAddressResponse extends ServiceInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service create request
|
||||||
|
*/
|
||||||
|
export interface ServiceCreateRequest {
|
||||||
|
provider: string;
|
||||||
|
data: Partial<ServiceInterface>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service create response
|
||||||
|
*/
|
||||||
|
export interface ServiceCreateResponse extends ServiceInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service update request
|
||||||
|
*/
|
||||||
|
export interface ServiceUpdateRequest {
|
||||||
|
provider: string;
|
||||||
|
identifier: string | number;
|
||||||
|
data: Partial<ServiceInterface>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service update response
|
||||||
|
*/
|
||||||
|
export interface ServiceUpdateResponse extends ServiceInterface {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service delete request
|
||||||
|
*/
|
||||||
|
export interface ServiceDeleteRequest {
|
||||||
|
provider: string;
|
||||||
|
identifier: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service delete response
|
||||||
|
*/
|
||||||
|
export interface ServiceDeleteResponse {}
|
||||||
|
|
||||||
|
// ==================== Discovery Types ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service discovery request - NEW VERSION
|
||||||
|
* Supports identity-based discovery with optional hints
|
||||||
|
*/
|
||||||
|
export interface ServiceDiscoverRequest {
|
||||||
|
identity: string; // Email address or domain
|
||||||
|
provider?: string; // Optional: specific provider ('jmap', 'smtp', etc.) or null for all
|
||||||
|
location?: string; // Optional: known hostname (bypasses DNS lookup)
|
||||||
|
secret?: string; // Optional: password/token for credential validation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service discovery response - NEW VERSION
|
||||||
|
* Provider-keyed map of discovered service locations
|
||||||
|
*/
|
||||||
|
export interface ServiceDiscoverResponse {
|
||||||
|
[provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovery status tracking for real-time UI updates
|
||||||
|
* Used by store to track per-provider discovery progress
|
||||||
|
*/
|
||||||
|
export interface ProviderDiscoveryStatus {
|
||||||
|
provider: string;
|
||||||
|
status: 'pending' | 'discovering' | 'success' | 'failed';
|
||||||
|
location?: ServiceLocation;
|
||||||
|
error?: string;
|
||||||
|
metadata?: {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Service Testing Types ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base service location interface
|
||||||
|
*/
|
||||||
|
export interface ServiceLocationBase {
|
||||||
|
type: 'URI' | 'SOCKET_SOLE' | 'SOCKET_SPLIT' | 'FILE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URI-based service location for API and web services
|
||||||
|
* Used by: JMAP, Gmail API, etc.
|
||||||
|
*/
|
||||||
|
export interface ServiceLocationUri extends ServiceLocationBase {
|
||||||
|
type: 'URI';
|
||||||
|
scheme: string; // e.g., 'https', 'http'
|
||||||
|
host: string; // e.g., 'api.example.com'
|
||||||
|
port: number; // e.g., 443
|
||||||
|
path?: string; // e.g., '/v1/api'
|
||||||
|
verifyPeer?: boolean; // Verify SSL/TLS peer certificate
|
||||||
|
verifyHost?: boolean; // Verify SSL/TLS certificate host
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single socket-based service location
|
||||||
|
* Used by: services using a single host/port combination
|
||||||
|
*/
|
||||||
|
export interface ServiceLocationSocketSole extends ServiceLocationBase {
|
||||||
|
type: 'SOCKET_SOLE';
|
||||||
|
host: string; // e.g., 'mail.example.com'
|
||||||
|
port: number; // e.g., 993
|
||||||
|
encryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Security mode
|
||||||
|
verifyPeer?: boolean; // Verify SSL/TLS peer certificate
|
||||||
|
verifyHost?: boolean; // Verify SSL/TLS certificate host
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split socket-based service location
|
||||||
|
* Used by: traditional IMAP/SMTP configurations
|
||||||
|
*/
|
||||||
|
export interface ServiceLocationSocketSplit extends ServiceLocationBase {
|
||||||
|
type: 'SOCKET_SPLIT';
|
||||||
|
inboundHost: string; // e.g., 'imap.example.com'
|
||||||
|
inboundPort: number; // e.g., 993
|
||||||
|
inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Inbound security mode
|
||||||
|
inboundVerifyPeer?: boolean; // Verify inbound SSL/TLS peer certificate
|
||||||
|
inboundVerifyHost?: boolean; // Verify inbound SSL/TLS certificate host
|
||||||
|
outboundHost: string; // e.g., 'smtp.example.com'
|
||||||
|
outboundPort: number; // e.g., 465
|
||||||
|
outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; // Outbound security mode
|
||||||
|
outboundVerifyPeer?: boolean; // Verify outbound SSL/TLS peer certificate
|
||||||
|
outboundVerifyHost?: boolean; // Verify outbound SSL/TLS certificate host
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-based service location
|
||||||
|
* Used by: local file system providers
|
||||||
|
*/
|
||||||
|
export interface ServiceLocationFile extends ServiceLocationBase {
|
||||||
|
type: 'FILE';
|
||||||
|
path: string; // File system path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all service location types
|
||||||
|
*/
|
||||||
|
export type ServiceLocation =
|
||||||
|
| ServiceLocationUri
|
||||||
|
| ServiceLocationSocketSole
|
||||||
|
| ServiceLocationSocketSplit
|
||||||
|
| ServiceLocationFile;
|
||||||
|
|
||||||
|
// ==================== Service Identity Types ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base service identity interface
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityBase {
|
||||||
|
type: 'NA' | 'BA' | 'TA' | 'OA' | 'CC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No authentication
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityNone extends ServiceIdentityBase {
|
||||||
|
type: 'NA';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic authentication (username/password)
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityBasic extends ServiceIdentityBase {
|
||||||
|
type: 'BA';
|
||||||
|
identity: string; // Username/email
|
||||||
|
secret: string; // Password
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token authentication (API key, static token)
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityToken extends ServiceIdentityBase {
|
||||||
|
type: 'TA';
|
||||||
|
token: string; // Authentication token/API key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth authentication
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityOAuth extends ServiceIdentityBase {
|
||||||
|
type: 'OA';
|
||||||
|
accessToken: string; // Current access token
|
||||||
|
accessScope?: string[]; // Token scopes
|
||||||
|
accessExpiry?: number; // Unix timestamp when token expires
|
||||||
|
refreshToken?: string; // Refresh token for getting new access tokens
|
||||||
|
refreshLocation?: string; // Token refresh endpoint URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client certificate authentication (mTLS)
|
||||||
|
*/
|
||||||
|
export interface ServiceIdentityCertificate extends ServiceIdentityBase {
|
||||||
|
type: 'CC';
|
||||||
|
certificate: string; // X.509 certificate (PEM format or file path)
|
||||||
|
privateKey: string; // Private key (PEM format or file path)
|
||||||
|
passphrase?: string; // Optional passphrase for encrypted private key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service identity configuration
|
||||||
|
* Discriminated union of all identity types
|
||||||
|
*/
|
||||||
|
export type ServiceIdentity =
|
||||||
|
| ServiceIdentityNone
|
||||||
|
| ServiceIdentityBasic
|
||||||
|
| ServiceIdentityToken
|
||||||
|
| ServiceIdentityOAuth
|
||||||
|
| ServiceIdentityCertificate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service connection test request
|
||||||
|
*/
|
||||||
|
export interface ServiceTestRequest {
|
||||||
|
provider: string;
|
||||||
|
// For existing service
|
||||||
|
identifier?: string | number | null;
|
||||||
|
// For fresh configuration
|
||||||
|
location?: ServiceLocation | null;
|
||||||
|
identity?: ServiceIdentity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service connection test response
|
||||||
|
*/
|
||||||
|
export interface ServiceTestResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
31
src/utils/key-generator.ts
Normal file
31
src/utils/key-generator.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for generating unique identifiers
|
||||||
|
*/
|
||||||
|
|
||||||
|
const globalCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||||
|
|
||||||
|
export const generateUuid = (): string => {
|
||||||
|
if (globalCrypto?.randomUUID) {
|
||||||
|
return globalCrypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
|
||||||
|
return template.replace(/[xy]/g, char => {
|
||||||
|
const randomNibble = Math.floor(Math.random() * 16);
|
||||||
|
const value = char === "x" ? randomNibble : (randomNibble & 0x3) | 0x8;
|
||||||
|
return value.toString(16);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateKey = (): string => {
|
||||||
|
if (globalCrypto?.randomUUID) {
|
||||||
|
return globalCrypto.randomUUID().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalCrypto?.getRandomValues) {
|
||||||
|
const [value] = globalCrypto.getRandomValues(new Uint32Array(1));
|
||||||
|
return value.toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.random().toString(16).slice(2);
|
||||||
|
};
|
||||||
276
src/utils/serviceHelpers.ts
Normal file
276
src/utils/serviceHelpers.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* Helper functions for working with service identity and location types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ServiceIdentity,
|
||||||
|
ServiceIdentityNone,
|
||||||
|
ServiceIdentityBasic,
|
||||||
|
ServiceIdentityToken,
|
||||||
|
ServiceIdentityOAuth,
|
||||||
|
ServiceIdentityCertificate,
|
||||||
|
ServiceLocation,
|
||||||
|
ServiceLocationUri,
|
||||||
|
ServiceLocationSocketSole,
|
||||||
|
ServiceLocationSocketSplit,
|
||||||
|
ServiceLocationFile
|
||||||
|
} from '@/types/service';
|
||||||
|
|
||||||
|
// ==================== Identity Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a "None" identity (no authentication)
|
||||||
|
*/
|
||||||
|
export function createIdentityNone(): ServiceIdentityNone {
|
||||||
|
return { type: 'NA' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Basic Auth identity
|
||||||
|
*/
|
||||||
|
export function createIdentityBasic(identity: string, secret: string): ServiceIdentityBasic {
|
||||||
|
return {
|
||||||
|
type: 'BA',
|
||||||
|
identity,
|
||||||
|
secret
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Token Auth identity
|
||||||
|
*/
|
||||||
|
export function createIdentityToken(token: string): ServiceIdentityToken {
|
||||||
|
return {
|
||||||
|
type: 'TA',
|
||||||
|
token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an OAuth identity
|
||||||
|
*/
|
||||||
|
export function createIdentityOAuth(
|
||||||
|
accessToken: string,
|
||||||
|
options?: {
|
||||||
|
accessScope?: string[];
|
||||||
|
accessExpiry?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
refreshLocation?: string;
|
||||||
|
}
|
||||||
|
): ServiceIdentityOAuth {
|
||||||
|
return {
|
||||||
|
type: 'OA',
|
||||||
|
accessToken,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Certificate identity
|
||||||
|
*/
|
||||||
|
export function createIdentityCertificate(
|
||||||
|
certificate: string,
|
||||||
|
privateKey: string,
|
||||||
|
passphrase?: string
|
||||||
|
): ServiceIdentityCertificate {
|
||||||
|
return {
|
||||||
|
type: 'CC',
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
...(passphrase && { passphrase })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Location Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a URI-based location
|
||||||
|
*/
|
||||||
|
export function createLocationUri(
|
||||||
|
host: string,
|
||||||
|
options?: {
|
||||||
|
scheme?: 'http' | 'https';
|
||||||
|
port?: number;
|
||||||
|
path?: string;
|
||||||
|
verifyPeer?: boolean;
|
||||||
|
verifyHost?: boolean;
|
||||||
|
}
|
||||||
|
): ServiceLocationUri {
|
||||||
|
return {
|
||||||
|
type: 'URI',
|
||||||
|
scheme: options?.scheme || 'https',
|
||||||
|
host,
|
||||||
|
port: options?.port || (options?.scheme === 'http' ? 80 : 443),
|
||||||
|
...(options?.path && { path: options.path }),
|
||||||
|
verifyPeer: options?.verifyPeer ?? true,
|
||||||
|
verifyHost: options?.verifyHost ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a URI location from a full URL string
|
||||||
|
*/
|
||||||
|
export function createLocationFromUrl(url: string): ServiceLocationUri {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return {
|
||||||
|
type: 'URI',
|
||||||
|
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsed.pathname,
|
||||||
|
verifyPeer: true,
|
||||||
|
verifyHost: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single socket location (IMAP, SMTP on same server)
|
||||||
|
*/
|
||||||
|
export function createLocationSocketSole(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl',
|
||||||
|
options?: {
|
||||||
|
verifyPeer?: boolean;
|
||||||
|
verifyHost?: boolean;
|
||||||
|
}
|
||||||
|
): ServiceLocationSocketSole {
|
||||||
|
return {
|
||||||
|
type: 'SOCKET_SOLE',
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
encryption,
|
||||||
|
verifyPeer: options?.verifyPeer ?? true,
|
||||||
|
verifyHost: options?.verifyHost ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a split socket location (separate IMAP/SMTP servers)
|
||||||
|
*/
|
||||||
|
export function createLocationSocketSplit(
|
||||||
|
config: {
|
||||||
|
inboundHost: string;
|
||||||
|
inboundPort: number;
|
||||||
|
inboundEncryption?: 'none' | 'ssl' | 'tls' | 'starttls';
|
||||||
|
outboundHost: string;
|
||||||
|
outboundPort: number;
|
||||||
|
outboundEncryption?: 'none' | 'ssl' | 'tls' | 'starttls';
|
||||||
|
inboundVerifyPeer?: boolean;
|
||||||
|
inboundVerifyHost?: boolean;
|
||||||
|
outboundVerifyPeer?: boolean;
|
||||||
|
outboundVerifyHost?: boolean;
|
||||||
|
}
|
||||||
|
): ServiceLocationSocketSplit {
|
||||||
|
return {
|
||||||
|
type: 'SOCKET_SPLIT',
|
||||||
|
inboundHost: config.inboundHost,
|
||||||
|
inboundPort: config.inboundPort,
|
||||||
|
inboundEncryption: config.inboundEncryption || 'ssl',
|
||||||
|
outboundHost: config.outboundHost,
|
||||||
|
outboundPort: config.outboundPort,
|
||||||
|
outboundEncryption: config.outboundEncryption || 'ssl',
|
||||||
|
inboundVerifyPeer: config.inboundVerifyPeer ?? true,
|
||||||
|
inboundVerifyHost: config.inboundVerifyHost ?? true,
|
||||||
|
outboundVerifyPeer: config.outboundVerifyPeer ?? true,
|
||||||
|
outboundVerifyHost: config.outboundVerifyHost ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a file-based location
|
||||||
|
*/
|
||||||
|
export function createLocationFile(path: string): ServiceLocationFile {
|
||||||
|
return {
|
||||||
|
type: 'FILE',
|
||||||
|
path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Validation Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that an identity object is properly formed
|
||||||
|
*/
|
||||||
|
export function validateIdentity(identity: ServiceIdentity): boolean {
|
||||||
|
switch (identity.type) {
|
||||||
|
case 'NA':
|
||||||
|
return true;
|
||||||
|
case 'BA':
|
||||||
|
return !!(identity as ServiceIdentityBasic).identity &&
|
||||||
|
!!(identity as ServiceIdentityBasic).secret;
|
||||||
|
case 'TA':
|
||||||
|
return !!(identity as ServiceIdentityToken).token;
|
||||||
|
case 'OA':
|
||||||
|
return !!(identity as ServiceIdentityOAuth).accessToken;
|
||||||
|
case 'CC':
|
||||||
|
return !!(identity as ServiceIdentityCertificate).certificate &&
|
||||||
|
!!(identity as ServiceIdentityCertificate).privateKey;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a location object is properly formed
|
||||||
|
*/
|
||||||
|
export function validateLocation(location: ServiceLocation): boolean {
|
||||||
|
switch (location.type) {
|
||||||
|
case 'URI':
|
||||||
|
return !!(location as ServiceLocationUri).host &&
|
||||||
|
!!(location as ServiceLocationUri).port;
|
||||||
|
case 'SOCKET_SOLE':
|
||||||
|
return !!(location as ServiceLocationSocketSole).host &&
|
||||||
|
!!(location as ServiceLocationSocketSole).port;
|
||||||
|
case 'SOCKET_SPLIT':
|
||||||
|
const split = location as ServiceLocationSocketSplit;
|
||||||
|
return !!split.inboundHost && !!split.inboundPort &&
|
||||||
|
!!split.outboundHost && !!split.outboundPort;
|
||||||
|
case 'FILE':
|
||||||
|
return !!(location as ServiceLocationFile).path;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Type Guards ====================
|
||||||
|
|
||||||
|
export function isIdentityNone(identity: ServiceIdentity): identity is ServiceIdentityNone {
|
||||||
|
return identity.type === 'NA';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIdentityBasic(identity: ServiceIdentity): identity is ServiceIdentityBasic {
|
||||||
|
return identity.type === 'BA';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIdentityToken(identity: ServiceIdentity): identity is ServiceIdentityToken {
|
||||||
|
return identity.type === 'TA';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIdentityOAuth(identity: ServiceIdentity): identity is ServiceIdentityOAuth {
|
||||||
|
return identity.type === 'OA';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIdentityCertificate(identity: ServiceIdentity): identity is ServiceIdentityCertificate {
|
||||||
|
return identity.type === 'CC';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocationUri(location: ServiceLocation): location is ServiceLocationUri {
|
||||||
|
return location.type === 'URI';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocationSocketSole(location: ServiceLocation): location is ServiceLocationSocketSole {
|
||||||
|
return location.type === 'SOCKET_SOLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocationSocketSplit(location: ServiceLocation): location is ServiceLocationSocketSplit {
|
||||||
|
return location.type === 'SOCKET_SPLIT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocationFile(location: ServiceLocation): location is ServiceLocationFile {
|
||||||
|
return location.type === 'FILE';
|
||||||
|
}
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"],
|
||||||
|
"@MailManager/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
60
vite.config.ts
Normal file
60
vite.config.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'inject-css-filename',
|
||||||
|
enforce: 'post',
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||||
|
if (!cssFile) return
|
||||||
|
|
||||||
|
for (const fileName of Object.keys(bundle)) {
|
||||||
|
const chunk = bundle[fileName]
|
||||||
|
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||||
|
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||||
|
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../core/src'),
|
||||||
|
'@MailManager': path.resolve(__dirname, './src')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'module.mjs',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'pinia',
|
||||||
|
'vue',
|
||||||
|
'vue-router',
|
||||||
|
// Externalize shared utilities from core to avoid duplication
|
||||||
|
/^@KTXC\/utils\//,
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
if (assetInfo.name?.endsWith('.css')) {
|
||||||
|
return 'mail_manager-[hash].css'
|
||||||
|
}
|
||||||
|
return '[name]-[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user