Compare commits
1 Commits
main
...
9a3520384f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a3520384f |
18
composer.lock
generated
18
composer.lock
generated
@@ -592,16 +592,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "11.5.55",
|
||||
"version": "11.5.53",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
|
||||
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
||||
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -674,7 +674,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -698,7 +698,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-18T12:37:06+00:00"
|
||||
"time": "2026-02-10T12:28:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -1791,15 +1791,15 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=8.2 <=8.5"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"platform-dev": [],
|
||||
"platform-overrides": {
|
||||
"php": "8.2"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.3.0"
|
||||
}
|
||||
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
899
lib/Manager.php
899
lib/Manager.php
File diff suppressed because it is too large
Load Diff
2283
package-lock.json
generated
2283
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -18,18 +18,18 @@
|
||||
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.0",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^5.0.0",
|
||||
"vuetify": "^4.0.0"
|
||||
"vue-router": "^4.5.1",
|
||||
"vuetify": "^3.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"enabledManagers": ["npm", "composer", "github-actions"],
|
||||
"timezone": "UTC",
|
||||
"schedule": ["* 0-3 * * *"],
|
||||
"dependencyDashboard": true,
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, watch } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useProvidersStore } from '@MailManager/stores/providersStore'
|
||||
import { ServiceObject, type ProviderObject } from '@MailManager/models'
|
||||
import type { ProviderDiscoveryStatus, ServiceInterface, ServiceLocation } from '@MailManager/types'
|
||||
import DiscoveryEntryPanel from '@MailManager/components/steps/DiscoveryEntryPanel.vue'
|
||||
import DiscoveryStatusPanel from '@MailManager/components/steps/DiscoveryStatusPanel.vue'
|
||||
import ProviderSelectionPanel from '@MailManager/components/steps/ProviderSelectionPanel.vue'
|
||||
import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
|
||||
import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
|
||||
import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
|
||||
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
|
||||
@@ -39,7 +38,6 @@ const emit = defineEmits<{
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const integrationStore = useIntegrationStore()
|
||||
const servicesStore = useServicesStore()
|
||||
const providersStore = useProvidersStore()
|
||||
|
||||
@@ -58,10 +56,19 @@ const discoverSecret = ref<string | null>(null)
|
||||
const discoverHostname = ref<string | null>(null)
|
||||
|
||||
// Step 2: Discovery Status / Provider Selection
|
||||
const selectedProvider = shallowRef<ProviderObject | null>(null)
|
||||
const selectedService = shallowRef<ServiceObject | null>(null)
|
||||
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)
|
||||
@@ -130,58 +137,19 @@ const showSaveButton = computed(() => {
|
||||
const canProceedToNext = computed(() => {
|
||||
if (isManualMode.value) {
|
||||
if (currentStep.value === MANUAL_STEPS.CONFIG) {
|
||||
return !!selectedService.value?.location
|
||||
return !!configuredLocation.value
|
||||
}
|
||||
if (currentStep.value === MANUAL_STEPS.AUTH) {
|
||||
return !!selectedService.value?.identity
|
||||
return authValid.value
|
||||
}
|
||||
} else {
|
||||
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
|
||||
return !!selectedService.value?.identity
|
||||
return authValid.value
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function createServiceObject(
|
||||
providerId: string,
|
||||
data: Partial<ServiceInterface> = {}
|
||||
): ServiceObject {
|
||||
const model: ServiceInterface = {
|
||||
'@type': 'mail:service',
|
||||
version: 1,
|
||||
provider: providerId,
|
||||
identifier: null,
|
||||
label: data.label ?? null,
|
||||
enabled: data.enabled ?? true,
|
||||
primaryAddress: data.primaryAddress ?? (discoverAddress.value || null),
|
||||
secondaryAddresses: data.secondaryAddresses ?? null,
|
||||
location: data.location ?? null,
|
||||
identity: data.identity ?? null,
|
||||
capabilities: data.capabilities ?? {},
|
||||
auxiliary: data.auxiliary ?? {}
|
||||
}
|
||||
|
||||
const factoryItem = integrationStore.getItemById('mail_provider_factory_service', providerId) as any
|
||||
const factory = factoryItem?.factory
|
||||
return factory ? factory(model) : new ServiceObject().fromJson(model)
|
||||
}
|
||||
|
||||
function setSelectedProviderAndService(providerId: string, service: ServiceObject) {
|
||||
selectedProvider.value = providersStore.provider(providerId)
|
||||
selectedService.value = service
|
||||
testAndSaveValid.value = false
|
||||
}
|
||||
|
||||
function handleServiceUpdate(service: ServiceObject) {
|
||||
selectedService.value = service
|
||||
testAndSaveValid.value = false
|
||||
}
|
||||
|
||||
function handleServiceTested(success: boolean) {
|
||||
testAndSaveValid.value = success
|
||||
}
|
||||
|
||||
// Navigation methods
|
||||
function handlePreviousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
@@ -203,7 +171,6 @@ function handleNextStep() {
|
||||
|
||||
async function handleDiscover() {
|
||||
// Move to discovery status screen
|
||||
isManualMode.value = false
|
||||
currentStep.value = DISCOVERY_STEPS.DISCOVERY
|
||||
|
||||
// Extract provider IDs
|
||||
@@ -231,24 +198,23 @@ async function handleDiscover() {
|
||||
discoveryStatus.value[identifier].status = 'discovering'
|
||||
|
||||
try {
|
||||
let discoveredService: any = undefined
|
||||
await servicesStore.discover(
|
||||
const services = await servicesStore.discover(
|
||||
discoverAddress.value,
|
||||
discoverSecret.value || undefined,
|
||||
discoverHostname.value || undefined,
|
||||
identifier,
|
||||
(service) => { discoveredService = service }
|
||||
identifier
|
||||
)
|
||||
|
||||
// Success - check if we got results for this provider
|
||||
if (discoveredService && discoveredService.location) {
|
||||
const service = services.find(s => s.provider === identifier)
|
||||
if (service && service.location) {
|
||||
discoveryStatus.value[identifier] = {
|
||||
provider: identifier,
|
||||
status: 'success',
|
||||
location: discoveredService.location,
|
||||
metadata: extractLocationMetadata(discoveredService.location)
|
||||
location: service.location,
|
||||
metadata: extractLocationMetadata(service.location)
|
||||
}
|
||||
discoveredServices.value.push(discoveredService)
|
||||
discoveredServices.value.push(service)
|
||||
} else {
|
||||
// No configuration found for this provider
|
||||
discoveryStatus.value[identifier].status = 'failed'
|
||||
@@ -292,18 +258,12 @@ function extractLocationMetadata(location: ServiceLocation) {
|
||||
|
||||
async function handleProviderSelect(identifier: string) {
|
||||
// User clicked "Select" on discovered provider - skip config, go to auth
|
||||
const discovered = discoveredServices.value.find(s => s.provider === identifier)
|
||||
if (!discovered || !discovered.location) return
|
||||
|
||||
const discoveredJson = discovered.toJson()
|
||||
const service = createServiceObject(identifier, {
|
||||
...discoveredJson,
|
||||
label: discoveredJson.label || discoverAddress.value,
|
||||
enabled: discoveredJson.enabled ?? true,
|
||||
primaryAddress: discoveredJson.primaryAddress || discoverAddress.value,
|
||||
location: discoveredJson.location
|
||||
})
|
||||
setSelectedProviderAndService(identifier, service)
|
||||
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
|
||||
@@ -311,17 +271,11 @@ async function handleProviderSelect(identifier: string) {
|
||||
|
||||
function handleProviderAdvanced(identifier: string) {
|
||||
// User clicked "Advanced" - show manual config with pre-filled values
|
||||
const discovered = discoveredServices.value.find(s => s.provider === identifier)
|
||||
const discoveredJson = discovered?.toJson()
|
||||
const service = createServiceObject(identifier, {
|
||||
...discoveredJson,
|
||||
label: discoveredJson?.label || discoverAddress.value,
|
||||
enabled: discoveredJson?.enabled ?? true,
|
||||
primaryAddress: discoveredJson?.primaryAddress || discoverAddress.value,
|
||||
location: discoveredJson?.location ?? null
|
||||
})
|
||||
|
||||
setSelectedProviderAndService(identifier, service)
|
||||
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
|
||||
@@ -338,15 +292,8 @@ function handleManualMode() {
|
||||
|
||||
function handleProviderManualSelect(identifier: string) {
|
||||
// User selected a provider in manual mode
|
||||
const service = createServiceObject(identifier, {
|
||||
label: discoverAddress.value,
|
||||
enabled: true,
|
||||
primaryAddress: discoverAddress.value,
|
||||
location: null,
|
||||
identity: null
|
||||
})
|
||||
|
||||
setSelectedProviderAndService(identifier, service)
|
||||
selectedProviderId.value = identifier
|
||||
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
|
||||
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
|
||||
}
|
||||
|
||||
@@ -355,20 +302,10 @@ function goBackToIdentity() {
|
||||
isManualMode.value = false
|
||||
discoveredServices.value = []
|
||||
discoveryStatus.value = {}
|
||||
selectedProvider.value = null
|
||||
selectedService.value = null
|
||||
testAndSaveValid.value = false
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!selectedProvider.value || !selectedService.value) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing configuration'
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedService.value.location || !selectedService.value.identity) {
|
||||
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing configuration'
|
||||
@@ -376,35 +313,31 @@ async function testConnection() {
|
||||
}
|
||||
|
||||
const testResult = await servicesStore.test(
|
||||
selectedProvider.value.identifier,
|
||||
selectedProviderId.value,
|
||||
null,
|
||||
selectedService.value.location,
|
||||
selectedService.value.identity
|
||||
configuredLocation.value,
|
||||
configuredIdentity.value
|
||||
)
|
||||
|
||||
return testResult
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
if (!selectedProvider.value || !selectedService.value) return
|
||||
|
||||
const serviceData = selectedService.value.toJson()
|
||||
if (!serviceData.location || !serviceData.identity) return
|
||||
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const accountData = {
|
||||
label: serviceData.label || discoverAddress.value,
|
||||
primaryAddress: serviceData.primaryAddress || discoverAddress.value,
|
||||
enabled: serviceData.enabled,
|
||||
location: serviceData.location,
|
||||
identity: serviceData.identity,
|
||||
auxiliary: serviceData.auxiliary
|
||||
label: accountLabel.value || discoverAddress.value,
|
||||
email: discoverAddress.value,
|
||||
enabled: accountEnabled.value,
|
||||
location: configuredLocation.value,
|
||||
identity: configuredIdentity.value
|
||||
}
|
||||
|
||||
await servicesStore.create(
|
||||
selectedProvider.value.identifier,
|
||||
selectedProviderId.value,
|
||||
accountData
|
||||
)
|
||||
|
||||
@@ -430,8 +363,13 @@ function resetForm() {
|
||||
discoverAddress.value = ''
|
||||
discoverSecret.value = null
|
||||
discoverHostname.value = null
|
||||
selectedProvider.value = null
|
||||
selectedService.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 = {}
|
||||
@@ -468,7 +406,7 @@ function resetForm() {
|
||||
<!-- Step 1: Discovery Entry -->
|
||||
<template #item.1>
|
||||
<v-card flat class="pa-6">
|
||||
<DiscoveryEntryPanel
|
||||
<DiscoveryEntryStep
|
||||
v-model:address="discoverAddress"
|
||||
v-model:secret="discoverSecret"
|
||||
v-model:hostname="discoverHostname"
|
||||
@@ -482,7 +420,7 @@ function resetForm() {
|
||||
<template #item.2>
|
||||
<v-card flat class="pa-6">
|
||||
<!-- Discovery path -->
|
||||
<DiscoveryStatusPanel
|
||||
<DiscoveryStatusStep
|
||||
v-if="!isManualMode"
|
||||
:address="discoverAddress"
|
||||
:status="discoveryStatus"
|
||||
@@ -493,7 +431,7 @@ function resetForm() {
|
||||
/>
|
||||
|
||||
<!-- Manual path - provider picker -->
|
||||
<ProviderSelectionPanel
|
||||
<ProviderSelectionStep
|
||||
v-else
|
||||
@select="handleProviderManualSelect"
|
||||
@back="goBackToIdentity"
|
||||
@@ -505,18 +443,24 @@ function resetForm() {
|
||||
<template #item.3>
|
||||
<v-card flat class="pa-6">
|
||||
<!-- Manual path: Protocol Configuration -->
|
||||
<ProviderProtocolPanel
|
||||
v-if="isManualMode && selectedProvider && selectedService"
|
||||
:provider="selectedProvider"
|
||||
:service="selectedService"
|
||||
@update:service="handleServiceUpdate"
|
||||
<ProviderConfigStep
|
||||
v-if="isManualMode && selectedProviderId"
|
||||
:provider-id="selectedProviderId"
|
||||
:discovered-location="configuredLocation || undefined"
|
||||
v-model="configuredLocation"
|
||||
@valid="() => { /* Can proceed to next step */ }"
|
||||
/>
|
||||
|
||||
<ProviderAuthPanel
|
||||
v-else-if="!isManualMode && selectedProvider && selectedService"
|
||||
:provider="selectedProvider"
|
||||
:service="selectedService"
|
||||
@update:service="handleServiceUpdate"
|
||||
<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>
|
||||
@@ -524,21 +468,31 @@ function resetForm() {
|
||||
<!-- Step 4: Auth (manual) OR Test (discovery) -->
|
||||
<template #item.4>
|
||||
<v-card flat class="pa-6">
|
||||
<ProviderAuthPanel
|
||||
v-if="isManualMode && selectedProvider && selectedService"
|
||||
:provider="selectedProvider"
|
||||
:service="selectedService"
|
||||
@update:service="handleServiceUpdate"
|
||||
<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 -->
|
||||
<TestAndSavePanel
|
||||
v-else-if="!isManualMode && selectedProvider && selectedService"
|
||||
:provider="selectedProvider"
|
||||
:service="selectedService"
|
||||
<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:service="handleServiceUpdate"
|
||||
@tested="handleServiceTested"
|
||||
@update:label="(val) => accountLabel = val"
|
||||
@update:enabled="(val) => accountEnabled = val"
|
||||
@valid="(valid) => testAndSaveValid = valid"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -546,13 +500,18 @@ function resetForm() {
|
||||
<!-- Step 5: Test & Save (manual only) -->
|
||||
<template #item.5>
|
||||
<v-card flat class="pa-6">
|
||||
<TestAndSavePanel
|
||||
v-if="selectedProvider && selectedService"
|
||||
:provider="selectedProvider"
|
||||
:service="selectedService"
|
||||
<TestAndSaveStep
|
||||
v-if="selectedProviderId"
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-label="selectedProviderLabel"
|
||||
:email-address="discoverAddress"
|
||||
:location="configuredLocation"
|
||||
:identity="configuredIdentity"
|
||||
:prefilled-label="discoverAddress"
|
||||
:on-test="testConnection"
|
||||
@update:service="handleServiceUpdate"
|
||||
@tested="handleServiceTested"
|
||||
@update:label="(val) => accountLabel = val"
|
||||
@update:enabled="(val) => accountEnabled = val"
|
||||
@valid="(valid) => testAndSaveValid = valid"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useProvidersStore } from '@MailManager/stores/providersStore'
|
||||
import type { ProviderObject, ServiceObject } from '@MailManager/models'
|
||||
import ProviderAuxiliaryPanel from '@MailManager/components/steps/ProviderAuxiliaryPanel.vue'
|
||||
import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
|
||||
import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
|
||||
import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
|
||||
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'
|
||||
|
||||
type EditTab = 'general' | 'auxiliary' | 'protocol' | 'auth'
|
||||
const EDIT_STEPS = {
|
||||
CONFIG: 1,
|
||||
AUTH: 2,
|
||||
TEST: 3
|
||||
} as const
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -29,90 +33,146 @@ const dialogOpen = computed({
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const currentTab = ref<EditTab>('general')
|
||||
const currentStep = ref<number>(EDIT_STEPS.CONFIG)
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const localProvider = shallowRef<ProviderObject | null>(null)
|
||||
const localService = shallowRef<ServiceObject | null>(null)
|
||||
// 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)
|
||||
|
||||
function serviceRequiresConnectionTest(service: ServiceObject | null): boolean {
|
||||
return !!(service?.location?.mutated() || service?.identity?.mutated())
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
title: 'General',
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
value: 'general' as const
|
||||
},
|
||||
{
|
||||
title: 'Auxiliary Settings',
|
||||
icon: 'mdi-tune-variant',
|
||||
value: 'auxiliary' as const
|
||||
},
|
||||
{
|
||||
title: 'Protocol',
|
||||
icon: 'mdi-tune-vertical',
|
||||
value: 'protocol' as const
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
icon: 'mdi-shield-key-outline',
|
||||
value: 'auth' as const
|
||||
}
|
||||
// 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 !serviceRequiresConnectionTest(localService.value) || testAndSaveValid.value
|
||||
return testAndSaveValid.value
|
||||
})
|
||||
|
||||
const showSaveButton = computed(() => currentTab.value === 'general')
|
||||
const accountReady = computed(() => localProvider.value !== null && localService.value !== null)
|
||||
// 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)
|
||||
|
||||
// Load service data when the dialog is open and the target account is available.
|
||||
watch(
|
||||
() => [props.modelValue, props.serviceProvider, props.serviceIdentifier] as const,
|
||||
async ([isOpen, serviceProvider, serviceIdentifier]) => {
|
||||
if (!isOpen || !serviceProvider || !serviceIdentifier) {
|
||||
return
|
||||
}
|
||||
|
||||
await load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
localProvider.value = null
|
||||
localService.value = null
|
||||
|
||||
if (!props.serviceProvider || !props.serviceIdentifier) {
|
||||
console.error('[Mail Manager][Edit Account Dialog] - Cannot open dialog missing service or provider identifier')
|
||||
loadError.value = 'missing service or provider identifier'
|
||||
loading.value = false
|
||||
return
|
||||
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 [provider, service] = await Promise.all([
|
||||
providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
|
||||
servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
|
||||
])
|
||||
const accountData = {
|
||||
label: accountLabel.value || service.value.label,
|
||||
enabled: accountEnabled.value,
|
||||
location: configuredLocation.value,
|
||||
identity: configuredIdentity.value
|
||||
}
|
||||
|
||||
localProvider.value = provider.clone()
|
||||
localService.value = service.clone()
|
||||
await servicesStore.update(
|
||||
service.value.provider,
|
||||
service.value.identifier as string | number,
|
||||
accountData
|
||||
)
|
||||
|
||||
emit('saved')
|
||||
close()
|
||||
} catch (error) {
|
||||
console.error('[Mail Manager][Edit Account Dialog] - Failed to load service:', error)
|
||||
loadError.value = 'Failed to load service details'
|
||||
console.error('Failed to save account:', error)
|
||||
// TODO: Show error message to user
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,90 +183,21 @@ function close() {
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentTab.value = 'general'
|
||||
localService.value = null
|
||||
localProvider.value = null
|
||||
loadError.value = null
|
||||
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
|
||||
}
|
||||
|
||||
function isTabDisabled(tab: EditTab) {
|
||||
if (tab === 'auth') {
|
||||
return !localService.value?.location
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function handleUpdate(mutatedService: ServiceObject) {
|
||||
localService.value = mutatedService
|
||||
|
||||
if (serviceRequiresConnectionTest(mutatedService)) {
|
||||
testAndSaveValid.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
try {
|
||||
if (!localService.value) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing service configuration'
|
||||
}
|
||||
}
|
||||
|
||||
let testResult = null
|
||||
|
||||
if (serviceRequiresConnectionTest(localService.value)) {
|
||||
testResult = await servicesStore.test(
|
||||
localService.value.provider,
|
||||
null,
|
||||
localService.value.location,
|
||||
localService.value.identity
|
||||
)
|
||||
} else {
|
||||
testResult = await servicesStore.test(
|
||||
localService.value.provider,
|
||||
localService.value.identifier
|
||||
)
|
||||
}
|
||||
|
||||
testAndSaveValid.value = testResult.success
|
||||
return testResult
|
||||
} catch (error) {
|
||||
console.error('[Mail Manager][Edit Account Dialog] - Test connection failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Test failed due to an unexpected error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
// No changes made, just close the dialog
|
||||
if (!localService.value.mutated() && !localService.value.location?.mutated() && !localService.value.identity?.mutated()) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await servicesStore.update(
|
||||
localService.value.provider,
|
||||
localService.value.identifier as string | number,
|
||||
true, // delta update
|
||||
localService.value
|
||||
)
|
||||
|
||||
emit('saved')
|
||||
close()
|
||||
} catch (error) {
|
||||
console.error('[Mail Manager][Edit Account Dialog] - Failed to save service:', error)
|
||||
// TODO: Show error message to user
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
// Watch for location changes
|
||||
watch(configuredLocation, (newLocation) => {
|
||||
configValid.value = !!newLocation
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -228,88 +219,105 @@ async function saveAccount() {
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text v-if="loading || (!loadError && !accountReady)" class="text-center py-8">
|
||||
<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-if="loadError" class="pa-6">
|
||||
<v-alert type="error" variant="tonal">
|
||||
{{ loadError }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-else class="pa-0">
|
||||
<v-tabs
|
||||
v-model="currentTab"
|
||||
bg-color="transparent"
|
||||
grow
|
||||
class="px-4 pt-2"
|
||||
>
|
||||
<v-tab
|
||||
v-for="item in tabItems"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:disabled="isTabDisabled(item.value)"
|
||||
>
|
||||
<v-icon start>{{ item.icon }}</v-icon>
|
||||
{{ item.title }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<!-- 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-window v-model="currentTab">
|
||||
<v-window-item value="general">
|
||||
<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">
|
||||
<TestAndSavePanel
|
||||
v-if="localProvider && localService"
|
||||
:provider="localProvider!"
|
||||
:service="localService!"
|
||||
<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:service="handleUpdate"
|
||||
@update:label="(val) => accountLabel = val"
|
||||
@update:enabled="(val) => accountEnabled = val"
|
||||
@valid="(valid) => testAndSaveValid = valid"
|
||||
/>
|
||||
</v-card>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="auxiliary">
|
||||
<v-card flat class="pa-6">
|
||||
<ProviderAuxiliaryPanel
|
||||
v-if="localProvider && localService"
|
||||
:provider="localProvider!"
|
||||
:service="localService!"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</v-card>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="protocol">
|
||||
<v-card flat class="pa-6">
|
||||
<ProviderProtocolPanel
|
||||
v-if="localProvider && localService"
|
||||
:provider="localProvider!"
|
||||
:service="localService!"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</v-card>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="auth">
|
||||
<v-card flat class="pa-6">
|
||||
<ProviderAuthPanel
|
||||
v-if="localProvider && localService"
|
||||
:provider="localProvider!"
|
||||
:service="localService!"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</v-card>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</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
|
||||
@@ -318,7 +326,18 @@ async function saveAccount() {
|
||||
>
|
||||
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"
|
||||
|
||||
@@ -36,14 +36,6 @@ const rules = {
|
||||
required: (v: string) => !!v || 'Required',
|
||||
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email address'
|
||||
}
|
||||
|
||||
function handleDiscoverOnEnter() {
|
||||
if (!localAddress.value || rules.email(localAddress.value) !== true) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('discover')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -63,7 +55,6 @@ function handleDiscoverOnEnter() {
|
||||
autocomplete="off"
|
||||
:rules="[rules.required, rules.email]"
|
||||
class="mb-4"
|
||||
@keydown.enter.prevent="handleDiscoverOnEnter"
|
||||
/>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
@@ -152,8 +143,6 @@ function handleDiscoverOnEnter() {
|
||||
<v-btn
|
||||
variant="text"
|
||||
block
|
||||
class="manual-action-btn"
|
||||
prepend-icon="mdi-tune"
|
||||
@click="$emit('manual')"
|
||||
>
|
||||
Manual Configuration
|
||||
@@ -166,8 +155,4 @@ function handleDiscoverOnEnter() {
|
||||
.gap-3 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.manual-action-btn {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ProviderDiscoveryStatus } from '@MailManager/types'
|
||||
import type { ProviderDiscoveryStatus } from '@MailManager/types/service'
|
||||
|
||||
const props = defineProps<{
|
||||
address: string
|
||||
@@ -16,7 +16,7 @@ const emit = defineEmits<{
|
||||
|
||||
const sortedStatus = computed(() => {
|
||||
const statusArray = Object.values(props.status)
|
||||
const order: Record<string, number> = { success: 0, discovering: 1, pending: 2, failed: 3 }
|
||||
const order = { success: 0, discovering: 1, pending: 2, failed: 3 }
|
||||
return statusArray.sort((a, b) => order[a.status] - order[b.status])
|
||||
})
|
||||
|
||||
@@ -191,22 +191,6 @@ function getProviderLabel(providerId: string): string {
|
||||
</div>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<div
|
||||
v-if="!isDiscovering && successCount === 0"
|
||||
class="mt-6"
|
||||
>
|
||||
<v-btn
|
||||
size="large"
|
||||
variant="text"
|
||||
block
|
||||
class="manual-action-btn"
|
||||
prepend-icon="mdi-tune"
|
||||
@click="$emit('manual')"
|
||||
>
|
||||
Manual Configuration
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -233,8 +217,4 @@ function getProviderLabel(providerId: string): string {
|
||||
.gap-3 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.manual-action-btn {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||
import type { ServiceObject } from '@MailManager/models/service'
|
||||
import type { ProviderObject } from '@MailManager/models/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderObject
|
||||
service: ServiceObject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:service': [value: ServiceObject]
|
||||
}>()
|
||||
|
||||
// Local state
|
||||
const integrationStore = useIntegrationStore()
|
||||
const panelCache = new Map<string, Component>()
|
||||
const panelLoading = ref(false)
|
||||
const panelActive = shallowRef<Component | null>(null)
|
||||
const localProvider = ref<ProviderObject>(props.provider)
|
||||
const localService = ref<ServiceObject>(props.service)
|
||||
|
||||
// Local watchers
|
||||
watch(
|
||||
() => props.provider,
|
||||
async (provider) => {
|
||||
localProvider.value = provider
|
||||
await loadProviderPanel()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
(service) => {
|
||||
localService.value = service
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [localProvider.value?.identifier, localService.value?.provider] as const,
|
||||
async () => {
|
||||
await loadProviderPanel()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Load provider panel
|
||||
async function loadProviderPanel() {
|
||||
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
|
||||
|
||||
if (!providerIdentifier) {
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve panel from cache if available
|
||||
if (panelCache.has(providerIdentifier)) {
|
||||
panelActive.value = panelCache.get(providerIdentifier) || null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
panelLoading.value = true
|
||||
|
||||
// retrieve panel from integration store
|
||||
const panel = integrationStore.getItems('mail_provider_panels_auth').find((panel: any) => {
|
||||
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
|
||||
})
|
||||
if (!panel?.component) {
|
||||
console.warn(`No panel found for provider ID: ${providerIdentifier}`)
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await panel.component()
|
||||
const component = module.default || module
|
||||
panelCache.set(providerIdentifier, component)
|
||||
panelActive.value = component
|
||||
} catch (error) {
|
||||
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
|
||||
panelActive.value = null
|
||||
} finally {
|
||||
panelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate(service: ServiceObject) {
|
||||
localService.value = service
|
||||
emit('update:service', localService.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-auth-panel">
|
||||
<h3 class="text-h6 mb-2">Authentication</h3>
|
||||
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||
Configure authentication specific settings for {{ localProvider?.label || 'this provider' }}.
|
||||
</p>
|
||||
|
||||
<div v-if="panelLoading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
Loading panel...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="!panelActive" type="info" variant="tonal">
|
||||
No panel available for this provider.
|
||||
</v-alert>
|
||||
|
||||
<component
|
||||
v-else
|
||||
:is="panelActive"
|
||||
:service="localService"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
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>
|
||||
@@ -1,119 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||
import type { ServiceObject } from '@MailManager/models/service'
|
||||
import type { ProviderObject } from '@MailManager/models/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderObject
|
||||
service: ServiceObject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:service': [value: ServiceObject]
|
||||
}>()
|
||||
|
||||
const integrationStore = useIntegrationStore()
|
||||
const panelCache = new Map<string, Component>()
|
||||
const panelLoading = ref(false)
|
||||
const panelActive = shallowRef<Component | null>(null)
|
||||
const localProvider = ref<ProviderObject>(props.provider)
|
||||
const localService = ref<ServiceObject>(props.service)
|
||||
|
||||
watch(
|
||||
() => props.provider,
|
||||
async (provider) => {
|
||||
localProvider.value = provider
|
||||
await loadProviderPanel()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
(service) => {
|
||||
localService.value = service
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [localProvider.value?.identifier, localService.value?.provider] as const,
|
||||
async () => {
|
||||
await loadProviderPanel()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function loadProviderPanel() {
|
||||
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
|
||||
|
||||
if (!providerIdentifier) {
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (panelCache.has(providerIdentifier)) {
|
||||
panelActive.value = panelCache.get(providerIdentifier) || null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
panelLoading.value = true
|
||||
|
||||
const panel = integrationStore.getItems('mail_provider_panels_auxiliary').find((panel: any) => {
|
||||
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
|
||||
})
|
||||
|
||||
if (!panel?.component) {
|
||||
console.warn(`No auxiliary panel found for provider ID: ${providerIdentifier}`)
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await panel.component()
|
||||
const component = module.default || module
|
||||
panelCache.set(providerIdentifier, component)
|
||||
panelActive.value = component
|
||||
} catch (error) {
|
||||
console.error(`Failed to load auxiliary panel for ${providerIdentifier}:`, error)
|
||||
panelActive.value = null
|
||||
} finally {
|
||||
panelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate(service: ServiceObject) {
|
||||
localService.value = service
|
||||
emit('update:service', localService.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-auxiliary-panel">
|
||||
<h3 class="text-h6 mb-2">Settings</h3>
|
||||
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||
Configure provider specific settings for {{ localProvider?.label || 'this provider' }}.
|
||||
</p>
|
||||
|
||||
<div v-if="panelLoading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
Loading panel...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="!panelActive" type="info" variant="tonal">
|
||||
No panel available for this provider.
|
||||
</v-alert>
|
||||
|
||||
<component
|
||||
v-else
|
||||
:is="panelActive"
|
||||
:service="localService"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
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>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||
import type { ServiceObject } from '@MailManager/models/service'
|
||||
import type { ProviderObject } from '@MailManager/models/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderObject
|
||||
service: ServiceObject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:service': [value: ServiceObject]
|
||||
}>()
|
||||
|
||||
// Local state
|
||||
const integrationStore = useIntegrationStore()
|
||||
const panelCache = new Map<string, Component>()
|
||||
const panelLoading = ref(false)
|
||||
const panelActive = shallowRef<Component | null>(null)
|
||||
const localProvider = ref<ProviderObject>(props.provider)
|
||||
const localService = ref<ServiceObject>(props.service)
|
||||
|
||||
// Local watchers
|
||||
watch(
|
||||
() => props.provider,
|
||||
async (provider) => {
|
||||
localProvider.value = provider
|
||||
await loadProviderPanel()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
(service) => {
|
||||
localService.value = service
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [localProvider.value?.identifier, localService.value?.provider] as const,
|
||||
async () => {
|
||||
await loadProviderPanel()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Load provider panel
|
||||
async function loadProviderPanel() {
|
||||
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
|
||||
|
||||
if (!providerIdentifier) {
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve panel from cache if available
|
||||
if (panelCache.has(providerIdentifier)) {
|
||||
panelActive.value = panelCache.get(providerIdentifier) || null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
panelLoading.value = true
|
||||
|
||||
// retrieve panel from integration store
|
||||
const panel = integrationStore.getItems('mail_provider_panels_protocol').find((panel: any) => {
|
||||
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
|
||||
})
|
||||
if (!panel?.component) {
|
||||
console.warn(`No panel found for provider ID: ${providerIdentifier}`)
|
||||
panelActive.value = null
|
||||
panelLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await panel.component()
|
||||
const component = module.default || module
|
||||
panelCache.set(providerIdentifier, component)
|
||||
panelActive.value = component
|
||||
} catch (error) {
|
||||
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
|
||||
panelActive.value = null
|
||||
} finally {
|
||||
panelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate(service: ServiceObject) {
|
||||
localService.value = service
|
||||
emit('update:service', localService.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-protocol-panel">
|
||||
<h3 class="text-h6 mb-2">Protocol</h3>
|
||||
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||
Configure protocol specific settings for {{ localProvider?.label || 'this provider' }}.
|
||||
</p>
|
||||
|
||||
<div v-if="panelLoading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
Loading panel...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="!panelActive" type="info" variant="tonal">
|
||||
No panel available for this provider.
|
||||
</v-alert>
|
||||
|
||||
<component
|
||||
v-else
|
||||
:is="panelActive"
|
||||
:service="localService"
|
||||
@update:service="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,7 +14,7 @@ const selected = ref<string | null>(null)
|
||||
|
||||
// Get provider metadata from integrations
|
||||
const providerMetadata = computed(() => {
|
||||
const metadata = integrationStore.getItems('mail_provider_details')
|
||||
const metadata = integrationStore.getItems('mail_provider_metadata')
|
||||
return metadata.reduce((acc: any, meta: any) => {
|
||||
acc[meta.id] = meta
|
||||
return acc
|
||||
@@ -1,121 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ProviderObject, ServiceObject } from '@MailManager/models'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderObject
|
||||
service: ServiceObject
|
||||
onTest?: () => Promise<{ success: boolean; message: string; details?: any }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:service': [value: ServiceObject]
|
||||
'tested': [success: boolean]
|
||||
}>()
|
||||
|
||||
// Local state
|
||||
const localProvider = ref<ProviderObject>(props.provider)
|
||||
const localService = ref<ServiceObject>(props.service)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<any>(null)
|
||||
|
||||
// Computed
|
||||
const testSuccess = computed(() => testResult.value?.success === true)
|
||||
const serviceLocation = computed(() => localService.value.location?.toJson() ?? null)
|
||||
const serviceIdentity = computed(() => localService.value.identity?.toJson() ?? null)
|
||||
|
||||
// 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 (!localService.value.location || !localService.value.identity) {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: 'Missing configuration'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
if (!props.onTest) {
|
||||
throw new Error('No connection test callback provided')
|
||||
}
|
||||
|
||||
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(localService, () => {
|
||||
emit('update:service', localService.value)
|
||||
}, { deep: true })
|
||||
|
||||
watch(
|
||||
() => props.provider,
|
||||
(provider) => {
|
||||
localProvider.value = provider
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
(service) => {
|
||||
localService.value = service
|
||||
testResult.value = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="test-and-save-step">
|
||||
<h3 class="text-h6 mb-2">Test & Save</h3>
|
||||
@@ -134,7 +16,7 @@ watch(
|
||||
<v-icon>mdi-label</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Account Name</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ localService.label || localService.primaryAddress || 'New Account' }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ accountLabel }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Email Address -->
|
||||
@@ -143,7 +25,7 @@ watch(
|
||||
<v-icon>mdi-email</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Email Address</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ localService.primaryAddress }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ emailAddress }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Provider -->
|
||||
@@ -152,48 +34,48 @@ watch(
|
||||
<v-icon>mdi-cloud</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Provider</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ localProvider.label }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ providerLabel }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Location Details -->
|
||||
<template v-if="serviceLocation">
|
||||
<template v-if="location">
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-list-item v-if="serviceLocation.type === 'URI'">
|
||||
<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>
|
||||
{{ serviceLocation.scheme }}://{{ serviceLocation.host }}:{{ serviceLocation.port }}{{ serviceLocation.path || '' }}
|
||||
{{ location.scheme }}://{{ location.host }}:{{ location.port }}{{ location.path || '' }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<template v-if="serviceLocation.type === 'SOCKET_SOLE'">
|
||||
<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>{{ serviceLocation.host }}:{{ serviceLocation.port }}</v-list-item-subtitle>
|
||||
<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>{{ serviceLocation.encryption.toUpperCase() }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ location.encryption.toUpperCase() }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<template v-if="serviceLocation.type === 'SOCKET_SPLIT'">
|
||||
<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>
|
||||
{{ serviceLocation.inboundHost }}:{{ serviceLocation.inboundPort }} ({{ serviceLocation.inboundEncryption.toUpperCase() }})
|
||||
{{ location.inbound.protocol.toUpperCase() }} - {{ location.inbound.host }}:{{ location.inbound.port }} ({{ location.inbound.encryption.toUpperCase() }})
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
@@ -202,7 +84,7 @@ watch(
|
||||
</template>
|
||||
<v-list-item-title>Outgoing Mail</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ serviceLocation.outboundHost }}:{{ serviceLocation.outboundPort }} ({{ serviceLocation.outboundEncryption.toUpperCase() }})
|
||||
{{ location.outbound.protocol.toUpperCase() }} - {{ location.outbound.host }}:{{ location.outbound.port }} ({{ location.outbound.encryption.toUpperCase() }})
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -212,10 +94,10 @@ watch(
|
||||
<v-divider class="my-2" />
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon>{{ getAuthIcon(serviceIdentity?.type) }}</v-icon>
|
||||
<v-icon>{{ getAuthIcon(identity?.type) }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Authentication</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ getAuthLabel(serviceIdentity?.type) }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ getAuthLabel(identity?.type) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
@@ -223,7 +105,7 @@ watch(
|
||||
|
||||
<!-- Account Label Input -->
|
||||
<v-text-field
|
||||
v-model="localService.label"
|
||||
v-model="localAccountLabel"
|
||||
label="Account Name"
|
||||
variant="outlined"
|
||||
hint="A friendly name for this account (e.g., Work Email)"
|
||||
@@ -234,7 +116,7 @@ watch(
|
||||
|
||||
<!-- Enable Account Toggle -->
|
||||
<v-switch
|
||||
v-model="localService.enabled"
|
||||
v-model="accountEnabled"
|
||||
label="Enable this account"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
@@ -294,4 +176,120 @@ watch(
|
||||
Please test the connection before saving
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { useEntitiesStore } from '../stores/entitiesStore';
|
||||
import { useCollectionsStore } from '../stores/collectionsStore';
|
||||
|
||||
export interface SyncSource {
|
||||
interface SyncSource {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collections: (string | number)[];
|
||||
@@ -24,21 +23,7 @@ interface SyncOptions {
|
||||
fetchDetails?: boolean;
|
||||
}
|
||||
|
||||
export interface MailSyncController {
|
||||
isRunning: Ref<boolean>;
|
||||
lastSync: Ref<Date | null>;
|
||||
error: Ref<string | null>;
|
||||
sources: Ref<SyncSource[]>;
|
||||
addSource: (source: SyncSource) => void;
|
||||
removeSource: (source: SyncSource) => void;
|
||||
clearSources: () => void;
|
||||
sync: () => Promise<void>;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export function useMailSync(options: SyncOptions = {}): MailSyncController {
|
||||
export function useMailSync(options: SyncOptions = {}) {
|
||||
const {
|
||||
interval = 30000,
|
||||
autoStart = true,
|
||||
|
||||
38
src/main.ts
38
src/main.ts
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* Mail Manager Module Boot
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
console.log('[Mail Manager] Booting module...')
|
||||
/**
|
||||
* Mail Manager Module Boot Script
|
||||
*
|
||||
* This script is executed when the mail_manager module is loaded.
|
||||
* It initializes the stores which manage mail providers, services, collections, and messages.
|
||||
*/
|
||||
|
||||
console.log('[Mail Manager] Module booted successfully...')
|
||||
console.log('[MailManager] Booting Mail Manager module...')
|
||||
|
||||
console.log('[MailManager] Mail Manager module booted successfully')
|
||||
|
||||
// CSS will be injected by build process
|
||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
@@ -15,14 +23,12 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
// Export routes and integrations for module system
|
||||
export { routes, integrations }
|
||||
|
||||
// Export services, stores and models for external use
|
||||
export * from '@/services'
|
||||
export * from '@/stores'
|
||||
export * from '@/models'
|
||||
// Export stores for external use if needed
|
||||
export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore }
|
||||
|
||||
// Export composables for external use
|
||||
export { useMailSync } from '@/composables/useMailSync'
|
||||
|
||||
// Export components for external use
|
||||
export { default as AddAccountDialog } from '@/components/AddAccountDialog.vue'
|
||||
export { default as EditAccountDialog } from '@/components/EditAccountDialog.vue'
|
||||
// Default export for Vue plugin installation
|
||||
export default {
|
||||
install(app: Vue) {
|
||||
// Module initialization if needed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { isProxy, toRaw } from 'vue';
|
||||
|
||||
function normalizeCloneable<T>(value: T): T {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const rawValue = isProxy(value) ? toRaw(value) : value;
|
||||
|
||||
if (Array.isArray(rawValue)) {
|
||||
return rawValue.map(item => normalizeCloneable(item)) as T;
|
||||
}
|
||||
|
||||
const plainObject = Object.fromEntries(
|
||||
Object.entries(rawValue).map(([key, nestedValue]) => [key, normalizeCloneable(nestedValue)])
|
||||
);
|
||||
|
||||
return plainObject as T;
|
||||
}
|
||||
|
||||
export function clonePlain<T>(value: T): T {
|
||||
return structuredClone(normalizeCloneable(value));
|
||||
}
|
||||
@@ -2,46 +2,57 @@
|
||||
* Class model for Collection Interface
|
||||
*/
|
||||
|
||||
import type { CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface, CollectionPropertiesModelInterface } from "@/types/collection";
|
||||
import { clonePlain } from './clone-plain';
|
||||
import type { CollectionIdentifier, ServiceIdentifier } from "@/services";
|
||||
import type { CollectionInterface, CollectionPropertiesInterface } from "@/types/collection";
|
||||
|
||||
export class CollectionObject implements CollectionModelInterface {
|
||||
export class CollectionObject implements CollectionInterface {
|
||||
|
||||
_data!: CollectionInterface<CollectionPropertiesInterface>;
|
||||
_properties: CollectionPropertiesObject | undefined = undefined;
|
||||
_data!: CollectionInterface;
|
||||
|
||||
constructor() {
|
||||
this._data = {
|
||||
'@type': 'mail:collection',
|
||||
version: 1,
|
||||
provider: '',
|
||||
service: '',
|
||||
collection: null,
|
||||
identifier: '',
|
||||
properties: {'@type': 'mail:folder', label: ''},
|
||||
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 = clonePlain(data);
|
||||
this._properties = undefined;
|
||||
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._properties
|
||||
? {
|
||||
...this._data,
|
||||
properties: this._properties.toJson(),
|
||||
}
|
||||
: this._data;
|
||||
|
||||
return clonePlain(json);
|
||||
const json = { ...this._data };
|
||||
if (this._data.properties instanceof CollectionPropertiesObject) {
|
||||
json.properties = this._data.properties.toJson();
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
clone(): CollectionObject {
|
||||
return new CollectionObject().fromJson(this.toJson());
|
||||
const cloned = new CollectionObject();
|
||||
cloned._data = { ...this._data };
|
||||
cloned._data.properties = this.properties.clone();
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/** Immutable Properties */
|
||||
@@ -50,16 +61,16 @@ export class CollectionObject implements CollectionModelInterface {
|
||||
return this._data.provider;
|
||||
}
|
||||
|
||||
get service(): ServiceIdentifier {
|
||||
return this._data.service as ServiceIdentifier;
|
||||
get service(): string | number {
|
||||
return this._data.service;
|
||||
}
|
||||
|
||||
get collection(): CollectionIdentifier | null {
|
||||
return this._data.collection as CollectionIdentifier | null;
|
||||
get collection(): string | number | null {
|
||||
return this._data.collection;
|
||||
}
|
||||
|
||||
get identifier(): CollectionIdentifier {
|
||||
return this._data.identifier as CollectionIdentifier;
|
||||
get identifier(): string | number {
|
||||
return this._data.identifier;
|
||||
}
|
||||
|
||||
get signature(): string | null | undefined {
|
||||
@@ -75,54 +86,70 @@ export class CollectionObject implements CollectionModelInterface {
|
||||
}
|
||||
|
||||
get properties(): CollectionPropertiesObject {
|
||||
if (this._properties) {
|
||||
return this._properties;
|
||||
if (this._data.properties instanceof CollectionPropertiesObject) {
|
||||
return this._data.properties;
|
||||
}
|
||||
else if (this._data.properties) {
|
||||
const properties = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface);
|
||||
this._properties = properties;
|
||||
return 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) {
|
||||
this._properties = value;
|
||||
if (value instanceof CollectionPropertiesObject) {
|
||||
this._data.properties = value as any;
|
||||
} else {
|
||||
this._data.properties = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectionPropertiesObject implements CollectionPropertiesModelInterface {
|
||||
export class CollectionPropertiesObject implements CollectionPropertiesInterface {
|
||||
|
||||
private _data!: CollectionPropertiesInterface;
|
||||
_data!: CollectionPropertiesInterface;
|
||||
|
||||
constructor() {
|
||||
this._data = {
|
||||
'@type': 'mail:folder',
|
||||
'@type': 'mail.collection',
|
||||
version: 1,
|
||||
total: 0,
|
||||
unread: 0,
|
||||
role: null,
|
||||
label: '',
|
||||
role: null,
|
||||
rank: 0,
|
||||
subscribed: true,
|
||||
};
|
||||
}
|
||||
|
||||
fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject {
|
||||
this._data = clonePlain(data);
|
||||
this._data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): CollectionPropertiesInterface {
|
||||
return clonePlain(this._data);
|
||||
return this._data;
|
||||
}
|
||||
|
||||
clone(): CollectionPropertiesObject {
|
||||
return new CollectionPropertiesObject().fromJson(this.toJson());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,51 +2,62 @@
|
||||
* Class model for Message/Entity Interface
|
||||
*/
|
||||
|
||||
import type { EntityInterface, EntityModelInterface } from "@/types/entity";
|
||||
import type { MessageInterface } from "@/types/message";
|
||||
import type { EntityInterface } from "@/types/entity";
|
||||
import type { MessageInterface, MessagePartInterface } from "@/types/message";
|
||||
import { MessageObject } from "./message";
|
||||
import { clonePlain } from './clone-plain';
|
||||
import type { CollectionIdentifier, EntityIdentifier, ServiceIdentifier } from "@/services";
|
||||
|
||||
export class EntityObject implements EntityModelInterface {
|
||||
export class EntityObject {
|
||||
|
||||
private _data!: EntityInterface<MessageInterface>;
|
||||
private _properties: MessageObject | undefined = undefined;
|
||||
_data!: EntityInterface<MessageInterface>;
|
||||
_message!: MessageObject;
|
||||
|
||||
constructor() {
|
||||
this._data = {
|
||||
'@type': 'mail:entity',
|
||||
version: 1,
|
||||
'@type': 'mail.entity',
|
||||
provider: '',
|
||||
service: '',
|
||||
collection: null,
|
||||
identifier: null,
|
||||
collection: '',
|
||||
identifier: '',
|
||||
signature: null,
|
||||
created: null,
|
||||
modified: null,
|
||||
properties: {'@type': 'mail:message'},
|
||||
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 = clonePlain(data);
|
||||
this._properties = undefined;
|
||||
this._data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): EntityInterface<MessageInterface> {
|
||||
const json = this._properties
|
||||
? {
|
||||
...this._data,
|
||||
properties: this._properties.toJson(),
|
||||
}
|
||||
: this._data;
|
||||
|
||||
return clonePlain(json);
|
||||
return this._data;
|
||||
}
|
||||
|
||||
clone(): EntityObject {
|
||||
return new EntityObject().fromJson(this.toJson());
|
||||
const cloned = new EntityObject();
|
||||
cloned._data = {
|
||||
...this._data,
|
||||
properties: { ...this._data.properties }
|
||||
};
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/** Metadata Properties */
|
||||
@@ -55,16 +66,16 @@ export class EntityObject implements EntityModelInterface {
|
||||
return this._data.provider;
|
||||
}
|
||||
|
||||
get service(): ServiceIdentifier {
|
||||
return this._data.service as ServiceIdentifier;
|
||||
get service(): string {
|
||||
return this._data.service;
|
||||
}
|
||||
|
||||
get collection(): CollectionIdentifier {
|
||||
return this._data.collection as CollectionIdentifier;
|
||||
get collection(): string|number {
|
||||
return this._data.collection;
|
||||
}
|
||||
|
||||
get identifier(): EntityIdentifier {
|
||||
return this._data.identifier as EntityIdentifier;
|
||||
get identifier(): string|number {
|
||||
return this._data.identifier;
|
||||
}
|
||||
|
||||
get signature(): string | null {
|
||||
@@ -82,10 +93,15 @@ export class EntityObject implements EntityModelInterface {
|
||||
/** Message Object Properties */
|
||||
|
||||
get properties(): MessageObject {
|
||||
if (!this._properties) {
|
||||
this._properties = new MessageObject().fromJson(this._data.properties as MessageInterface);
|
||||
if (!this._message) {
|
||||
this._message = new MessageObject(this._data.properties);
|
||||
}
|
||||
return this._properties;
|
||||
return this._message;
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
get object(): MessageObject {
|
||||
return this.properties;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,55 +10,13 @@ import type {
|
||||
ServiceIdentityOAuth,
|
||||
ServiceIdentityCertificate
|
||||
} from '@/types/service';
|
||||
import { MutationProxy } from './mutation-proxy';
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
/**
|
||||
* Base Identity class
|
||||
*/
|
||||
export abstract class Identity<T extends ServiceIdentity = ServiceIdentity> {
|
||||
protected _original: T;
|
||||
protected _mutated: Partial<T>;
|
||||
protected _mutationProxy: MutationProxy<T>;
|
||||
protected _data: T;
|
||||
|
||||
protected constructor(initial: T) {
|
||||
this._original = clonePlain(initial);
|
||||
this._mutated = {};
|
||||
this._mutationProxy = new MutationProxy<T>(() => this._original, () => this._mutated);
|
||||
this._data = this._mutationProxy.create();
|
||||
}
|
||||
|
||||
protected load(data: T): this {
|
||||
this._original = clonePlain(data);
|
||||
this._mutated = {};
|
||||
this._data = this._mutationProxy.create();
|
||||
return this;
|
||||
}
|
||||
|
||||
toJSON(): ServiceIdentity {
|
||||
return this.toJson();
|
||||
}
|
||||
|
||||
toJson(): T;
|
||||
toJson(delta: true): Partial<T>;
|
||||
toJson(delta?: boolean): T | Partial<T> {
|
||||
if (delta) {
|
||||
return clonePlain(this._mutated);
|
||||
}
|
||||
|
||||
return {
|
||||
...clonePlain(this._original),
|
||||
...clonePlain(this._mutated),
|
||||
};
|
||||
}
|
||||
|
||||
abstract clone(): Identity;
|
||||
|
||||
mutated(): boolean {
|
||||
return Reflect.ownKeys(this._mutated).length > 0;
|
||||
}
|
||||
|
||||
export abstract class Identity {
|
||||
abstract toJson(): ServiceIdentity;
|
||||
|
||||
static fromJson(data: ServiceIdentity): Identity {
|
||||
switch (data.type) {
|
||||
case 'NA':
|
||||
@@ -80,109 +38,81 @@ export abstract class Identity<T extends ServiceIdentity = ServiceIdentity> {
|
||||
/**
|
||||
* No authentication
|
||||
*/
|
||||
export class IdentityNone extends Identity<ServiceIdentityNone> {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
type: 'NA'
|
||||
});
|
||||
}
|
||||
export class IdentityNone extends Identity {
|
||||
readonly type = 'NA' as const;
|
||||
|
||||
static fromJson(_data: ServiceIdentityNone): IdentityNone {
|
||||
return new IdentityNone();
|
||||
}
|
||||
|
||||
clone(): IdentityNone {
|
||||
return IdentityNone.fromJson(this.toJson());
|
||||
toJson(): ServiceIdentityNone {
|
||||
return {
|
||||
type: this.type
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'NA' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic authentication (username/password)
|
||||
*/
|
||||
export class IdentityBasic extends Identity<ServiceIdentityBasic> {
|
||||
export class IdentityBasic extends Identity {
|
||||
readonly type = 'BA' as const;
|
||||
identity: string;
|
||||
secret: string;
|
||||
|
||||
constructor(identity: string = '', secret: string = '') {
|
||||
super({
|
||||
type: 'BA',
|
||||
identity,
|
||||
secret
|
||||
});
|
||||
super();
|
||||
this.identity = identity;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceIdentityBasic): IdentityBasic {
|
||||
return new IdentityBasic().load(data);
|
||||
return new IdentityBasic(data.identity, data.secret);
|
||||
}
|
||||
|
||||
clone(): IdentityBasic {
|
||||
return IdentityBasic.fromJson(this.toJson());
|
||||
toJson(): ServiceIdentityBasic {
|
||||
return {
|
||||
type: this.type,
|
||||
identity: this.identity,
|
||||
secret: this.secret
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'BA' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get identity(): string {
|
||||
return this._data.identity;
|
||||
}
|
||||
|
||||
set identity(value: string) {
|
||||
this._data.identity = value;
|
||||
}
|
||||
|
||||
get secret(): string {
|
||||
return this._data.secret;
|
||||
}
|
||||
|
||||
set secret(value: string) {
|
||||
this._data.secret = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Token authentication (API key, static token)
|
||||
*/
|
||||
export class IdentityToken extends Identity<ServiceIdentityToken> {
|
||||
export class IdentityToken extends Identity {
|
||||
readonly type = 'TA' as const;
|
||||
token: string;
|
||||
|
||||
constructor(token: string = '') {
|
||||
super({
|
||||
type: 'TA',
|
||||
token
|
||||
});
|
||||
super();
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceIdentityToken): IdentityToken {
|
||||
return new IdentityToken().load(data);
|
||||
return new IdentityToken(data.token);
|
||||
}
|
||||
|
||||
clone(): IdentityToken {
|
||||
return IdentityToken.fromJson(this.toJson());
|
||||
toJson(): ServiceIdentityToken {
|
||||
return {
|
||||
type: this.type,
|
||||
token: this.token
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'TA' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get token(): string {
|
||||
return this._data.token;
|
||||
}
|
||||
|
||||
set token(value: string) {
|
||||
this._data.token = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth authentication
|
||||
*/
|
||||
export class IdentityOAuth extends Identity<ServiceIdentityOAuth> {
|
||||
export class IdentityOAuth extends Identity {
|
||||
readonly type = 'OA' as const;
|
||||
accessToken: string;
|
||||
accessScope?: string[];
|
||||
accessExpiry?: number;
|
||||
refreshToken?: string;
|
||||
refreshLocation?: string;
|
||||
|
||||
constructor(
|
||||
accessToken: string = '',
|
||||
@@ -191,22 +121,33 @@ export class IdentityOAuth extends Identity<ServiceIdentityOAuth> {
|
||||
refreshToken?: string,
|
||||
refreshLocation?: string
|
||||
) {
|
||||
super({
|
||||
type: 'OA',
|
||||
accessToken,
|
||||
accessScope,
|
||||
accessExpiry,
|
||||
refreshToken,
|
||||
refreshLocation
|
||||
});
|
||||
super();
|
||||
this.accessToken = accessToken;
|
||||
this.accessScope = accessScope;
|
||||
this.accessExpiry = accessExpiry;
|
||||
this.refreshToken = refreshToken;
|
||||
this.refreshLocation = refreshLocation;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceIdentityOAuth): IdentityOAuth {
|
||||
return new IdentityOAuth().load(data);
|
||||
return new IdentityOAuth(
|
||||
data.accessToken,
|
||||
data.accessScope,
|
||||
data.accessExpiry,
|
||||
data.refreshToken,
|
||||
data.refreshLocation
|
||||
);
|
||||
}
|
||||
|
||||
clone(): IdentityOAuth {
|
||||
return IdentityOAuth.fromJson(this.toJson());
|
||||
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 {
|
||||
@@ -218,101 +159,38 @@ export class IdentityOAuth extends Identity<ServiceIdentityOAuth> {
|
||||
if (!this.accessExpiry) return Infinity;
|
||||
return Math.max(0, this.accessExpiry - Date.now() / 1000);
|
||||
}
|
||||
|
||||
get type(): 'OA' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get accessToken(): string {
|
||||
return this._data.accessToken;
|
||||
}
|
||||
|
||||
set accessToken(value: string) {
|
||||
this._data.accessToken = value;
|
||||
}
|
||||
|
||||
get accessScope(): string[] | undefined {
|
||||
return this._data.accessScope ? [...this._data.accessScope] : undefined;
|
||||
}
|
||||
|
||||
set accessScope(value: string[] | undefined) {
|
||||
this._data.accessScope = value ? [...value] : undefined;
|
||||
}
|
||||
|
||||
get accessExpiry(): number | undefined {
|
||||
return this._data.accessExpiry;
|
||||
}
|
||||
|
||||
set accessExpiry(value: number | undefined) {
|
||||
this._data.accessExpiry = value;
|
||||
}
|
||||
|
||||
get refreshToken(): string | undefined {
|
||||
return this._data.refreshToken;
|
||||
}
|
||||
|
||||
set refreshToken(value: string | undefined) {
|
||||
this._data.refreshToken = value;
|
||||
}
|
||||
|
||||
get refreshLocation(): string | undefined {
|
||||
return this._data.refreshLocation;
|
||||
}
|
||||
|
||||
set refreshLocation(value: string | undefined) {
|
||||
this._data.refreshLocation = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Client certificate authentication (mTLS)
|
||||
*/
|
||||
export class IdentityCertificate extends Identity<ServiceIdentityCertificate> {
|
||||
export class IdentityCertificate extends Identity {
|
||||
readonly type = 'CC' as const;
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
passphrase?: string;
|
||||
|
||||
constructor(certificate: string = '', privateKey: string = '', passphrase?: string) {
|
||||
super({
|
||||
type: 'CC',
|
||||
certificate,
|
||||
privateKey,
|
||||
passphrase
|
||||
});
|
||||
super();
|
||||
this.certificate = certificate;
|
||||
this.privateKey = privateKey;
|
||||
this.passphrase = passphrase;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceIdentityCertificate): IdentityCertificate {
|
||||
return new IdentityCertificate().load(data);
|
||||
return new IdentityCertificate(
|
||||
data.certificate,
|
||||
data.privateKey,
|
||||
data.passphrase
|
||||
);
|
||||
}
|
||||
|
||||
clone(): IdentityCertificate {
|
||||
return IdentityCertificate.fromJson(this.toJson());
|
||||
toJson(): ServiceIdentityCertificate {
|
||||
return {
|
||||
type: this.type,
|
||||
certificate: this.certificate,
|
||||
privateKey: this.privateKey,
|
||||
...(this.passphrase && { passphrase: this.passphrase })
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'CC' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get certificate(): string {
|
||||
return this._data.certificate;
|
||||
}
|
||||
|
||||
set certificate(value: string) {
|
||||
this._data.certificate = value;
|
||||
}
|
||||
|
||||
get privateKey(): string {
|
||||
return this._data.privateKey;
|
||||
}
|
||||
|
||||
set privateKey(value: string) {
|
||||
this._data.privateKey = value;
|
||||
}
|
||||
|
||||
get passphrase(): string | undefined {
|
||||
return this._data.passphrase;
|
||||
}
|
||||
|
||||
set passphrase(value: string | undefined) {
|
||||
this._data.passphrase = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Central export point for all Mail Manager models
|
||||
*/
|
||||
|
||||
export { CollectionObject } from './collection';
|
||||
export { EntityObject } from './entity';
|
||||
export { ProviderObject } from './provider';
|
||||
export { ServiceObject } from './service';
|
||||
export {
|
||||
CollectionObject,
|
||||
CollectionPropertiesObject
|
||||
} from './collection';
|
||||
export { EntityObject } from './entity';
|
||||
export {
|
||||
MessageObject,
|
||||
MessagePartObject
|
||||
} from './message';
|
||||
|
||||
// Identity models
|
||||
export {
|
||||
Identity,
|
||||
IdentityNone,
|
||||
@@ -17,6 +16,8 @@ export {
|
||||
IdentityOAuth,
|
||||
IdentityCertificate
|
||||
} from './identity';
|
||||
|
||||
// Location models
|
||||
export {
|
||||
Location,
|
||||
LocationUri,
|
||||
@@ -24,6 +25,3 @@ export {
|
||||
LocationSocketSplit,
|
||||
LocationFile
|
||||
} from './location';
|
||||
export {
|
||||
MutationProxy
|
||||
} from './mutation-proxy';
|
||||
|
||||
@@ -9,50 +9,12 @@ import type {
|
||||
ServiceLocationSocketSplit,
|
||||
ServiceLocationFile
|
||||
} from '@/types/service';
|
||||
import { MutationProxy } from './mutation-proxy';
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
/**
|
||||
* Base Location class
|
||||
*/
|
||||
export abstract class Location<T extends ServiceLocation = ServiceLocation> {
|
||||
protected _original: T;
|
||||
protected _mutated: Partial<T>;
|
||||
protected _mutationProxy: MutationProxy<T>;
|
||||
protected _data: T;
|
||||
|
||||
protected constructor(initial: T) {
|
||||
this._original = clonePlain(initial);
|
||||
this._mutated = {};
|
||||
this._mutationProxy = new MutationProxy<T>(() => this._original, () => this._mutated);
|
||||
this._data = this._mutationProxy.create();
|
||||
}
|
||||
|
||||
protected load(data: T): this {
|
||||
this._original = clonePlain(data);
|
||||
this._mutated = {};
|
||||
this._data = this._mutationProxy.create();
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): T;
|
||||
toJson(delta: true): Partial<T>;
|
||||
toJson(delta?: boolean): T | Partial<T> {
|
||||
if (delta) {
|
||||
return clonePlain(this._mutated);
|
||||
}
|
||||
|
||||
return {
|
||||
...clonePlain(this._original),
|
||||
...clonePlain(this._mutated),
|
||||
};
|
||||
}
|
||||
|
||||
abstract clone(): Location;
|
||||
|
||||
mutated(): boolean {
|
||||
return Reflect.ownKeys(this._mutated).length > 0;
|
||||
}
|
||||
export abstract class Location {
|
||||
abstract toJson(): ServiceLocation;
|
||||
|
||||
static fromJson(data: ServiceLocation): Location {
|
||||
switch (data.type) {
|
||||
@@ -74,7 +36,14 @@ export abstract class Location<T extends ServiceLocation = ServiceLocation> {
|
||||
* URI-based service location for API and web services
|
||||
* Used by: JMAP, Gmail API, etc.
|
||||
*/
|
||||
export class LocationUri extends Location<ServiceLocationUri> {
|
||||
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',
|
||||
@@ -84,89 +53,55 @@ export class LocationUri extends Location<ServiceLocationUri> {
|
||||
verifyPeer: boolean = true,
|
||||
verifyHost: boolean = true
|
||||
) {
|
||||
super({
|
||||
type: 'URI',
|
||||
scheme,
|
||||
host,
|
||||
port,
|
||||
...(path !== undefined && { path }),
|
||||
verifyPeer,
|
||||
verifyHost,
|
||||
});
|
||||
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().load(data);
|
||||
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}`;
|
||||
}
|
||||
|
||||
clone(): LocationUri {
|
||||
return LocationUri.fromJson(structuredClone(this.toJson()));
|
||||
}
|
||||
|
||||
get type(): 'URI' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get scheme(): string {
|
||||
return this._data.scheme;
|
||||
}
|
||||
|
||||
set scheme(value: string) {
|
||||
this._data.scheme = value;
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
return this._data.host;
|
||||
}
|
||||
|
||||
set host(value: string) {
|
||||
this._data.host = value;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._data.port;
|
||||
}
|
||||
|
||||
set port(value: number) {
|
||||
this._data.port = value;
|
||||
}
|
||||
|
||||
get path(): string | undefined {
|
||||
return this._data.path;
|
||||
}
|
||||
|
||||
set path(value: string | undefined) {
|
||||
this._data.path = value;
|
||||
}
|
||||
|
||||
get verifyPeer(): boolean {
|
||||
return this._data.verifyPeer ?? true;
|
||||
}
|
||||
|
||||
set verifyPeer(value: boolean) {
|
||||
this._data.verifyPeer = value;
|
||||
}
|
||||
|
||||
get verifyHost(): boolean {
|
||||
return this._data.verifyHost ?? true;
|
||||
}
|
||||
|
||||
set verifyHost(value: boolean) {
|
||||
this._data.verifyHost = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Single socket-based service location
|
||||
* Used by: services using a single host/port combination
|
||||
*/
|
||||
export class LocationSocketSole extends Location<ServiceLocationSocketSole> {
|
||||
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 = '',
|
||||
@@ -175,75 +110,52 @@ export class LocationSocketSole extends Location<ServiceLocationSocketSole> {
|
||||
verifyPeer: boolean = true,
|
||||
verifyHost: boolean = true
|
||||
) {
|
||||
super({
|
||||
type: 'SOCKET_SOLE',
|
||||
host,
|
||||
port,
|
||||
encryption,
|
||||
verifyPeer,
|
||||
verifyHost,
|
||||
});
|
||||
super();
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.encryption = encryption;
|
||||
this.verifyPeer = verifyPeer;
|
||||
this.verifyHost = verifyHost;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceLocationSocketSole): LocationSocketSole {
|
||||
return new LocationSocketSole().load(data);
|
||||
return new LocationSocketSole(
|
||||
data.host,
|
||||
data.port,
|
||||
data.encryption,
|
||||
data.verifyPeer ?? true,
|
||||
data.verifyHost ?? true
|
||||
);
|
||||
}
|
||||
|
||||
clone(): LocationSocketSole {
|
||||
return LocationSocketSole.fromJson(structuredClone(this.toJson()));
|
||||
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 })
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'SOCKET_SOLE' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
return this._data.host;
|
||||
}
|
||||
|
||||
set host(value: string) {
|
||||
this._data.host = value;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._data.port;
|
||||
}
|
||||
|
||||
set port(value: number) {
|
||||
this._data.port = value;
|
||||
}
|
||||
|
||||
get encryption(): 'none' | 'ssl' | 'tls' | 'starttls' {
|
||||
return this._data.encryption;
|
||||
}
|
||||
|
||||
set encryption(value: 'none' | 'ssl' | 'tls' | 'starttls') {
|
||||
this._data.encryption = value;
|
||||
}
|
||||
|
||||
get verifyPeer(): boolean {
|
||||
return this._data.verifyPeer ?? true;
|
||||
}
|
||||
|
||||
set verifyPeer(value: boolean) {
|
||||
this._data.verifyPeer = value;
|
||||
}
|
||||
|
||||
get verifyHost(): boolean {
|
||||
return this._data.verifyHost ?? true;
|
||||
}
|
||||
|
||||
set verifyHost(value: boolean) {
|
||||
this._data.verifyHost = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Split socket-based service location
|
||||
* Used by: traditional IMAP/SMTP configurations
|
||||
*/
|
||||
export class LocationSocketSplit extends Location<ServiceLocationSocketSplit> {
|
||||
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 = '',
|
||||
@@ -257,146 +169,72 @@ export class LocationSocketSplit extends Location<ServiceLocationSocketSplit> {
|
||||
outboundVerifyPeer: boolean = true,
|
||||
outboundVerifyHost: boolean = true
|
||||
) {
|
||||
super({
|
||||
type: 'SOCKET_SPLIT',
|
||||
inboundHost,
|
||||
inboundPort,
|
||||
inboundEncryption,
|
||||
outboundHost,
|
||||
outboundPort,
|
||||
outboundEncryption,
|
||||
inboundVerifyPeer,
|
||||
inboundVerifyHost,
|
||||
outboundVerifyPeer,
|
||||
outboundVerifyHost,
|
||||
});
|
||||
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().load(data);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
clone(): LocationSocketSplit {
|
||||
return LocationSocketSplit.fromJson(structuredClone(this.toJson()));
|
||||
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 })
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'SOCKET_SPLIT' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get inboundHost(): string {
|
||||
return this._data.inboundHost;
|
||||
}
|
||||
|
||||
set inboundHost(value: string) {
|
||||
this._data.inboundHost = value;
|
||||
}
|
||||
|
||||
get inboundPort(): number {
|
||||
return this._data.inboundPort;
|
||||
}
|
||||
|
||||
set inboundPort(value: number) {
|
||||
this._data.inboundPort = value;
|
||||
}
|
||||
|
||||
get inboundEncryption(): 'none' | 'ssl' | 'tls' | 'starttls' {
|
||||
return this._data.inboundEncryption;
|
||||
}
|
||||
|
||||
set inboundEncryption(value: 'none' | 'ssl' | 'tls' | 'starttls') {
|
||||
this._data.inboundEncryption = value;
|
||||
}
|
||||
|
||||
get outboundHost(): string {
|
||||
return this._data.outboundHost;
|
||||
}
|
||||
|
||||
set outboundHost(value: string) {
|
||||
this._data.outboundHost = value;
|
||||
}
|
||||
|
||||
get outboundPort(): number {
|
||||
return this._data.outboundPort;
|
||||
}
|
||||
|
||||
set outboundPort(value: number) {
|
||||
this._data.outboundPort = value;
|
||||
}
|
||||
|
||||
get outboundEncryption(): 'none' | 'ssl' | 'tls' | 'starttls' {
|
||||
return this._data.outboundEncryption;
|
||||
}
|
||||
|
||||
set outboundEncryption(value: 'none' | 'ssl' | 'tls' | 'starttls') {
|
||||
this._data.outboundEncryption = value;
|
||||
}
|
||||
|
||||
get inboundVerifyPeer(): boolean {
|
||||
return this._data.inboundVerifyPeer ?? true;
|
||||
}
|
||||
|
||||
set inboundVerifyPeer(value: boolean) {
|
||||
this._data.inboundVerifyPeer = value;
|
||||
}
|
||||
|
||||
get inboundVerifyHost(): boolean {
|
||||
return this._data.inboundVerifyHost ?? true;
|
||||
}
|
||||
|
||||
set inboundVerifyHost(value: boolean) {
|
||||
this._data.inboundVerifyHost = value;
|
||||
}
|
||||
|
||||
get outboundVerifyPeer(): boolean {
|
||||
return this._data.outboundVerifyPeer ?? true;
|
||||
}
|
||||
|
||||
set outboundVerifyPeer(value: boolean) {
|
||||
this._data.outboundVerifyPeer = value;
|
||||
}
|
||||
|
||||
get outboundVerifyHost(): boolean {
|
||||
return this._data.outboundVerifyHost ?? true;
|
||||
}
|
||||
|
||||
set outboundVerifyHost(value: boolean) {
|
||||
this._data.outboundVerifyHost = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based service location
|
||||
* Used by: local file system providers
|
||||
*/
|
||||
export class LocationFile extends Location<ServiceLocationFile> {
|
||||
export class LocationFile extends Location {
|
||||
readonly type = 'FILE' as const;
|
||||
path: string;
|
||||
|
||||
constructor(path: string = '') {
|
||||
super({
|
||||
type: 'FILE',
|
||||
path,
|
||||
});
|
||||
super();
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
static fromJson(data: ServiceLocationFile): LocationFile {
|
||||
return new LocationFile().load(data);
|
||||
return new LocationFile(data.path);
|
||||
}
|
||||
|
||||
clone(): LocationFile {
|
||||
return LocationFile.fromJson(structuredClone(this.toJson()));
|
||||
toJson(): ServiceLocationFile {
|
||||
return {
|
||||
type: this.type,
|
||||
path: this.path
|
||||
};
|
||||
}
|
||||
|
||||
get type(): 'FILE' {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this._data.path;
|
||||
}
|
||||
|
||||
set path(value: string) {
|
||||
this._data.path = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,338 +1,94 @@
|
||||
/**
|
||||
* Message and MessagePart model classes
|
||||
*/
|
||||
import type {
|
||||
MessageAddressInterface,
|
||||
MessageInterface,
|
||||
MessageModelInterface,
|
||||
MessagePartInterface,
|
||||
MessagePartModelInterface
|
||||
} from "@/types/message";
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
/**
|
||||
* Message class for working with message objects
|
||||
*/
|
||||
export class MessageObject implements MessageModelInterface {
|
||||
|
||||
_data: MessageInterface;
|
||||
_body: MessagePartObject | null = null;
|
||||
|
||||
constructor() {
|
||||
this._data = {
|
||||
'@type': 'mail:message',
|
||||
};
|
||||
this._body = null;
|
||||
}
|
||||
|
||||
fromJson(data: MessageInterface): MessageObject {
|
||||
this._data = clonePlain(data);
|
||||
this._body = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): MessageInterface {
|
||||
const json = this._body
|
||||
? {
|
||||
...this._data,
|
||||
body: this._body.toJson(),
|
||||
}
|
||||
: this._data;
|
||||
|
||||
return clonePlain(json);
|
||||
}
|
||||
|
||||
clone(): MessageObject {
|
||||
return new MessageObject().fromJson(this.toJson());
|
||||
}
|
||||
|
||||
/** Properties */
|
||||
|
||||
get size(): number {
|
||||
return this._data.size ?? 0;
|
||||
}
|
||||
|
||||
get headers(): Record<string, string> {
|
||||
return clonePlain(this._data.headers ?? {});
|
||||
}
|
||||
|
||||
get urid(): string | null{
|
||||
return this._data.urid ?? null;
|
||||
}
|
||||
|
||||
get inReplyTo(): string | null {
|
||||
return this._data.inReplyTo ?? null;
|
||||
}
|
||||
|
||||
get references(): string | null {
|
||||
return this._data.references ?? null;
|
||||
}
|
||||
|
||||
get received(): string | null {
|
||||
return this._data.received ?? null;
|
||||
}
|
||||
|
||||
get sent(): string | null {
|
||||
return this._data.sent ?? null;
|
||||
}
|
||||
|
||||
get sender(): MessageAddressObject | null {
|
||||
return this._data.sender ? new MessageAddressObject(this._data.sender) : null;
|
||||
}
|
||||
|
||||
get from(): MessageAddressObject | null {
|
||||
return this._data.from ? new MessageAddressObject(this._data.from) : null;
|
||||
}
|
||||
|
||||
get replyTo(): Array<MessageAddressObject> | null {
|
||||
return this._data.replyTo ? this._data.replyTo.map(addr => new MessageAddressObject(addr)) : null;
|
||||
}
|
||||
|
||||
get to(): Array<MessageAddressObject> | null {
|
||||
return this._data.to ? this._data.to.map(addr => new MessageAddressObject(addr)) : null;
|
||||
}
|
||||
|
||||
get cc(): Array<MessageAddressObject> | null {
|
||||
return this._data.cc ? this._data.cc.map(addr => new MessageAddressObject(addr)) : null;
|
||||
}
|
||||
|
||||
get bcc(): Array<MessageAddressObject> | null {
|
||||
return this._data.bcc ? this._data.bcc.map(addr => new MessageAddressObject(addr)) : null;
|
||||
}
|
||||
|
||||
get subject(): string | null {
|
||||
return this._data.subject ?? null;
|
||||
}
|
||||
|
||||
get body(): MessagePartObject | null {
|
||||
if (this._body) {
|
||||
return this._body;
|
||||
}
|
||||
else if (this._data.body) {
|
||||
this._body = new MessagePartObject(this._data.body);
|
||||
return this._body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get attachments(): Array<MessagePartObject> {
|
||||
return this._data.attachments ? this._data.attachments.map(att => new MessagePartObject(att)) : [];
|
||||
}
|
||||
|
||||
get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | {} {
|
||||
return clonePlain(this._data.flags ?? {});
|
||||
}
|
||||
|
||||
// this should be moved to a mutable object, but for now we can allow it here for convenience
|
||||
set flags(value: { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean }) {
|
||||
this._data.flags = clonePlain(value);
|
||||
}
|
||||
|
||||
/** 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) : [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class MessageAddressObject implements MessageAddressInterface {
|
||||
|
||||
_data: MessageAddressInterface;
|
||||
|
||||
constructor(data: MessageAddressInterface) {
|
||||
this._data = clonePlain(data);
|
||||
}
|
||||
|
||||
fromJson(data: MessageAddressInterface): MessageAddressObject {
|
||||
this._data = clonePlain(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): MessageAddressInterface {
|
||||
return clonePlain(this._data);
|
||||
}
|
||||
|
||||
clone(): MessageAddressObject {
|
||||
return new MessageAddressObject(this.toJson());
|
||||
}
|
||||
|
||||
/** Properties */
|
||||
|
||||
get address(): string {
|
||||
return this._data.address;
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
return this._data.label;
|
||||
}
|
||||
|
||||
}
|
||||
import type { MessageInterface, MessagePartInterface } from "@/types/message";
|
||||
|
||||
/**
|
||||
* MessagePart class for working with message body parts
|
||||
*/
|
||||
export class MessagePartObject implements MessagePartModelInterface {
|
||||
export class MessagePartObject {
|
||||
|
||||
_data: MessagePartInterface;
|
||||
_subParts: MessagePartObject[] = [];
|
||||
|
||||
constructor(data?: Partial<MessagePartInterface>) {
|
||||
this._data = {
|
||||
partId: clonePlain(data?.partId ?? null),
|
||||
blobId: clonePlain(data?.blobId ?? null),
|
||||
size: clonePlain(data?.size ?? null),
|
||||
name: clonePlain(data?.name ?? null),
|
||||
type: clonePlain(data?.type ?? null),
|
||||
charset: clonePlain(data?.charset ?? null),
|
||||
disposition: clonePlain(data?.disposition ?? null),
|
||||
cid: clonePlain(data?.cid ?? null),
|
||||
language: clonePlain(data?.language ?? null),
|
||||
location: clonePlain(data?.location ?? null),
|
||||
content: clonePlain(data?.content ?? null),
|
||||
subParts: clonePlain(data?.subParts ?? []),
|
||||
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 = clonePlain(data);
|
||||
this._subParts = [];
|
||||
this._data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): MessagePartInterface {
|
||||
const json = this._subParts.length > 0
|
||||
? {
|
||||
...this._data,
|
||||
subParts: this._subParts.map(subPart => subPart.toJson()),
|
||||
}
|
||||
: this._data;
|
||||
|
||||
return clonePlain(json);
|
||||
return this._data;
|
||||
}
|
||||
|
||||
clone(): MessagePartObject {
|
||||
return new MessagePartObject().fromJson(this.toJson());
|
||||
return new MessagePartObject(JSON.parse(JSON.stringify(this._data)));
|
||||
}
|
||||
|
||||
/** Properties */
|
||||
|
||||
get partId(): string | null {
|
||||
return this._data.partId ?? null;
|
||||
get partId(): string | null | undefined {
|
||||
return this._data.partId;
|
||||
}
|
||||
|
||||
get blobId(): string | null {
|
||||
return this._data.blobId ?? null;
|
||||
get blobId(): string | null | undefined {
|
||||
return this._data.blobId;
|
||||
}
|
||||
|
||||
get size(): number | null {
|
||||
return this._data.size ?? null;
|
||||
get size(): number | null | undefined {
|
||||
return this._data.size;
|
||||
}
|
||||
|
||||
get name(): string | null {
|
||||
return this._data.name ?? null;
|
||||
get name(): string | null | undefined {
|
||||
return this._data.name;
|
||||
}
|
||||
|
||||
get type(): string | null {
|
||||
return this._data.type ?? null;
|
||||
get type(): string | undefined {
|
||||
return this._data.type;
|
||||
}
|
||||
|
||||
get charset(): string | null {
|
||||
return this._data.charset ?? null;
|
||||
get charset(): string | null | undefined {
|
||||
return this._data.charset;
|
||||
}
|
||||
|
||||
get disposition(): string | null {
|
||||
return this._data.disposition ?? null;
|
||||
get disposition(): string | null | undefined {
|
||||
return this._data.disposition;
|
||||
}
|
||||
|
||||
get cid(): string | null {
|
||||
return this._data.cid ?? null;
|
||||
get cid(): string | null | undefined {
|
||||
return this._data.cid;
|
||||
}
|
||||
|
||||
get language(): string | null {
|
||||
return this._data.language ?? null;
|
||||
get language(): string | null | undefined {
|
||||
return this._data.language;
|
||||
}
|
||||
|
||||
get location(): string | null {
|
||||
return this._data.location ?? null;
|
||||
get location(): string | null | undefined {
|
||||
return this._data.location;
|
||||
}
|
||||
|
||||
get content(): string | null {
|
||||
return this._data.content ?? null;
|
||||
get content(): string | undefined {
|
||||
return this._data.content;
|
||||
}
|
||||
|
||||
get subParts(): MessagePartModelInterface[] {
|
||||
if (this._subParts) {
|
||||
return this._subParts;
|
||||
}
|
||||
else if (this._data.subParts) {
|
||||
this._subParts = this._data.subParts.map((subPart) => new MessagePartObject(subPart));
|
||||
return this._subParts;
|
||||
}
|
||||
return [];
|
||||
get subParts(): MessagePartInterface[] | undefined {
|
||||
return this._data.subParts;
|
||||
}
|
||||
|
||||
/** Helper methods */
|
||||
@@ -342,7 +98,7 @@ export class MessagePartObject implements MessagePartModelInterface {
|
||||
}
|
||||
|
||||
hasSubParts(): boolean {
|
||||
return this.subParts.length > 0;
|
||||
return !!this._data.subParts && this._data.subParts.length > 0;
|
||||
}
|
||||
|
||||
isMultipart(): boolean {
|
||||
@@ -450,3 +206,171 @@ export class MessagePartObject implements MessagePartModelInterface {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) : [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
export class MutationProxy<T extends object> {
|
||||
|
||||
private readonly getOriginal: () => T;
|
||||
private readonly getMutated: () => Partial<T>;
|
||||
|
||||
constructor(
|
||||
getOriginal: () => T,
|
||||
getMutated: () => Partial<T>,
|
||||
) {
|
||||
this.getOriginal = getOriginal;
|
||||
this.getMutated = getMutated;
|
||||
}
|
||||
|
||||
create(): T {
|
||||
return new Proxy({} as T, {
|
||||
get: (_target, prop: string | symbol) => {
|
||||
if (typeof prop !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = prop as keyof T;
|
||||
const mutated = this.getMutated();
|
||||
const original = this.getOriginal();
|
||||
return key in mutated ? mutated[key] : original[key];
|
||||
},
|
||||
set: (_target, prop: string | symbol, value: unknown) => {
|
||||
if (typeof prop === 'string') {
|
||||
const key = prop as keyof T;
|
||||
(this.getMutated() as Record<keyof T, unknown>)[key] = clonePlain(value);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
has: (_target, prop: string | symbol) => {
|
||||
if (typeof prop !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mutated = this.getMutated();
|
||||
const original = this.getOriginal();
|
||||
return prop in mutated || prop in original;
|
||||
},
|
||||
ownKeys: () => {
|
||||
const mutated = this.getMutated();
|
||||
const original = this.getOriginal();
|
||||
|
||||
return Array.from(new Set([
|
||||
...Reflect.ownKeys(original),
|
||||
...Reflect.ownKeys(mutated),
|
||||
]));
|
||||
},
|
||||
getOwnPropertyDescriptor: () => ({
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,19 +4,16 @@
|
||||
|
||||
import type {
|
||||
ProviderInterface,
|
||||
ProviderCapabilitiesInterface,
|
||||
ProviderModelInterface
|
||||
ProviderCapabilitiesInterface
|
||||
} from "@/types/provider";
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
export class ProviderObject implements ProviderModelInterface {
|
||||
export class ProviderObject implements ProviderInterface {
|
||||
|
||||
_data!: ProviderInterface;
|
||||
|
||||
constructor() {
|
||||
this._data = {
|
||||
'@type': 'mail:provider',
|
||||
version: 1,
|
||||
'@type': 'mail.provider',
|
||||
identifier: '',
|
||||
label: '',
|
||||
capabilities: {},
|
||||
@@ -24,16 +21,12 @@ export class ProviderObject implements ProviderModelInterface {
|
||||
}
|
||||
|
||||
fromJson(data: ProviderInterface): ProviderObject {
|
||||
this._data = clonePlain(data);
|
||||
this._data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): ProviderInterface {
|
||||
return clonePlain(this._data);
|
||||
}
|
||||
|
||||
clone(): ProviderObject {
|
||||
return new ProviderObject().fromJson(this.toJson());
|
||||
return this._data;
|
||||
}
|
||||
|
||||
capable(capability: keyof ProviderCapabilitiesInterface): boolean {
|
||||
@@ -50,6 +43,10 @@ export class ProviderObject implements ProviderModelInterface {
|
||||
|
||||
/** Immutable Properties */
|
||||
|
||||
get '@type'(): string {
|
||||
return this._data['@type'];
|
||||
}
|
||||
|
||||
get identifier(): string {
|
||||
return this._data.identifier;
|
||||
}
|
||||
@@ -59,7 +56,7 @@ export class ProviderObject implements ProviderModelInterface {
|
||||
}
|
||||
|
||||
get capabilities(): ProviderCapabilitiesInterface {
|
||||
return clonePlain(this._data.capabilities);
|
||||
return this._data.capabilities;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,91 +5,34 @@
|
||||
import type {
|
||||
ServiceInterface,
|
||||
ServiceCapabilitiesInterface,
|
||||
ServiceLocation,
|
||||
ServiceModelInterface
|
||||
ServiceIdentity,
|
||||
ServiceLocation
|
||||
} from "@/types/service";
|
||||
import { Identity } from './identity';
|
||||
import { Location } from './location';
|
||||
import { MutationProxy } from './mutation-proxy';
|
||||
import { clonePlain } from './clone-plain';
|
||||
|
||||
export class ServiceObject implements ServiceModelInterface {
|
||||
export class ServiceObject implements ServiceInterface {
|
||||
|
||||
private _original: ServiceInterface;
|
||||
private _mutated: Partial<ServiceInterface>;
|
||||
private _mutationProxy = new MutationProxy<ServiceInterface>(() => this._original, () => this._mutated);
|
||||
_data!: ServiceInterface;
|
||||
_location: Location | null | undefined = undefined;
|
||||
_identity: Identity | null | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
this._original = {
|
||||
this._data = {
|
||||
'@type': 'mail:service',
|
||||
version: 1,
|
||||
provider: '',
|
||||
identifier: null,
|
||||
label: null,
|
||||
enabled: false,
|
||||
capabilities: {}
|
||||
};
|
||||
this._mutated = {};
|
||||
this._data = this._mutationProxy.create();
|
||||
}
|
||||
|
||||
fromJson(data: ServiceInterface): ServiceObject {
|
||||
this._original = clonePlain(data);
|
||||
this._mutated = {};
|
||||
this._data = this._mutationProxy.create();
|
||||
this._location = undefined;
|
||||
this._identity = undefined;
|
||||
this._data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): ServiceInterface;
|
||||
toJson(delta: true): Partial<ServiceInterface>;
|
||||
toJson(delta?: boolean): ServiceInterface | Partial<ServiceInterface> {
|
||||
if (this._location !== undefined) {
|
||||
if (!delta) {
|
||||
// handled below to preserve full ServiceInterface typing
|
||||
}
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
const json: Partial<ServiceInterface> = clonePlain(this._mutated);
|
||||
|
||||
if (this._location?.mutated()) {
|
||||
json.location = this._location.toJson(true) as ServiceInterface['location'];
|
||||
}
|
||||
|
||||
if (this._identity?.mutated()) {
|
||||
json.identity = this._identity.toJson(true) as ServiceInterface['identity'];
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
const json: ServiceInterface = {
|
||||
...clonePlain(this._original),
|
||||
...clonePlain(this._mutated),
|
||||
};
|
||||
|
||||
if (this._location !== undefined) {
|
||||
json.location = this._location ? this._location.toJson() : null;
|
||||
}
|
||||
|
||||
if (this._identity !== undefined) {
|
||||
json.identity = this._identity ? this._identity.toJson() : null;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
clone(): ServiceObject {
|
||||
return new ServiceObject().fromJson(this.toJson());
|
||||
}
|
||||
|
||||
mutated(): boolean {
|
||||
return Reflect.ownKeys(this._mutated).length > 0 || (this._location?.mutated() ?? false) || (this._identity?.mutated() ?? false);
|
||||
toJson(): ServiceInterface {
|
||||
return this._data;
|
||||
}
|
||||
|
||||
capable(capability: keyof ServiceCapabilitiesInterface): boolean {
|
||||
@@ -106,6 +49,10 @@ export class ServiceObject implements ServiceModelInterface {
|
||||
|
||||
/** Immutable Properties */
|
||||
|
||||
get '@type'(): string {
|
||||
return this._data['@type'];
|
||||
}
|
||||
|
||||
get provider(): string {
|
||||
return this._data.provider;
|
||||
}
|
||||
@@ -114,26 +61,18 @@ export class ServiceObject implements ServiceModelInterface {
|
||||
return this._data.identifier;
|
||||
}
|
||||
|
||||
get capabilities(): ServiceCapabilitiesInterface {
|
||||
return this._data.capabilities ?? {};
|
||||
get capabilities(): ServiceCapabilitiesInterface | undefined {
|
||||
return this._data.capabilities;
|
||||
}
|
||||
|
||||
get primaryAddress(): string | null {
|
||||
return this._data.primaryAddress ?? null;
|
||||
}
|
||||
|
||||
set primaryAddress(value: string | null) {
|
||||
this._data.primaryAddress = value;
|
||||
}
|
||||
|
||||
get secondaryAddresses(): string[] {
|
||||
return this._data.secondaryAddresses ?? [];
|
||||
}
|
||||
|
||||
set secondaryAddresses(value: string[] | null) {
|
||||
this._data.secondaryAddresses = value;
|
||||
}
|
||||
|
||||
/** Mutable Properties */
|
||||
|
||||
get label(): string | null {
|
||||
@@ -152,48 +91,46 @@ export class ServiceObject implements ServiceModelInterface {
|
||||
this._data.enabled = value;
|
||||
}
|
||||
|
||||
get location(): Location | null {
|
||||
if (this._location !== undefined) {
|
||||
return this._location;
|
||||
}
|
||||
|
||||
if (this._data.location) {
|
||||
this._location = Location.fromJson(this._data.location as ServiceLocation);
|
||||
return this._location;
|
||||
}
|
||||
|
||||
this._location = null;
|
||||
return null;
|
||||
get location(): ServiceLocation | null {
|
||||
return this._data.location ?? null;
|
||||
}
|
||||
|
||||
set location(value: Location | null) {
|
||||
this._location = value;
|
||||
set location(value: ServiceLocation | null) {
|
||||
this._data.location = value;
|
||||
}
|
||||
|
||||
get identity(): Identity | null {
|
||||
if (this._identity !== undefined) {
|
||||
return this._identity;
|
||||
}
|
||||
|
||||
if (this._data.identity) {
|
||||
this._identity = Identity.fromJson(this._data.identity);
|
||||
return this._identity;
|
||||
}
|
||||
|
||||
this._identity = null;
|
||||
return null;
|
||||
get identity(): ServiceIdentity | null {
|
||||
return this._data.identity ?? null;
|
||||
}
|
||||
|
||||
set identity(value: Identity | null) {
|
||||
this._identity = value;
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||
import { useServicesStore } from '../stores/servicesStore'
|
||||
import AddAccountDialog from '../components/AddAccountDialog.vue'
|
||||
import EditAccountDialog from '../components/EditAccountDialog.vue'
|
||||
import type { ServiceObject } from '../models'
|
||||
import { useServicesStore } from '@/stores/servicesStore'
|
||||
import AddAccountDialog from '@/components/AddAccountDialog.vue'
|
||||
import type { ServiceObject } from '@/models'
|
||||
|
||||
const servicesStore = useServicesStore()
|
||||
const integrationStore = useIntegrationStore()
|
||||
|
||||
const showAddDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
@@ -15,20 +12,13 @@ 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 providerMetadata = computed(() => {
|
||||
return integrationStore.getItems('mail_provider_details').reduce((metadata, entry: any) => {
|
||||
const providerId = entry.id.split('.').pop() || entry.id
|
||||
metadata[providerId] = entry
|
||||
return metadata
|
||||
}, {} as Record<string, { icon?: string; label?: string }>)
|
||||
})
|
||||
|
||||
const hasAccounts = computed(() => servicesStore.has)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -41,18 +31,44 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function getProviderIcon(providerId: string): string {
|
||||
return providerMetadata.value[providerId]?.icon || 'mdi-email'
|
||||
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 {
|
||||
return providerMetadata.value[providerId]?.label || providerId.toUpperCase()
|
||||
const labels: Record<string, string> = {
|
||||
'smtp': 'SMTP/IMAP',
|
||||
'jmap': 'JMAP',
|
||||
'exchange': 'Microsoft Exchange',
|
||||
}
|
||||
return labels[providerId] || providerId.toUpperCase()
|
||||
}
|
||||
|
||||
function editAccount(account: ServiceObject) {
|
||||
selectedAccount.value = account
|
||||
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
|
||||
@@ -78,7 +94,9 @@ async function testAccount(service: ServiceObject) {
|
||||
try {
|
||||
const result = await servicesStore.test(
|
||||
service.provider,
|
||||
service.identifier
|
||||
service.identifier,
|
||||
service.location,
|
||||
service.identity
|
||||
)
|
||||
testResult.value = result
|
||||
showTestResult.value = true
|
||||
@@ -94,7 +112,6 @@ async function testAccount(service: ServiceObject) {
|
||||
}
|
||||
|
||||
async function handleAccountSaved() {
|
||||
showEditDialog.value = false
|
||||
await servicesStore.list()
|
||||
}
|
||||
</script>
|
||||
@@ -275,12 +292,42 @@ async function handleAccountSaved() {
|
||||
/>
|
||||
|
||||
<!-- Edit Account Dialog -->
|
||||
<EditAccountDialog
|
||||
<v-dialog
|
||||
v-model="showEditDialog"
|
||||
:service-provider="selectedAccount?.provider || ''"
|
||||
:service-identifier="selectedAccount?.identifier || ''"
|
||||
@saved="handleAccountSaved"
|
||||
/>
|
||||
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
|
||||
|
||||
@@ -17,8 +17,6 @@ import type {
|
||||
CollectionDeleteResponse,
|
||||
CollectionDeleteRequest,
|
||||
CollectionInterface,
|
||||
CollectionMoveRequest,
|
||||
CollectionMoveResponse,
|
||||
} from '../types/collection';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { CollectionObject, CollectionPropertiesObject } from '../models/collection';
|
||||
@@ -72,16 +70,9 @@ export const collectionService = {
|
||||
*
|
||||
* @returns Promise with collection object
|
||||
*/
|
||||
async fetch(request: CollectionFetchRequest): Promise<Record<string, CollectionObject>> {
|
||||
async fetch(request: CollectionFetchRequest): Promise<CollectionObject> {
|
||||
const response = await transceivePost<CollectionFetchRequest, CollectionFetchResponse>('collection.fetch', request);
|
||||
|
||||
// Convert response to CollectionObject instances
|
||||
const list: Record<string, CollectionObject> = {};
|
||||
Object.entries(response).forEach(([identifier, entity]) => {
|
||||
list[entity.identifier] = createCollectionObject(entity);
|
||||
});
|
||||
|
||||
return list;
|
||||
return createCollectionObject(response);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -132,27 +123,9 @@ export const collectionService = {
|
||||
*
|
||||
* @returns Promise with deletion result
|
||||
*/
|
||||
async delete(request: CollectionDeleteRequest): Promise<boolean | CollectionObject> {
|
||||
const response = await transceivePost<CollectionDeleteRequest, CollectionDeleteResponse>('collection.delete', request);
|
||||
|
||||
if (response.disposition === 'moved' && response.mutation) {
|
||||
return createCollectionObject(response.mutation);
|
||||
}
|
||||
|
||||
return true;
|
||||
async delete(request: CollectionDeleteRequest): Promise<CollectionDeleteResponse> {
|
||||
return await transceivePost<CollectionDeleteRequest, CollectionDeleteResponse>('collection.delete', request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a collection to a new target collection
|
||||
*
|
||||
* @param request - move request parameters
|
||||
*
|
||||
* @returns Promise with moved collection object
|
||||
*/
|
||||
async move(request: CollectionMoveRequest): Promise<CollectionObject> {
|
||||
const response = await transceivePost<CollectionMoveRequest, CollectionMoveResponse>('collection.move', request);
|
||||
return createCollectionObject(response);
|
||||
}
|
||||
};
|
||||
|
||||
export default collectionService;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* Entity management service
|
||||
*/
|
||||
|
||||
import { transceivePost, transceiveStream, transceiveDownload } from './transceive';
|
||||
import { transceivePost } from './transceive';
|
||||
import type {
|
||||
EntityListRequest,
|
||||
EntityListResponse,
|
||||
EntityFetchRequest,
|
||||
EntityFetchResponse,
|
||||
EntityExtantRequest,
|
||||
@@ -16,18 +18,9 @@ import type {
|
||||
EntityDeleteResponse,
|
||||
EntityDeltaRequest,
|
||||
EntityDeltaResponse,
|
||||
EntityMoveRequest,
|
||||
EntityMoveResponse,
|
||||
EntityTransmitRequest,
|
||||
EntityTransmitResponse,
|
||||
EntityInterface,
|
||||
EntityListStreamResponse,
|
||||
EntityListStreamRequest,
|
||||
EntityListBulkResponse,
|
||||
EntityListBulkRequest,
|
||||
EntityPatchResponse,
|
||||
EntityPatchRequest,
|
||||
EntityDownloadRequest,
|
||||
} from '../types/entity';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { EntityObject } from '../models';
|
||||
@@ -54,8 +47,8 @@ export const entityService = {
|
||||
*
|
||||
* @returns Promise with entity object list grouped by provider, service, collection, and entity identifier
|
||||
*/
|
||||
async listBulk(request: EntityListBulkRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
|
||||
const response = await transceivePost<EntityListBulkRequest, EntityListBulkResponse>('entity.listBulk', request);
|
||||
async list(request: EntityListRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
|
||||
const response = await transceivePost<EntityListRequest, EntityListResponse>('entity.list', request);
|
||||
|
||||
// Convert nested response to EntityObject instances
|
||||
const providerList: Record<string, Record<string, Record<string, Record<string, EntityObject>>>> = {};
|
||||
@@ -78,27 +71,6 @@ export const entityService = {
|
||||
return providerList;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stream entities as NDJSON, invoking onEntity for each entity as it arrives.
|
||||
*
|
||||
* The server emits one entity per line so the caller receives entities
|
||||
* progressively rather than waiting for the full collection to load.
|
||||
*
|
||||
* @param request - stream request parameters (same shape as list)
|
||||
* @param onEntity - called synchronously for each entity as it is received
|
||||
*
|
||||
* @returns Promise resolving to { total } when the stream completes
|
||||
*/
|
||||
async listStream(request: EntityListStreamRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> {
|
||||
return await transceiveStream<EntityListStreamRequest, EntityListStreamResponse>(
|
||||
'entity.listStream',
|
||||
request,
|
||||
(entity) => {
|
||||
onEntity(createEntityObject(entity));
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a specific entity by provider and identifier
|
||||
*
|
||||
@@ -111,8 +83,8 @@ export const entityService = {
|
||||
|
||||
// Convert response to EntityObject instances
|
||||
const list: Record<string, EntityObject> = {};
|
||||
Object.entries(response).forEach(([, entity]) => {
|
||||
list[entity.identifier] = createEntityObject(entity);
|
||||
Object.entries(response).forEach(([identifier, entityData]) => {
|
||||
list[identifier] = createEntityObject(entityData);
|
||||
});
|
||||
|
||||
return list;
|
||||
@@ -154,32 +126,11 @@ export const entityService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Patch existing entities with new properties
|
||||
*
|
||||
* @param request - patch request parameters
|
||||
*
|
||||
* @returns Promise with patch results keyed by target entity identifier
|
||||
*/
|
||||
async patch(request: EntityPatchRequest): Promise<EntityPatchResponse> {
|
||||
const properties = {
|
||||
'@type': request.properties['@type'] ?? 'mail:message',
|
||||
...(Object.prototype.hasOwnProperty.call(request.properties, 'flags')
|
||||
? { flags: request.properties.flags }
|
||||
: {}),
|
||||
}
|
||||
|
||||
return await transceivePost<EntityPatchRequest, EntityPatchResponse>('entity.patch', {
|
||||
...request,
|
||||
properties,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete entities by their identifiers
|
||||
* Delete an entity
|
||||
*
|
||||
* @param request - delete request parameters
|
||||
*
|
||||
* @returns Promise with deletion results keyed by source entity identifier
|
||||
* @returns Promise with deletion result
|
||||
*/
|
||||
async delete(request: EntityDeleteRequest): Promise<EntityDeleteResponse> {
|
||||
return await transceivePost<EntityDeleteRequest, EntityDeleteResponse>('entity.delete', request);
|
||||
@@ -196,17 +147,6 @@ export const entityService = {
|
||||
return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('entity.delta', request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move entities to a target collection
|
||||
*
|
||||
* @param request - move request parameters
|
||||
*
|
||||
* @returns Promise with move results keyed by source entity identifier
|
||||
*/
|
||||
async move(request: EntityMoveRequest): Promise<EntityMoveResponse> {
|
||||
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an entity
|
||||
*
|
||||
@@ -217,16 +157,6 @@ export const entityService = {
|
||||
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
|
||||
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit a browser-native attachment download request.
|
||||
*
|
||||
* The backend download endpoint is expected to honor the supplied selector
|
||||
* and respond with an attachment payload rather than JSON.
|
||||
*/
|
||||
download(request: EntityDownloadRequest): { transaction: string } {
|
||||
return transceiveDownload<EntityDownloadRequest>('entity.download', request);
|
||||
},
|
||||
};
|
||||
|
||||
export default entityService;
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
import type {
|
||||
ServiceListRequest,
|
||||
ServiceListResponse,
|
||||
ServiceFetchRequest,
|
||||
ServiceFetchResponse,
|
||||
ServiceExtantRequest,
|
||||
ServiceExtantResponse,
|
||||
ServiceFetchRequest,
|
||||
ServiceFetchResponse,
|
||||
ServiceDiscoverRequest,
|
||||
ServiceDiscoverResponse,
|
||||
ServiceTestRequest,
|
||||
ServiceTestResponse,
|
||||
ServiceInterface,
|
||||
ServiceCreateResponse,
|
||||
ServiceCreateRequest,
|
||||
ServiceUpdateResponse,
|
||||
ServiceUpdateRequest,
|
||||
ServiceDeleteResponse,
|
||||
ServiceDeleteRequest,
|
||||
ServiceDiscoverRequest,
|
||||
ServiceTestRequest,
|
||||
ServiceTestResponse,
|
||||
ServiceInterface,
|
||||
ServiceDiscoverResponse,
|
||||
} from '../types/service';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { transceivePost, transceiveStream } from './transceive';
|
||||
import { transceivePost } from './transceive';
|
||||
import { ServiceObject } from '../models/service';
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ import { ServiceObject } from '../models/service';
|
||||
*/
|
||||
function createServiceObject(data: ServiceInterface): ServiceObject {
|
||||
const integrationStore = useIntegrationStore();
|
||||
const factoryItem = integrationStore.getItemById('mail_provider_factory_service', data.provider) as any;
|
||||
const factoryItem = integrationStore.getItemById('mail_service_factory', data.provider) as any;
|
||||
const factory = factoryItem?.factory;
|
||||
|
||||
// Use provider factory if available, otherwise base class
|
||||
@@ -87,32 +87,31 @@ export const serviceService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Discover services, streaming results as each provider responds
|
||||
* Retrieve discoverable services for a given source selector, sorted by provider
|
||||
*
|
||||
* @param request - discover request parameters
|
||||
* @param onService - called for each discovered service as it arrives
|
||||
* @param request - discover request parameters
|
||||
*
|
||||
* @returns Promise resolving to { total } when the stream completes
|
||||
* @returns Promise with array of discovered services sorted by provider
|
||||
*/
|
||||
async discover(
|
||||
request: ServiceDiscoverRequest,
|
||||
onService: (service: ServiceObject) => void
|
||||
): Promise<{ total: number }> {
|
||||
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
|
||||
'service.discover',
|
||||
request,
|
||||
(service) => {
|
||||
const serviceData: ServiceInterface = {
|
||||
'@type': 'mail:service',
|
||||
provider: service.provider,
|
||||
identifier: null,
|
||||
label: null,
|
||||
enabled: false,
|
||||
location: service.location,
|
||||
};
|
||||
onService(createServiceObject(serviceData));
|
||||
}
|
||||
);
|
||||
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));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Provides a centralized way to make API calls with envelope wrapping/unwrapping
|
||||
*/
|
||||
|
||||
import { createFetchWrapper } from '@KTXC';
|
||||
import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common';
|
||||
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';
|
||||
@@ -48,177 +48,3 @@ export async function transceivePost<TRequest, TResponse>(
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an NDJSON API response, unwrapping data frames for the caller.
|
||||
*
|
||||
* The server emits one JSON object per line with a transport-level `type`
|
||||
* discriminant. This helper consumes control and error frames, forwards only
|
||||
* unwrapped `data` payloads to the caller, and returns the final stream total.
|
||||
*
|
||||
* @param operation - Operation name, e.g. 'entity.stream'
|
||||
* @param data - Operation-specific request data
|
||||
* @param onData - Synchronous callback invoked for every unwrapped data payload.
|
||||
* May throw to abort the stream.
|
||||
* @param user - Optional user identifier override
|
||||
* @returns Promise resolving to the final stream total from the control/end frame
|
||||
*/
|
||||
export async function transceiveStream<TRequest, TData>(
|
||||
operation: string,
|
||||
data: TRequest,
|
||||
onData: (data: TData) => void,
|
||||
user?: string
|
||||
): Promise<{ total: number }> {
|
||||
const request: ApiRequest<TRequest> = {
|
||||
version: API_VERSION,
|
||||
transaction: generateTransactionId(),
|
||||
operation,
|
||||
data,
|
||||
user,
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
|
||||
await fetchWrapper.post(API_URL, request, {
|
||||
//headers: { 'Accept': 'application/x-ndjson' },
|
||||
headers: { 'Accept': 'application/json' },
|
||||
onStream: async (response: Response) => {
|
||||
if (!response.body) {
|
||||
throw new Error(`[${operation}] Response body is not readable`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop()!; // retain any incomplete trailing chunk
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const message = JSON.parse(line) as ApiStreamResponse<TData>;
|
||||
|
||||
if (message.type === 'control') {
|
||||
if (message.status === 'end') {
|
||||
total = message.total;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
throw new Error(`[${operation}] ${message.message}`);
|
||||
}
|
||||
|
||||
onData(message.data);
|
||||
}
|
||||
}
|
||||
|
||||
// flush any remaining bytes still in the buffer
|
||||
if (buffer.trim()) {
|
||||
const message = JSON.parse(buffer) as ApiStreamResponse<TData>;
|
||||
|
||||
if (message.type === 'control') {
|
||||
if (message.status === 'end') {
|
||||
total = message.total;
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
throw new Error(`[${operation}] ${message.message}`);
|
||||
} else {
|
||||
onData(message.data);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a browser-native file download request via a top-level form POST.
|
||||
*
|
||||
* This avoids buffering the response body in application-managed JavaScript
|
||||
* memory. The backend is expected to accept form fields where `data` contains
|
||||
* the serialized operation payload and to return an attachment response.
|
||||
*/
|
||||
export function transceiveDownload<TRequest>(
|
||||
operation: string,
|
||||
data: TRequest,
|
||||
user?: string,
|
||||
): { transaction: string } {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
throw new Error('Browser window is not available for download submission');
|
||||
}
|
||||
|
||||
const request: ApiRequest<TRequest> = {
|
||||
version: API_VERSION,
|
||||
transaction: generateTransactionId(),
|
||||
operation,
|
||||
data,
|
||||
user,
|
||||
};
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = API_URL;
|
||||
form.target = '_blank';
|
||||
form.style.display = 'none';
|
||||
|
||||
appendHiddenField(form, 'version', String(request.version));
|
||||
appendHiddenField(form, 'transaction', request.transaction);
|
||||
appendHiddenField(form, 'operation', request.operation);
|
||||
appendFormValue(form, 'data', request.data);
|
||||
|
||||
if (request.user) {
|
||||
appendHiddenField(form, 'user', request.user);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
|
||||
return { transaction: request.transaction };
|
||||
}
|
||||
|
||||
function appendHiddenField(form: HTMLFormElement, name: string, value: string): void {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
function appendFormValue(form: HTMLFormElement, name: string, value: unknown): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
appendHiddenField(form, name, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
appendFormValue(form, `${name}[${index}]`, item);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
Object.entries(value as Record<string, unknown>).forEach(([key, nestedValue]) => {
|
||||
appendFormValue(form, `${name}[${key}]`, nestedValue);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
appendHiddenField(form, name, String(value));
|
||||
}
|
||||
|
||||
5
src/shims-vue.d.ts
vendored
5
src/shims-vue.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -4,21 +4,13 @@
|
||||
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
type ServiceIdentifier,
|
||||
type CollectionIdentifier,
|
||||
type ListFilter,
|
||||
type ListSort,
|
||||
collectionService,
|
||||
} from '../services'
|
||||
import { collectionService } from '../services'
|
||||
import { CollectionObject, CollectionPropertiesObject } from '../models/collection'
|
||||
import type { SourceSelector, ListFilter, ListSort } from '../types'
|
||||
|
||||
export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
|
||||
// State
|
||||
const _collections = ref<Record<string, CollectionObject>>({})
|
||||
const _collectionsByServiceIndex = ref<Record<string, string[]>>({})
|
||||
const _collectionsByParentIndex = ref<Record<string, string[]>>({})
|
||||
const transceiving = ref(false)
|
||||
|
||||
/**
|
||||
@@ -41,20 +33,13 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*/
|
||||
const collectionsByService = computed(() => {
|
||||
const groups: Record<string, CollectionObject[]> = {}
|
||||
|
||||
Object.keys(_collectionsByServiceIndex.value).forEach(serviceIndexKey => {
|
||||
const collectionKeys = _collectionsByServiceIndex.value[serviceIndexKey] ?? []
|
||||
const collectionsForKey = collectionKeys
|
||||
.map(collectionKey => _collections.value[collectionKey])
|
||||
.filter((collection): collection is CollectionObject => collection !== undefined)
|
||||
|
||||
if (collectionsForKey.length === 0) {
|
||||
return
|
||||
|
||||
Object.values(_collections.value).forEach((collection) => {
|
||||
const serviceKey = `${collection.provider}:${collection.service}`
|
||||
if (!groups[serviceKey]) {
|
||||
groups[serviceKey] = []
|
||||
}
|
||||
|
||||
const firstCollection = collectionsForKey[0]
|
||||
const serviceKey = `${firstCollection.provider}:${firstCollection.service}`
|
||||
groups[serviceKey] = collectionsForKey
|
||||
groups[serviceKey].push(collection)
|
||||
})
|
||||
|
||||
return groups
|
||||
@@ -70,13 +55,14 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*
|
||||
* @returns Collection object or null
|
||||
*/
|
||||
function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null {
|
||||
if (retrieve === true && !_collections.value[target]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collection "${target}"`)
|
||||
fetch([target])
|
||||
function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null {
|
||||
const key = identifierKey(provider, service, identifier)
|
||||
if (retrieve === true && !_collections.value[key]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collection "${key}"`)
|
||||
fetch(provider, service, identifier)
|
||||
}
|
||||
|
||||
return _collections.value[target] || null
|
||||
return _collections.value[key] || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,89 +75,50 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
* @returns Array of collection objects
|
||||
*/
|
||||
function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] {
|
||||
const serviceIdentifier = `${provider}:${service}` as ServiceIdentifier
|
||||
const serviceCollections = collectionObjectsForKeys(
|
||||
_collectionsByServiceIndex.value[serviceIdentifier] ?? [],
|
||||
)
|
||||
const serviceKeyPrefix = `${provider}:${service}:`
|
||||
const serviceCollections = Object.entries(_collections.value)
|
||||
.filter(([key]) => key.startsWith(serviceKeyPrefix))
|
||||
.map(([_, collection]) => collection)
|
||||
|
||||
if (retrieve === true && serviceCollections.length === 0) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${serviceIdentifier}"`)
|
||||
list([serviceIdentifier])
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`)
|
||||
const sources: SourceSelector = {
|
||||
[provider]: {
|
||||
[String(service)]: true
|
||||
}
|
||||
}
|
||||
list(sources)
|
||||
}
|
||||
|
||||
return serviceCollections
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct child collections for a parent collection, or root collections when parent is null.
|
||||
*
|
||||
* @param provider - provider identifier
|
||||
* @param service - service identifier
|
||||
* @param collectionId - parent collection identifier, or null for root-level collections
|
||||
* @param retrieve - Retrieve behavior: true = fetch service collections if missing, false = cache only
|
||||
*
|
||||
* @returns Array of direct child collection objects
|
||||
*/
|
||||
function collectionsInCollection(provider: string, service: string | number, collection?: CollectionIdentifier | null, retrieve: boolean = false): CollectionObject[] {
|
||||
const collectionIdentifier = collection ?? `${provider}:${service}` as CollectionIdentifier
|
||||
const nestedCollections = collectionObjectsForKeys(
|
||||
_collectionsByParentIndex.value[collectionIdentifier] ?? [],
|
||||
)
|
||||
function collectionsInCollection(provider: string, service: string | number, collectionId: string | number, retrieve: boolean = false): CollectionObject[] {
|
||||
const collectionKeyPrefix = `${provider}:${service}:${collectionId}:`
|
||||
const nestedCollections = Object.entries(_collections.value)
|
||||
.filter(([key]) => key.startsWith(collectionKeyPrefix))
|
||||
.map(([_, collection]) => collection)
|
||||
|
||||
if (retrieve === true && nestedCollections.length === 0) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${collectionIdentifier}"`)
|
||||
list([collectionIdentifier])
|
||||
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`)
|
||||
const sources: SourceSelector = {
|
||||
[provider]: {
|
||||
[String(service)]: {
|
||||
[String(collectionId)]: true
|
||||
}
|
||||
}
|
||||
}
|
||||
list(sources)
|
||||
}
|
||||
|
||||
return nestedCollections
|
||||
}
|
||||
|
||||
function hasChildrenInCollection(provider: string, service: string | number, collection: CollectionIdentifier | null): boolean {
|
||||
const collectionIdentifier = collection ?? `${provider}:${service}` as CollectionIdentifier
|
||||
return (_collectionsByParentIndex.value[collectionIdentifier]?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function collectionObjectsForKeys(collectionKeys: string[]): CollectionObject[] {
|
||||
return collectionKeys
|
||||
.map(collectionKey => _collections.value[collectionKey])
|
||||
.filter((collection): collection is CollectionObject => collection !== undefined)
|
||||
}
|
||||
|
||||
function indexCollection(collection: CollectionObject) {
|
||||
addIndexEntry(_collectionsByServiceIndex.value, String(collection.service), String(collection.identifier))
|
||||
addIndexEntry(_collectionsByParentIndex.value, String(collection.collection ?? collection.service), String(collection.identifier))
|
||||
}
|
||||
|
||||
function deindexCollection(collection: CollectionObject) {
|
||||
removeIndexEntry(_collectionsByServiceIndex.value, String(collection.service), String(collection.identifier))
|
||||
removeIndexEntry(_collectionsByParentIndex.value, String(collection.collection ?? collection.service), String(collection.identifier))
|
||||
}
|
||||
|
||||
function addIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
|
||||
const existing = index[indexKey] ?? []
|
||||
|
||||
if (existing.includes(collectionKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
index[indexKey] = [...existing, collectionKey]
|
||||
}
|
||||
|
||||
function removeIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
|
||||
const existing = index[indexKey]
|
||||
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
|
||||
const filtered = existing.filter(existingKey => existingKey !== collectionKey)
|
||||
|
||||
if (filtered.length === 0) {
|
||||
delete index[indexKey]
|
||||
return
|
||||
}
|
||||
|
||||
index[indexKey] = filtered
|
||||
/**
|
||||
* Create unique key for a collection
|
||||
*/
|
||||
function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string {
|
||||
return `${provider}:${service ?? ''}:${identifier ?? ''}`
|
||||
}
|
||||
|
||||
// Actions
|
||||
@@ -185,7 +132,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*
|
||||
* @returns Promise with collection object list keyed by provider, service, and collection identifier
|
||||
*/
|
||||
async function list(sources?: ServiceIdentifier[] | CollectionIdentifier[], filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
|
||||
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.list({ sources, filter, sort })
|
||||
@@ -195,20 +142,14 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
Object.entries(response).forEach(([_providerId, providerServices]) => {
|
||||
Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => {
|
||||
Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => {
|
||||
if (_collections.value[collectionObj.identifier]) {
|
||||
deindexCollection(_collections.value[collectionObj.identifier])
|
||||
}
|
||||
|
||||
collections[collectionObj.identifier] = collectionObj
|
||||
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
|
||||
collections[key] = collectionObj
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Merge retrieved collections into state
|
||||
_collections.value = { ..._collections.value, ...collections }
|
||||
Object.values(collections).forEach(collectionObj => {
|
||||
indexCollection(collectionObj)
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections')
|
||||
return collections
|
||||
@@ -229,25 +170,19 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*
|
||||
* @returns Promise with collection object
|
||||
*/
|
||||
async function fetch(targets: CollectionIdentifier[]): Promise<Record<string, CollectionObject>> {
|
||||
async function fetch(provider: string, service: string | number, identifier: string | number): Promise<CollectionObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.fetch({ targets })
|
||||
const response = await collectionService.fetch({ provider, service, collection: identifier })
|
||||
|
||||
// Merge fetched collection into state
|
||||
Object.values(response).forEach(collectionObj => {
|
||||
if (_collections.value[collectionObj.identifier]) {
|
||||
deindexCollection(_collections.value[collectionObj.identifier])
|
||||
}
|
||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||
_collections.value[key] = response
|
||||
|
||||
_collections.value[collectionObj.identifier] = collectionObj
|
||||
indexCollection(collectionObj)
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully fetched collections:', Object.keys(response).join(', '))
|
||||
console.debug('[Mail Manager][Store] - Successfully fetched collection:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to fetch collections:', error)
|
||||
console.error('[Mail Manager][Store] - Failed to fetch collection:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
@@ -261,12 +196,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*
|
||||
* @returns Promise with collection availability status
|
||||
*/
|
||||
async function extant(targets: CollectionIdentifier[]): Promise<Record<string, Record<string, Record<string, boolean>>>> {
|
||||
async function extant(sources: SourceSelector) {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.extant({ targets })
|
||||
const response = await collectionService.extant({ sources })
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully checked', targets ? targets.length : 0, 'collections')
|
||||
console.debug('[Mail Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections')
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to check collections:', error)
|
||||
@@ -286,22 +221,21 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
*
|
||||
* @returns Promise with created collection object
|
||||
*/
|
||||
async function create(provider: string, service: string | number, properties: CollectionPropertiesObject, target?: CollectionIdentifier): Promise<CollectionObject> {
|
||||
async function create(provider: string, service: string | number, collection: string | number | null, data: CollectionPropertiesObject): Promise<CollectionObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.create({
|
||||
provider,
|
||||
service,
|
||||
target,
|
||||
properties: properties.toJson()
|
||||
const response = await collectionService.create({
|
||||
provider,
|
||||
service,
|
||||
collection,
|
||||
properties: data
|
||||
})
|
||||
|
||||
if (response instanceof CollectionObject) {
|
||||
_collections.value[response.identifier] = response
|
||||
indexCollection(response)
|
||||
}
|
||||
// Merge created collection into state
|
||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||
_collections.value[key] = response
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully created collection:', response.identifier)
|
||||
console.debug('[Mail Manager][Store] - Successfully created collection:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to create collection:', error)
|
||||
@@ -312,31 +246,30 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing collection with given target and properties
|
||||
* Update an existing collection with given provider, service, identifier, and data
|
||||
*
|
||||
* @param target - collection identifier for the collection to update
|
||||
* @param properties - collection properties for update
|
||||
* @param provider - provider identifier for the collection to update
|
||||
* @param service - service identifier for the collection to update
|
||||
* @param identifier - collection identifier for the collection to update
|
||||
* @param data - collection properties for update
|
||||
*
|
||||
* @returns Promise with updated collection object
|
||||
*/
|
||||
async function update(target: CollectionIdentifier, properties: CollectionPropertiesObject): Promise<CollectionObject> {
|
||||
async function update(provider: string, service: string | number, identifier: string | number, data: CollectionPropertiesObject): Promise<CollectionObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.update({
|
||||
target,
|
||||
properties: properties.toJson()
|
||||
provider,
|
||||
service,
|
||||
identifier,
|
||||
properties: data
|
||||
})
|
||||
|
||||
if (_collections.value[target]) {
|
||||
deindexCollection(_collections.value[target])
|
||||
}
|
||||
|
||||
if (response instanceof CollectionObject) {
|
||||
_collections.value[response.identifier] = response
|
||||
indexCollection(response)
|
||||
}
|
||||
// Merge updated collection into state
|
||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||
_collections.value[key] = response
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully updated collection:', response.identifier)
|
||||
console.debug('[Mail Manager][Store] - Successfully updated collection:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to update collection:', error)
|
||||
@@ -347,39 +280,24 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection by identifier, with optional force delete if collection is not empty.
|
||||
* Delete a collection by provider, service, and identifier
|
||||
*
|
||||
* @param target - collection identifier for the collection to delete
|
||||
* @param force - optional flag to force delete if collection is not empty
|
||||
* @param provider - provider identifier for the collection to delete
|
||||
* @param service - service identifier for the collection to delete
|
||||
* @param identifier - collection identifier for the collection to delete
|
||||
*
|
||||
* @returns Promise with deletion result
|
||||
*/
|
||||
async function remove(target: CollectionIdentifier, force?: boolean): Promise<CollectionObject | boolean> {
|
||||
async function remove(provider: string, service: string | number, identifier: string | number): Promise<any> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.delete({ target, options: { force } })
|
||||
await collectionService.delete({ provider, service, identifier })
|
||||
|
||||
if (response !== true && !(response instanceof CollectionObject)) {
|
||||
console.warn('[Mail Manager][Store] - Delete failed. Received unexpected response from delete operation:', response)
|
||||
return false
|
||||
}
|
||||
// Remove deleted collection from state
|
||||
const key = identifierKey(provider, service, identifier)
|
||||
delete _collections.value[key]
|
||||
|
||||
if (_collections.value[target]) {
|
||||
deindexCollection(_collections.value[target])
|
||||
}
|
||||
|
||||
delete _collections.value[target]
|
||||
|
||||
if (response instanceof CollectionObject) {
|
||||
_collections.value[response.identifier] = response
|
||||
indexCollection(response)
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully moved collection to trash', target, '->', response.identifier)
|
||||
return response
|
||||
}
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully deleted collection:', target)
|
||||
return response
|
||||
console.debug('[Mail Manager][Store] - Successfully deleted collection:', key)
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to delete collection:', error)
|
||||
throw error
|
||||
@@ -388,45 +306,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move collections to another target collection.
|
||||
*
|
||||
* Updates local store keys for successfully moved collections when they are
|
||||
* already present in cache.
|
||||
*
|
||||
* @param target - target collection identifier
|
||||
* @param source - source collection identifier
|
||||
*
|
||||
* @returns Promise with move results keyed by source identifier
|
||||
*/
|
||||
async function move(target: CollectionIdentifier, source: CollectionIdentifier): Promise<CollectionObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await collectionService.move({ target, source })
|
||||
|
||||
if (!(response instanceof CollectionObject)) {
|
||||
console.warn('[Mail Manager][Store] - Move failed. Received unexpected response from move operation:', response)
|
||||
throw new Error('Failed to move collection: unexpected response from move operation')
|
||||
}
|
||||
|
||||
if (_collections.value[source]) {
|
||||
deindexCollection(_collections.value[source])
|
||||
}
|
||||
delete _collections.value[source]
|
||||
|
||||
_collections.value[response.identifier] = response
|
||||
indexCollection(response)
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully moved collection:', source, ' to ', response.identifier)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to move collection:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
// State (readonly)
|
||||
@@ -438,7 +317,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
collectionsByService,
|
||||
collectionsForService,
|
||||
collectionsInCollection,
|
||||
hasChildrenInCollection,
|
||||
// Actions
|
||||
collection,
|
||||
list,
|
||||
@@ -447,6 +325,5 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||
create,
|
||||
update,
|
||||
delete: remove,
|
||||
move,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,21 +5,9 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { entityService } from '../services'
|
||||
import { EntityObject, MessageObject } from '../models'
|
||||
import type {
|
||||
EntityBlobSelector,
|
||||
EntityDownloadRequest,
|
||||
EntityTransmitRequest,
|
||||
EntityTransmitResponse,
|
||||
} from '../types/entity'
|
||||
import type {
|
||||
CollectionIdentifier,
|
||||
EntityIdentifier,
|
||||
ListFilter,
|
||||
ListRange,
|
||||
ListSort,
|
||||
} from '../types/common'
|
||||
import type { MessageInterface, MessagePartInterface } from '@/types/message'
|
||||
import { EntityObject } from '../models'
|
||||
import type { EntityTransmitRequest, EntityTransmitResponse } from '../types/entity'
|
||||
import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common'
|
||||
|
||||
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
// State
|
||||
@@ -52,13 +40,14 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
*
|
||||
* @returns Entity object or null
|
||||
*/
|
||||
function entity(target: EntityIdentifier, retrieve: boolean = false): EntityObject | null {
|
||||
if (retrieve === true && !_entities.value[target]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching entity "${target}"`)
|
||||
fetch([target])
|
||||
function entity(provider: string, service: string | number, collection: string | number, identifier: string | number, retrieve: boolean = false): EntityObject | null {
|
||||
const key = identifierKey(provider, service, collection, identifier)
|
||||
if (retrieve === true && !_entities.value[key]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching entity "${key}"`)
|
||||
fetch(provider, service, collection, [identifier])
|
||||
}
|
||||
|
||||
return _entities.value[target] || null
|
||||
return _entities.value[key] || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +60,34 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
*
|
||||
* @returns Array of entity objects
|
||||
*/
|
||||
function entitiesForCollection(target: CollectionIdentifier, retrieve: boolean = false): EntityObject[] {
|
||||
function entitiesForCollection(provider: string, service: string | number, collection: string | number, retrieve: boolean = false): EntityObject[] {
|
||||
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
|
||||
const collectionEntities = Object.entries(_entities.value)
|
||||
.filter(([key]) => key.startsWith(target))
|
||||
.filter(([key]) => key.startsWith(collectionKeyPrefix))
|
||||
.map(([_, entity]) => entity)
|
||||
|
||||
if (retrieve === true && collectionEntities.length === 0) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${target}"`)
|
||||
list([target])
|
||||
console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`)
|
||||
const sources: SourceSelector = {
|
||||
[provider]: {
|
||||
[String(service)]: {
|
||||
[String(collection)]: true
|
||||
}
|
||||
}
|
||||
}
|
||||
list(sources)
|
||||
}
|
||||
|
||||
return collectionEntities
|
||||
}
|
||||
|
||||
/**
|
||||
* Create unique key for an entity
|
||||
*/
|
||||
function identifierKey(provider: string, service: string | number, collection: string | number, identifier: string | number): string {
|
||||
return `${provider}:${service}:${collection}:${identifier}`
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
/**
|
||||
@@ -96,16 +100,27 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
*
|
||||
* @returns Promise with entity object list keyed by identifier
|
||||
*/
|
||||
async function list(sources: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
|
||||
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const entities: Record<string, EntityObject> = {}
|
||||
const response = await entityService.list({ sources, filter, sort, range })
|
||||
|
||||
await entityService.listStream({ sources, filter, sort, range }, (entity: EntityObject) => {
|
||||
_entities.value[entity.identifier] = entity
|
||||
entities[entity.identifier] = entity
|
||||
// Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object
|
||||
const entities: Record<string, EntityObject> = {}
|
||||
Object.entries(response).forEach(([providerId, providerServices]) => {
|
||||
Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => {
|
||||
Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => {
|
||||
Object.entries(collectionEntities).forEach(([entityId, entityData]) => {
|
||||
const key = identifierKey(providerId, serviceId, collectionId, entityId)
|
||||
entities[key] = entityData
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Merge retrieved entities into state
|
||||
_entities.value = { ..._entities.value, ...entities }
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities')
|
||||
return entities
|
||||
} catch (error: any) {
|
||||
@@ -126,16 +141,17 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
*
|
||||
* @returns Promise with entity objects keyed by identifier
|
||||
*/
|
||||
async function fetch(targets: EntityIdentifier[]): Promise<Record<string, EntityObject>> {
|
||||
async function fetch(provider: string, service: string | number, collection: string | number, identifiers: (string | number)[]): Promise<Record<string, EntityObject>> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await entityService.fetch({ targets })
|
||||
const response = await entityService.fetch({ provider, service, collection, identifiers })
|
||||
|
||||
// Merge fetched entities into state
|
||||
const entities: Record<string, EntityObject> = {}
|
||||
Object.entries(response).forEach(([identifier, entity]) => {
|
||||
entities[identifier] = entity
|
||||
_entities.value[identifier] = entity
|
||||
Object.entries(response).forEach(([identifier, entityData]) => {
|
||||
const key = identifierKey(provider, service, collection, identifier)
|
||||
entities[key] = entityData
|
||||
_entities.value[key] = entityData
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities')
|
||||
@@ -149,16 +165,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve entity availability status for a given set of entity identifiers
|
||||
* Retrieve entity availability status for a given source selector
|
||||
*
|
||||
* @param targets - array of entity identifiers to check availability for
|
||||
* @param sources - source selector to check availability for
|
||||
*
|
||||
* @returns Promise with entity availability status
|
||||
*/
|
||||
async function extant(targets: EntityIdentifier[]) {
|
||||
async function extant(sources: SourceSelector) {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await entityService.extant({ targets })
|
||||
const response = await entityService.extant({ sources })
|
||||
console.debug('[Mail Manager][Store] - Successfully checked entity availability')
|
||||
return response
|
||||
} catch (error: any) {
|
||||
@@ -170,82 +186,25 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve delta changes for entities
|
||||
* Create a new entity with given provider, service, collection, and data
|
||||
*
|
||||
* @param sources - source selector for delta check
|
||||
*
|
||||
* @returns Promise with delta changes (additions, modifications, deletions)
|
||||
*
|
||||
* Note: Delta returns only identifiers, not full entities.
|
||||
* Caller should fetch full entities for additions/modifications separately.
|
||||
*/
|
||||
async function delta(sources: CollectionIdentifier[]) {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await entityService.delta({ sources })
|
||||
|
||||
// Process delta and update store
|
||||
Object.entries(response).forEach(([, providerData]) => {
|
||||
// Skip if no changes for provider
|
||||
if (providerData === false) return
|
||||
|
||||
Object.entries(providerData).forEach(([, serviceData]) => {
|
||||
// Skip if no changes for service
|
||||
if (serviceData === false) return
|
||||
|
||||
Object.entries(serviceData).forEach(([, collectionData]) => {
|
||||
// Skip if no changes for collection
|
||||
if (collectionData === false) return
|
||||
|
||||
// Process deletions (remove from store)
|
||||
if (collectionData.deletions && collectionData.deletions.length > 0) {
|
||||
collectionData.deletions.forEach((identifier) => {
|
||||
delete _entities.value[identifier]
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully processed delta changes')
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to process delta:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty entity object
|
||||
*
|
||||
* @returns New entity object instance
|
||||
*/
|
||||
function fresh(): EntityObject {
|
||||
return new EntityObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new entity with given collection identifier and properties
|
||||
*
|
||||
* @param target - collection identifier for the new entity
|
||||
* @param properties - entity properties for creation
|
||||
* @param provider - provider identifier for the new entity
|
||||
* @param service - service identifier for the new entity
|
||||
* @param collection - collection identifier for the new entity
|
||||
* @param data - entity properties for creation
|
||||
*
|
||||
* @returns Promise with created entity object
|
||||
*/
|
||||
async function create(target: CollectionIdentifier, properties: MessageInterface | MessageObject): Promise<EntityObject> {
|
||||
async function create(provider: string, service: string | number, collection: string | number, data: any): Promise<EntityObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
if (properties instanceof MessageObject) {
|
||||
properties = properties.toJson()
|
||||
}
|
||||
const response = await entityService.create({ target, properties })
|
||||
const response = await entityService.create({ provider, service, collection, properties: data })
|
||||
|
||||
// Add created entity to state
|
||||
_entities.value[response.identifier] = response
|
||||
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
|
||||
_entities.value[key] = response
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully created entity:', response.identifier)
|
||||
console.debug('[Mail Manager][Store] - Successfully created entity:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to create entity:', error)
|
||||
@@ -266,18 +225,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
*
|
||||
* @returns Promise with updated entity object
|
||||
*/
|
||||
async function update(target: EntityIdentifier, properties: MessageInterface | MessageObject): Promise<EntityObject> {
|
||||
async function update(provider: string, service: string | number, collection: string | number, identifier: string | number, data: any): Promise<EntityObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
if (properties instanceof MessageObject) {
|
||||
properties = properties.toJson()
|
||||
}
|
||||
const response = await entityService.update({ target, properties })
|
||||
const response = await entityService.update({ provider, service, collection, identifier, properties: data })
|
||||
|
||||
// Update entity in state
|
||||
_entities.value[response.identifier] = response
|
||||
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
|
||||
_entities.value[key] = response
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully updated entity:', response.identifier)
|
||||
console.debug('[Mail Manager][Store] - Successfully updated entity:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to update entity:', error)
|
||||
@@ -288,108 +245,28 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entities by their identifiers.
|
||||
*
|
||||
* Removes successfully deleted entities from the local store.
|
||||
*
|
||||
* @param targets - entity identifiers to delete
|
||||
*
|
||||
* @returns Promise with deletion results keyed by target identifier
|
||||
* Delete an entity by provider, service, collection, and identifier
|
||||
*
|
||||
* @param provider - provider identifier for the entity to delete
|
||||
* @param service - service identifier for the entity to delete
|
||||
* @param collection - collection identifier for the entity to delete
|
||||
* @param identifier - entity identifier for the entity to delete
|
||||
*
|
||||
* @returns Promise with deletion result
|
||||
*/
|
||||
async function remove(targets: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> {
|
||||
async function remove(provider: string, service: string | number, collection: string | number, identifier: string | number): Promise<any> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await entityService.delete({ targets })
|
||||
const successes: EntityIdentifier[] = []
|
||||
const failures: EntityIdentifier[] = []
|
||||
|
||||
Object.entries(response).forEach(([targetIdentifier, result]) => {
|
||||
const originalIdentifier = targetIdentifier as EntityIdentifier
|
||||
if (!result.disposition || result.disposition === 'error') {
|
||||
console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`)
|
||||
failures.push(originalIdentifier)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.disposition || (result.disposition !== 'moved' && result.disposition !== 'deleted')) {
|
||||
console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`)
|
||||
failures.push(originalIdentifier)
|
||||
return
|
||||
}
|
||||
|
||||
const cachedEntity = _entities.value[originalIdentifier]
|
||||
if (!cachedEntity) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.disposition === 'moved') {
|
||||
const movedEntity = cachedEntity.clone().fromJson({
|
||||
...cachedEntity.toJson(),
|
||||
collection: result.destination,
|
||||
identifier: result.mutation,
|
||||
})
|
||||
_entities.value[result.mutation] = movedEntity
|
||||
}
|
||||
|
||||
delete _entities.value[originalIdentifier]
|
||||
successes.push(originalIdentifier)
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully deleted', successes.length, 'entities')
|
||||
return { successes, failures }
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to delete entities:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch existing entities with new properties
|
||||
*/
|
||||
async function patch(properties: MessageInterface | MessageObject, targets: EntityIdentifier[]) {
|
||||
transceiving.value = true
|
||||
try {
|
||||
if (properties instanceof MessageObject) {
|
||||
properties = properties.toJson()
|
||||
}
|
||||
const response = await entityService.patch({ properties, targets })
|
||||
const successes: EntityIdentifier[] = []
|
||||
const failures: EntityIdentifier[] = []
|
||||
const response = await entityService.delete({ provider, service, collection, identifier })
|
||||
|
||||
Object.entries(response).forEach(([targetIdentifier, result]) => {
|
||||
const originalIdentifier = targetIdentifier as EntityIdentifier
|
||||
if (!result.disposition || result.disposition === 'error') {
|
||||
console.warn(`[Mail Manager][Store] - Entity patch on "${originalIdentifier}" returned an error: ${result.error})`)
|
||||
failures.push(originalIdentifier)
|
||||
return
|
||||
}
|
||||
// Remove entity from state
|
||||
const key = identifierKey(provider, service, collection, identifier)
|
||||
delete _entities.value[key]
|
||||
|
||||
const cachedEntity = _entities.value[originalIdentifier]
|
||||
if (!cachedEntity) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.disposition === 'patched') {
|
||||
const cachedEntityJson = cachedEntity.toJson()
|
||||
const mutatedEntity = cachedEntity.clone().fromJson({
|
||||
...cachedEntityJson,
|
||||
properties: {
|
||||
...cachedEntityJson.properties,
|
||||
...properties,
|
||||
},
|
||||
})
|
||||
_entities.value[originalIdentifier] = mutatedEntity
|
||||
}
|
||||
|
||||
successes.push(originalIdentifier)
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully patched', successes.length, 'entities')
|
||||
return { successes, failures }
|
||||
console.debug('[Mail Manager][Store] - Successfully deleted entity:', key)
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to patch entities:', error)
|
||||
console.error('[Mail Manager][Store] - Failed to delete entity:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
@@ -397,57 +274,51 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move entities to another collection.
|
||||
*
|
||||
* Updates local store keys for successfully moved entities when they are
|
||||
* already present in cache.
|
||||
*
|
||||
* @param target - target collection identifier
|
||||
* @param sources - source entity identifiers
|
||||
*
|
||||
* @returns Promise with move results keyed by source identifier
|
||||
* Retrieve delta changes for entities
|
||||
*
|
||||
* @param sources - source selector for delta check
|
||||
*
|
||||
* @returns Promise with delta changes (additions, modifications, deletions)
|
||||
*
|
||||
* Note: Delta returns only identifiers, not full entities.
|
||||
* Caller should fetch full entities for additions/modifications separately.
|
||||
*/
|
||||
async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> {
|
||||
async function delta(sources: SourceSelector) {
|
||||
transceiving.value = true
|
||||
try {
|
||||
const response = await entityService.move({ target, sources })
|
||||
const successes: EntityIdentifier[] = []
|
||||
const failures: EntityIdentifier[] = []
|
||||
|
||||
Object.entries(response).forEach(([sourceIdentifier, result]) => {
|
||||
const originalIdentifier = sourceIdentifier as EntityIdentifier
|
||||
if (!result.disposition || result.disposition === 'error') {
|
||||
console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`)
|
||||
failures.push(originalIdentifier)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.disposition || result.disposition !== 'moved') {
|
||||
console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`)
|
||||
failures.push(originalIdentifier)
|
||||
return
|
||||
}
|
||||
|
||||
const cachedEntity = _entities.value[originalIdentifier]
|
||||
if (!cachedEntity) {
|
||||
return
|
||||
}
|
||||
|
||||
const movedEntity = cachedEntity.clone().fromJson({
|
||||
...cachedEntity.toJson(),
|
||||
collection: result.destination,
|
||||
identifier: result.mutation,
|
||||
const response = await entityService.delta({ sources })
|
||||
|
||||
// Process delta and update store
|
||||
Object.entries(response).forEach(([provider, providerData]) => {
|
||||
// Skip if no changes for provider
|
||||
if (providerData === false) return
|
||||
|
||||
Object.entries(providerData).forEach(([service, serviceData]) => {
|
||||
// Skip if no changes for service
|
||||
if (serviceData === false) return
|
||||
|
||||
Object.entries(serviceData).forEach(([collection, collectionData]) => {
|
||||
// Skip if no changes for collection
|
||||
if (collectionData === false) return
|
||||
|
||||
// Process deletions (remove from store)
|
||||
if (collectionData.deletions && collectionData.deletions.length > 0) {
|
||||
collectionData.deletions.forEach((identifier) => {
|
||||
const key = identifierKey(provider, service, collection, identifier)
|
||||
delete _entities.value[key]
|
||||
})
|
||||
}
|
||||
|
||||
// Note: additions and modifications contain only identifiers
|
||||
// The caller should fetch full entities using the fetch() method
|
||||
})
|
||||
})
|
||||
_entities.value[result.mutation] = movedEntity
|
||||
|
||||
delete _entities.value[originalIdentifier]
|
||||
successes.push(originalIdentifier)
|
||||
})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully moved', successes.length, 'entities')
|
||||
return { successes, failures }
|
||||
console.debug('[Mail Manager][Store] - Successfully processed delta changes')
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to move entities:', error)
|
||||
console.error('[Mail Manager][Store] - Failed to process delta:', error)
|
||||
throw error
|
||||
} finally {
|
||||
transceiving.value = false
|
||||
@@ -475,39 +346,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function download(target: EntityIdentifier, part?: Partial<MessagePartInterface>) {
|
||||
let targetPart: EntityBlobSelector | undefined = undefined
|
||||
if (part && (part.blobId || part.partId || part.cid)) {
|
||||
targetPart = {
|
||||
blobId: part.blobId ?? undefined,
|
||||
partId: part.partId ?? undefined,
|
||||
cid: part.cid ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let filename: string
|
||||
if (part && part.name && part.name.trim().length > 0) {
|
||||
filename = part.name.trim()
|
||||
} else if (part) {
|
||||
filename = `attachment-${part.partId || part.blobId || part.cid || 'unknown'}`
|
||||
} else {
|
||||
filename = 'message.eml'
|
||||
}
|
||||
|
||||
const request: EntityDownloadRequest = {
|
||||
target,
|
||||
part: targetPart,
|
||||
filename,
|
||||
}
|
||||
|
||||
try {
|
||||
entityService.download(request)
|
||||
} catch (error: any) {
|
||||
console.error('[Mail Manager][Store] - Failed to submit attachment download:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
// State (readonly)
|
||||
@@ -517,18 +355,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||
has,
|
||||
entities,
|
||||
entitiesForCollection,
|
||||
// Actions
|
||||
entity,
|
||||
list,
|
||||
fetch,
|
||||
extant,
|
||||
fresh,
|
||||
create,
|
||||
update,
|
||||
patch,
|
||||
delete: remove,
|
||||
delta,
|
||||
move,
|
||||
transmit,
|
||||
download,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useCollectionsStore } from './collectionsStore';
|
||||
export { useEntitiesStore } from './entitiesStore';
|
||||
export { useProvidersStore } from './providersStore';
|
||||
export { useServicesStore } from './servicesStore';
|
||||
@@ -7,16 +7,11 @@ import { defineStore } from 'pinia'
|
||||
import { serviceService } from '../services'
|
||||
import { ServiceObject } from '../models/service'
|
||||
import type {
|
||||
ServiceIdentifier,
|
||||
ServiceLocation,
|
||||
SourceSelector,
|
||||
ServiceIdentity,
|
||||
ServiceInterface,
|
||||
} from '../types'
|
||||
import {
|
||||
Location,
|
||||
Identity
|
||||
} from '@/models'
|
||||
|
||||
export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
// State
|
||||
@@ -38,11 +33,6 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
*/
|
||||
const services = computed(() => Object.values(_services.value))
|
||||
|
||||
/**
|
||||
* Get all enabled services present in store
|
||||
*/
|
||||
const servicesEnabled = computed(() => services.value.filter(service => service.enabled))
|
||||
|
||||
/**
|
||||
* Get all services present in store grouped by provider
|
||||
*/
|
||||
@@ -69,27 +59,13 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
* @returns Service object or null
|
||||
*/
|
||||
function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null {
|
||||
return serviceByIdentifier(identifierKey(provider, identifier), retrieve)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a service from store by its unique identifier, with optional retrieval
|
||||
*
|
||||
* @param identifier - unique service identifier
|
||||
* @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only
|
||||
* @returns Service object or null
|
||||
*/
|
||||
function serviceByIdentifier(identifier: ServiceIdentifier, retrieve: boolean = false): ServiceObject | null {
|
||||
if (retrieve === true && !_services.value[identifier]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching service "${identifier}"`)
|
||||
const separatorIndex = identifier.indexOf(':')
|
||||
const provider = identifier.slice(0, separatorIndex)
|
||||
const serviceIdentifier = identifier.slice(separatorIndex + 1)
|
||||
|
||||
void fetch(provider, serviceIdentifier)
|
||||
const key = identifierKey(provider, identifier)
|
||||
if (retrieve === true && !_services.value[key]) {
|
||||
console.debug(`[Mail Manager][Store] - Force fetching service "${key}"`)
|
||||
fetch(provider, identifier)
|
||||
}
|
||||
|
||||
return _services.value[identifier] ?? null
|
||||
return _services.value[key] || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,8 +91,8 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
/**
|
||||
* Unique key for a service
|
||||
*/
|
||||
function identifierKey(provider: string, identifier: string | number | null): ServiceIdentifier {
|
||||
return `${provider}:${identifier ?? ''}` as ServiceIdentifier
|
||||
function identifierKey(provider: string, identifier: string | number | null): string {
|
||||
return `${provider}:${identifier ?? ''}`
|
||||
}
|
||||
|
||||
// Actions
|
||||
@@ -236,23 +212,14 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
*
|
||||
* @param provider - provider identifier for the service to update
|
||||
* @param identifier - service identifier for the service to update
|
||||
* @param delta - whether the update is a delta (partial) update or a full replacement
|
||||
* @param data - service data for update
|
||||
* @param data - partial service data for update
|
||||
*
|
||||
* @returns Promise with updated service object
|
||||
*/
|
||||
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>): Promise<ServiceObject> {
|
||||
async function update(provider: string, identifier: string | number, data: Partial<ServiceInterface>): Promise<ServiceObject> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
// convert ServiceObject to JSON if needed
|
||||
let payload: Partial<ServiceInterface>
|
||||
if (data instanceof ServiceObject) {
|
||||
payload = data.toJson(delta)
|
||||
} else {
|
||||
payload = data
|
||||
}
|
||||
|
||||
const service = await serviceService.update({ provider, identifier, delta, data: payload })
|
||||
const service = await serviceService.update({ provider, identifier, data })
|
||||
|
||||
// Merge updated service into state
|
||||
const key = identifierKey(service.provider, service.identifier)
|
||||
@@ -301,29 +268,22 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
* @param secret - optional secret for discovery
|
||||
* @param location - optional location for discovery
|
||||
* @param provider - optional provider identifier for discovery
|
||||
* @param onService - called for each discovered service as it arrives
|
||||
*
|
||||
* @returns Promise resolving to { total } when the stream completes
|
||||
* @returns Promise with list of discovered service objects
|
||||
*/
|
||||
async function discover(
|
||||
identity: string,
|
||||
secret: string | undefined,
|
||||
location: string | undefined,
|
||||
provider: string | undefined,
|
||||
onService?: (service: ServiceObject) => void,
|
||||
): Promise<{ total: number }> {
|
||||
): Promise<ServiceObject[]> {
|
||||
transceiving.value = true
|
||||
|
||||
try {
|
||||
const result = await serviceService.discover(
|
||||
{ identity, secret, location, provider },
|
||||
(service: ServiceObject) => {
|
||||
onService?.(service)
|
||||
}
|
||||
)
|
||||
const services = await serviceService.discover({identity, secret, location, provider})
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
|
||||
return result
|
||||
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
|
||||
@@ -345,30 +305,11 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
async function test(
|
||||
provider: string,
|
||||
identifier?: string | number | null,
|
||||
location?: ServiceLocation | Location | null,
|
||||
identity?: ServiceIdentity | Identity | null,
|
||||
location?: ServiceLocation | null,
|
||||
identity?: ServiceIdentity | null,
|
||||
): Promise<any> {
|
||||
transceiving.value = true
|
||||
try {
|
||||
|
||||
if (provider === undefined || provider === null) {
|
||||
console.error('[Mail Manager][Store] - Provider is required for testing service')
|
||||
throw new Error('Provider is required for testing service')
|
||||
}
|
||||
|
||||
if (identifier === undefined && (location === undefined || location === null) && (identity === undefined || identity === null)) {
|
||||
console.error('[Mail Manager][Store] - Either identifier or location/identity is required for testing service')
|
||||
throw new Error('Either identifier or location/identity is required for testing service')
|
||||
}
|
||||
|
||||
if (location && location instanceof Location) {
|
||||
location = location.toJson()
|
||||
}
|
||||
|
||||
if (identity && identity instanceof Identity) {
|
||||
identity = identity.toJson()
|
||||
}
|
||||
|
||||
const response = await serviceService.test({ provider, identifier, location, identity })
|
||||
|
||||
console.debug('[Mail Manager][Store] - Successfully tested service:', provider, identifier || location)
|
||||
@@ -389,12 +330,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
||||
count,
|
||||
has,
|
||||
services,
|
||||
servicesEnabled,
|
||||
servicesByProvider,
|
||||
|
||||
// Actions
|
||||
service,
|
||||
serviceByIdentifier,
|
||||
serviceForAddress,
|
||||
list,
|
||||
fetch,
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
/**
|
||||
* Collection type definitions
|
||||
*/
|
||||
import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier, SourceSelector } from './common';
|
||||
import type { ListFilter, ListSort, SourceSelector } from './common';
|
||||
|
||||
/**
|
||||
* Collection information
|
||||
*/
|
||||
export interface CollectionInterface<T = CollectionPropertiesInterface> {
|
||||
'@type': string;
|
||||
version: number;
|
||||
export interface CollectionInterface {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: CollectionIdentifier | null;
|
||||
identifier: CollectionIdentifier;
|
||||
collection: string | number | null;
|
||||
identifier: string | number;
|
||||
signature?: string | null;
|
||||
created?: string | null;
|
||||
modified?: string | null;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface CollectionModelInterface extends Omit<CollectionInterface<CollectionPropertiesInterface>, '@type' | 'version' | 'properties'> {
|
||||
properties: CollectionPropertiesModelInterface;
|
||||
properties: CollectionPropertiesInterface;
|
||||
}
|
||||
|
||||
export interface CollectionBaseProperties {
|
||||
'@type': string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CollectionImmutableProperties extends CollectionBaseProperties {
|
||||
@@ -41,13 +36,11 @@ export interface CollectionMutableProperties extends CollectionBaseProperties {
|
||||
|
||||
export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {}
|
||||
|
||||
export interface CollectionPropertiesModelInterface extends Omit<CollectionPropertiesInterface, '@type'> {}
|
||||
|
||||
/**
|
||||
* Collection list
|
||||
*/
|
||||
export interface CollectionListRequest {
|
||||
sources?: ServiceIdentifier[] | CollectionIdentifier[];
|
||||
sources?: SourceSelector;
|
||||
filter?: ListFilter;
|
||||
sort?: ListSort;
|
||||
}
|
||||
@@ -64,18 +57,18 @@ export interface CollectionListResponse {
|
||||
* Collection fetch
|
||||
*/
|
||||
export interface CollectionFetchRequest {
|
||||
targets: CollectionIdentifier[];
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: string | number;
|
||||
}
|
||||
|
||||
export interface CollectionFetchResponse {
|
||||
[identifier: CollectionIdentifier]: CollectionInterface;
|
||||
}
|
||||
export interface CollectionFetchResponse extends CollectionInterface {}
|
||||
|
||||
/**
|
||||
* Collection extant
|
||||
*/
|
||||
export interface CollectionExtantRequest {
|
||||
targets: CollectionIdentifier[];
|
||||
sources: SourceSelector;
|
||||
}
|
||||
|
||||
export interface CollectionExtantResponse {
|
||||
@@ -92,7 +85,7 @@ export interface CollectionExtantResponse {
|
||||
export interface CollectionCreateRequest {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
target?: CollectionIdentifier; // Optional parent target for the new collection
|
||||
collection?: string | number | null; // Parent Collection Identifier
|
||||
properties: CollectionMutableProperties;
|
||||
}
|
||||
|
||||
@@ -102,7 +95,9 @@ export interface CollectionCreateResponse extends CollectionInterface {}
|
||||
* Collection modify
|
||||
*/
|
||||
export interface CollectionUpdateRequest {
|
||||
target: CollectionIdentifier;
|
||||
provider: string;
|
||||
service: string | number;
|
||||
identifier: string | number;
|
||||
properties: CollectionMutableProperties;
|
||||
}
|
||||
|
||||
@@ -112,23 +107,15 @@ export interface CollectionUpdateResponse extends CollectionInterface {}
|
||||
* Collection delete
|
||||
*/
|
||||
export interface CollectionDeleteRequest {
|
||||
target: CollectionIdentifier;
|
||||
provider: string;
|
||||
service: string | number;
|
||||
identifier: string | number;
|
||||
options?: {
|
||||
force?: boolean; // Whether to force delete even if collection is not empty
|
||||
recursive?: boolean; // Whether to delete child collections/items as well
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectionDeleteResponse {
|
||||
disposition: 'deleted' | 'moved';
|
||||
mutation?: CollectionInterface | null; // If moved, the new location of the collection
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection move
|
||||
*/
|
||||
export interface CollectionMoveRequest {
|
||||
target: CollectionIdentifier;
|
||||
source: CollectionIdentifier;
|
||||
}
|
||||
|
||||
export interface CollectionMoveResponse extends CollectionInterface {};
|
||||
|
||||
@@ -43,47 +43,6 @@ export interface ApiErrorResponse {
|
||||
*/
|
||||
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Stream control start line
|
||||
*/
|
||||
export interface ApiStreamStartResponse {
|
||||
type: 'control';
|
||||
status: 'start';
|
||||
version: number;
|
||||
transaction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream control end line
|
||||
*/
|
||||
export interface ApiStreamEndResponse {
|
||||
type: 'control';
|
||||
status: 'end';
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream error line
|
||||
*/
|
||||
export interface ApiStreamErrorResponse {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiStreamDataResponse<T = any> {
|
||||
type: 'data';
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared stream control lines
|
||||
*/
|
||||
export type ApiStreamResponse<T = any> =
|
||||
| ApiStreamStartResponse
|
||||
| ApiStreamEndResponse
|
||||
| ApiStreamErrorResponse
|
||||
| ApiStreamDataResponse<T>;
|
||||
|
||||
/**
|
||||
* Selector for targeting specific providers, services, collections, or entities in list or extant operations.
|
||||
*
|
||||
@@ -113,10 +72,6 @@ export type CollectionSelector = {
|
||||
|
||||
export type EntitySelector = (string | number)[];
|
||||
|
||||
export type ProviderIdentifier = `${string}`;
|
||||
export type ServiceIdentifier = `${string}:${string}`;
|
||||
export type CollectionIdentifier = `${string}:${string}:${string | number}`;
|
||||
export type EntityIdentifier = `${string}:${string}:${string}:${string | number}`;
|
||||
|
||||
/**
|
||||
* Filter comparison for list operations
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
/**
|
||||
* Entity type definitions
|
||||
*/
|
||||
import type {
|
||||
CollectionIdentifier,
|
||||
EntityIdentifier,
|
||||
ListFilter,
|
||||
ListRange,
|
||||
ListSort,
|
||||
} from './common';
|
||||
import type { MessageInterface, MessageModelInterface } from './message';
|
||||
import type { SourceSelector, ListFilter, ListSort, ListRange } from './common';
|
||||
import type { MessageInterface } from './message';
|
||||
|
||||
/**
|
||||
* Entity definition
|
||||
*/
|
||||
export interface EntityInterface<T = MessageInterface> {
|
||||
'@type': string;
|
||||
version: number;
|
||||
provider: string;
|
||||
service: string;
|
||||
collection: CollectionIdentifier;
|
||||
identifier: EntityIdentifier;
|
||||
collection: string | number;
|
||||
identifier: string | number;
|
||||
signature: string | null;
|
||||
created: string | null;
|
||||
modified: string | null;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface EntityModelInterface extends Omit<EntityInterface<MessageModelInterface>, '@type' | 'version'> {}
|
||||
|
||||
/**
|
||||
* Entity list bulk
|
||||
* Entity list
|
||||
*/
|
||||
export interface EntityListBulkRequest {
|
||||
sources?: CollectionIdentifier[];
|
||||
export interface EntityListRequest {
|
||||
sources?: SourceSelector;
|
||||
filter?: ListFilter;
|
||||
sort?: ListSort;
|
||||
range?: ListRange;
|
||||
}
|
||||
|
||||
export interface EntityListBulkResponse {
|
||||
export interface EntityListResponse {
|
||||
[providerId: string]: {
|
||||
[serviceId: string]: {
|
||||
[collectionId: string]: {
|
||||
@@ -48,23 +38,14 @@ export interface EntityListBulkResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity list stream
|
||||
*/
|
||||
export interface EntityListStreamRequest {
|
||||
sources?: CollectionIdentifier[];
|
||||
filter?: ListFilter;
|
||||
sort?: ListSort;
|
||||
range?: ListRange;
|
||||
}
|
||||
|
||||
export interface EntityListStreamResponse extends EntityInterface<MessageInterface> {}
|
||||
|
||||
/**
|
||||
* Entity fetch
|
||||
*/
|
||||
export interface EntityFetchRequest {
|
||||
targets: EntityIdentifier[];
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: string | number;
|
||||
identifiers: (string | number)[];
|
||||
}
|
||||
|
||||
export interface EntityFetchResponse {
|
||||
@@ -75,7 +56,7 @@ export interface EntityFetchResponse {
|
||||
* Entity extant
|
||||
*/
|
||||
export interface EntityExtantRequest {
|
||||
targets: EntityIdentifier[];
|
||||
sources: SourceSelector;
|
||||
}
|
||||
|
||||
export interface EntityExtantResponse {
|
||||
@@ -88,11 +69,50 @@ export interface EntityExtantResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity create
|
||||
*/
|
||||
export interface EntityCreateRequest<T = MessageInterface> {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: string | number;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface EntityCreateResponse<T = MessageInterface> extends EntityInterface<T> {}
|
||||
|
||||
/**
|
||||
* Entity update
|
||||
*/
|
||||
export interface EntityUpdateRequest<T = MessageInterface> {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: string | number;
|
||||
identifier: string | number;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface EntityUpdateResponse<T = MessageInterface> extends EntityInterface<T> {}
|
||||
|
||||
/**
|
||||
* Entity delete
|
||||
*/
|
||||
export interface EntityDeleteRequest {
|
||||
provider: string;
|
||||
service: string | number;
|
||||
collection: string | number;
|
||||
identifier: string | number;
|
||||
}
|
||||
|
||||
export interface EntityDeleteResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity delta
|
||||
*/
|
||||
export interface EntityDeltaRequest {
|
||||
sources: CollectionIdentifier[];
|
||||
sources: SourceSelector;
|
||||
}
|
||||
|
||||
export interface EntityDeltaResponse {
|
||||
@@ -108,74 +128,6 @@ export interface EntityDeltaResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity create
|
||||
*/
|
||||
export interface EntityCreateRequest<T = MessageInterface> {
|
||||
target: CollectionIdentifier;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface EntityCreateResponse<T = MessageInterface> extends EntityInterface<T> {}
|
||||
|
||||
/**
|
||||
* Entity update
|
||||
*/
|
||||
export interface EntityUpdateRequest<T = MessageInterface> {
|
||||
target: EntityIdentifier;
|
||||
properties: T;
|
||||
}
|
||||
|
||||
export interface EntityUpdateResponse<T = MessageInterface> extends EntityInterface<T> {}
|
||||
|
||||
/**
|
||||
* Entity patch
|
||||
*/
|
||||
export interface EntityPatchRequest<T = MessageInterface> {
|
||||
properties: T;
|
||||
targets: EntityIdentifier[];
|
||||
}
|
||||
|
||||
export interface EntityPatchResponse{
|
||||
[targetIdentifier: EntityIdentifier]: {
|
||||
disposition: 'patched' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity delete
|
||||
*/
|
||||
export interface EntityDeleteRequest {
|
||||
targets: EntityIdentifier[];
|
||||
}
|
||||
|
||||
export interface EntityDeleteResponse {
|
||||
[targetIdentifier: EntityIdentifier]: {
|
||||
disposition: 'deleted' | 'moved' | 'error';
|
||||
destination: CollectionIdentifier | null;
|
||||
mutation: EntityIdentifier | null;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity move
|
||||
*/
|
||||
export interface EntityMoveRequest {
|
||||
target: CollectionIdentifier;
|
||||
sources: EntityIdentifier[];
|
||||
}
|
||||
|
||||
export interface EntityMoveResponse {
|
||||
[sourceIdentifier: EntityIdentifier]: {
|
||||
disposition: 'moved' | 'error';
|
||||
destination: CollectionIdentifier| null;
|
||||
mutation: EntityIdentifier | null;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity transmit
|
||||
*/
|
||||
@@ -205,32 +157,4 @@ export interface EntityTransmitRequest {
|
||||
export interface EntityTransmitResponse {
|
||||
id: string;
|
||||
status: 'queued' | 'sent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity Blob Fetch
|
||||
*/
|
||||
export interface EntityBlobSelector {
|
||||
blobId?: string;
|
||||
partId?: string;
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
export interface EntityDownloadRequest {
|
||||
target: EntityIdentifier;
|
||||
part?: EntityBlobSelector;
|
||||
filename?: string | null;
|
||||
}
|
||||
|
||||
export interface EntityBlobsRequest {
|
||||
target: EntityIdentifier;
|
||||
parts: EntityBlobSelector[];
|
||||
}
|
||||
|
||||
export interface EntityBlobsResult {
|
||||
source: EntityIdentifier;
|
||||
part: EntityBlobSelector;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export interface EntityBlobsResponse extends Array<EntityBlobsResult> {}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Central export point for all Mail Manager types
|
||||
*/
|
||||
|
||||
export type * from './collection';
|
||||
export type * from './common';
|
||||
export type * from './entity';
|
||||
|
||||
@@ -6,23 +6,25 @@
|
||||
// ==================== Provider Panel Contracts ====================
|
||||
|
||||
/**
|
||||
* Props all provider Protocol panels receive
|
||||
* Protocol panels handle protocol/location settings only
|
||||
* Props all provider CONFIG panels receive
|
||||
* Config panels handle protocol/location settings only
|
||||
*/
|
||||
export interface ProviderProtocolPanelProps {
|
||||
/** Current service value for v-model binding */
|
||||
service?: import('../models').ServiceObject;
|
||||
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 Protocol panels emit
|
||||
* Protocol panels emit service configuration and validation state
|
||||
* Events all provider CONFIG panels emit
|
||||
* Config panels emit location configuration and validation state
|
||||
*/
|
||||
export interface ProviderProtocolPanelEmits {
|
||||
/** Emit updated service configuration */
|
||||
'update:service': [value: import('../models').ServiceObject];
|
||||
export interface ProviderConfigPanelEmits {
|
||||
/** Emit updated location configuration */
|
||||
'update:modelValue': [value: import('./service').ServiceLocation];
|
||||
/** Emit validation state (true = valid, false = invalid) */
|
||||
'valid': [value: boolean];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +32,6 @@ export interface ProviderProtocolPanelEmits {
|
||||
* Auth panels handle credentials/authentication only
|
||||
*/
|
||||
export interface ProviderAuthPanelProps {
|
||||
/** Current service value for v-model binding */
|
||||
service?: import('../models').ServiceObject;
|
||||
/** Email address from discovery entry (for pre-filling username) */
|
||||
emailAddress?: string;
|
||||
/** Discovered or configured location (for context/auth decisions) */
|
||||
@@ -40,6 +40,8 @@ export interface ProviderAuthPanelProps {
|
||||
prefilledIdentity?: string;
|
||||
/** Pre-filled secret/password if user entered during discovery */
|
||||
prefilledSecret?: string;
|
||||
/** Current identity value for v-model binding */
|
||||
modelValue?: import('./service').ServiceIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,10 @@ export interface ProviderAuthPanelProps {
|
||||
* Auth panels emit identity configuration, validation state, and errors
|
||||
*/
|
||||
export interface ProviderAuthPanelEmits {
|
||||
/** Emit updated service configuration */
|
||||
'update:service': [value: import('../models').ServiceObject];
|
||||
/** 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];
|
||||
}
|
||||
|
||||
@@ -1,66 +1,68 @@
|
||||
/**
|
||||
* Message object interface
|
||||
*/
|
||||
export interface MessageModelInterface extends Omit<{
|
||||
[K in keyof MessageInterface]-?: Exclude<MessageInterface[K], undefined>;
|
||||
}, '@type' | 'version' | 'body'> {
|
||||
body: MessagePartModelInterface | null;
|
||||
attachments: Array<MessagePartModelInterface>;
|
||||
}
|
||||
|
||||
export interface MessageInterface {
|
||||
'@type': string;
|
||||
size?: number | null;
|
||||
headers?: Record<string, string> | null;
|
||||
urid?: string | null;
|
||||
inReplyTo?: string | null;
|
||||
references?: string | null;
|
||||
received?: string | null;
|
||||
sent?: string | null;
|
||||
sender?: MessageAddressInterface | null;
|
||||
from?: MessageAddressInterface | null;
|
||||
replyTo?: Array<MessageAddressInterface> | null;
|
||||
to?: Array<MessageAddressInterface> | null;
|
||||
cc?: Array<MessageAddressInterface> | null;
|
||||
bcc?: Array<MessageAddressInterface> | null;
|
||||
subject?: string | null;
|
||||
body?: MessagePartInterface | null;
|
||||
attachments?: Array<MessagePartInterface> | [];
|
||||
flags?: MessageFlagsInterface | null;
|
||||
}
|
||||
|
||||
export interface MessageAddressInterface {
|
||||
address: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface MessageFlagsInterface {
|
||||
read?: boolean;
|
||||
flagged?: boolean;
|
||||
answered?: boolean;
|
||||
draft?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message Part Interface
|
||||
*/
|
||||
export interface MessagePartModelInterface extends Omit<{
|
||||
[K in keyof MessagePartInterface]-?: Exclude<MessagePartInterface[K], undefined>;
|
||||
}, 'subParts'> {
|
||||
subParts: MessagePartModelInterface[];
|
||||
}
|
||||
|
||||
export interface MessagePartInterface {
|
||||
partId?: string | null;
|
||||
blobId?: string | null;
|
||||
size?: number | null;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
type?: string;
|
||||
charset?: string | null;
|
||||
disposition?: string | null;
|
||||
cid?: string | null;
|
||||
language?: string | null;
|
||||
location?: string | null;
|
||||
content?: 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;
|
||||
}>;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ export interface ProviderCapabilitiesInterface {
|
||||
ServiceFetch?: boolean;
|
||||
ServiceExtant?: boolean;
|
||||
ServiceCreate?: boolean;
|
||||
ServiceUpdate?: boolean;
|
||||
ServiceDelete?: boolean;
|
||||
ServiceModify?: boolean;
|
||||
ServiceDestroy?: boolean;
|
||||
ServiceDiscover?: boolean;
|
||||
ServiceTest?: boolean;
|
||||
[key: string]: boolean | object | string[] | undefined;
|
||||
@@ -23,14 +23,11 @@ export interface ProviderCapabilitiesInterface {
|
||||
*/
|
||||
export interface ProviderInterface {
|
||||
'@type': string;
|
||||
version: number;
|
||||
identifier: string;
|
||||
label: string;
|
||||
capabilities: ProviderCapabilitiesInterface;
|
||||
}
|
||||
|
||||
export interface ProviderModelInterface extends Omit<ProviderInterface, '@type' | 'version'> {}
|
||||
|
||||
/**
|
||||
* Provider list
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Service type definitions
|
||||
*/
|
||||
import type { Identity } from '@/models/identity';
|
||||
import type { Location } from '@/models/location';
|
||||
import type {
|
||||
ListFilterComparisonOperator,
|
||||
SourceSelector,
|
||||
} from './common';
|
||||
import type { SourceSelector, ListFilterComparisonOperator } from './common';
|
||||
|
||||
/**
|
||||
* Service capabilities
|
||||
@@ -21,7 +16,6 @@ export interface ServiceCapabilitiesInterface {
|
||||
CollectionCreate?: boolean;
|
||||
CollectionUpdate?: boolean;
|
||||
CollectionDelete?: boolean;
|
||||
CollectionMove?: boolean;
|
||||
// Message capabilities
|
||||
EntityList?: boolean;
|
||||
EntityListFilter?: ServiceListFilterEntity;
|
||||
@@ -45,7 +39,6 @@ export interface ServiceCapabilitiesInterface {
|
||||
*/
|
||||
export interface ServiceInterface {
|
||||
'@type': string;
|
||||
version: number;
|
||||
provider: string;
|
||||
identifier: string | number | null;
|
||||
label: string | null;
|
||||
@@ -58,13 +51,6 @@ export interface ServiceInterface {
|
||||
auxiliary?: Record<string, any>; // Provider-specific extension data
|
||||
}
|
||||
|
||||
export interface ServiceModelInterface extends Omit<{
|
||||
[K in keyof ServiceInterface]-?: Exclude<ServiceInterface[K], undefined>;
|
||||
}, '@type' | 'version' | 'location' | 'identity'> {
|
||||
location: Location | null;
|
||||
identity: Identity | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service list
|
||||
*/
|
||||
@@ -117,7 +103,6 @@ export interface ServiceCreateResponse extends ServiceInterface {}
|
||||
export interface ServiceUpdateRequest {
|
||||
provider: string;
|
||||
identifier: string | number;
|
||||
delta?: boolean; // If true, 'data' contains only fields to update (partial update). If false or omitted, 'data' is a full replacement.
|
||||
data: Partial<ServiceInterface>;
|
||||
}
|
||||
|
||||
@@ -144,20 +129,7 @@ export interface ServiceDiscoverRequest {
|
||||
}
|
||||
|
||||
export interface ServiceDiscoverResponse {
|
||||
provider: string;
|
||||
location: ServiceLocation;
|
||||
}
|
||||
|
||||
export interface ProviderDiscoveryStatus {
|
||||
provider: string;
|
||||
status: 'pending' | 'discovering' | 'success' | 'failed';
|
||||
location?: ServiceLocation;
|
||||
metadata?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: string;
|
||||
};
|
||||
error?: string;
|
||||
[provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
30
tests/js/unit/base.test.ts
Normal file
30
tests/js/unit/base.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Basic Tests', () => {
|
||||
it('should perform basic assertion', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should test array operations', () => {
|
||||
const array = ['foo', 'bar', 'baz']
|
||||
|
||||
expect(array).toHaveLength(3)
|
||||
expect(array).toContain('bar')
|
||||
expect(array[0]).toBe('foo')
|
||||
})
|
||||
|
||||
it('should test string operations', () => {
|
||||
const string = 'Hello, World!'
|
||||
|
||||
expect(string).toContain('World')
|
||||
expect(string.length).toBe(13)
|
||||
})
|
||||
|
||||
it('should test object operations', () => {
|
||||
const obj = { foo: 'bar', count: 42 }
|
||||
|
||||
expect(obj).toHaveProperty('foo')
|
||||
expect(obj.foo).toBe('bar')
|
||||
expect(obj.count).toBeGreaterThan(40)
|
||||
})
|
||||
})
|
||||
33
tests/js/vitest.config.ts
Normal file
33
tests/js/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, configDefaults } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vuetify from 'vite-plugin-vuetify'
|
||||
import path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vuetify()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@KTXC': path.resolve(__dirname, '../../../core/src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/dist/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -32,6 +32,10 @@
|
||||
<directory>../../core/lib</directory>
|
||||
<directory>../../shared/lib</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"@MailManager/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
|
||||
@@ -41,16 +41,13 @@ export default defineConfig({
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'pinia',
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'@KTXC',
|
||||
// Externalize shared utilities from core to avoid duplication
|
||||
/^@KTXC\/utils\//,
|
||||
],
|
||||
output: {
|
||||
paths: (id) => {
|
||||
if (id === '@KTXC') return '/js/ktxc.mjs'
|
||||
return id
|
||||
},
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name?.endsWith('.css')) {
|
||||
return 'mail_manager-[hash].css'
|
||||
|
||||
Reference in New Issue
Block a user