65 Commits

Author SHA1 Message Date
27ce930a12 chore(deps): update dependency vite to v8.0.16
Some checks failed
Build Test / test (pull_request) Successful in 41s
JS Unit Tests / test (pull_request) Failing after 42s
PHP Unit Tests / test (pull_request) Successful in 1m22s
2026-06-07 03:04:27 +00:00
0e4bd905a2 Merge pull request 'feat: mail entity download' (#37) from feat/mail-entity-download into main
Some checks failed
Renovate / renovate (push) Failing after 1m50s
Reviewed-on: #37
2026-05-29 03:22:55 +00:00
935743963f feat: mail entity download
Some checks failed
Build Test / test (pull_request) Successful in 32s
JS Unit Tests / test (pull_request) Failing after 31s
PHP Unit Tests / test (pull_request) Successful in 1m1s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-28 23:22:02 -04:00
502b18962c Merge pull request 'fix(deps): update dependency vue-router to v5' (#32) from renovate/vue-router-5.x into main
Some checks failed
Renovate / renovate (push) Failing after 1m38s
Reviewed-on: #32
2026-05-21 03:45:36 +00:00
138b5adcac fix(deps): update dependency vue-router to v5
Some checks failed
JS Unit Tests / test (pull_request) Failing after 36s
Build Test / test (pull_request) Successful in 40s
PHP Unit Tests / test (pull_request) Successful in 1m6s
2026-05-21 03:45:14 +00:00
e2192be442 Merge pull request 'fix(deps): update dependency pinia to v3' (#31) from renovate/pinia-3.x into main
Reviewed-on: #31
2026-05-21 03:43:24 +00:00
e86de50f2e Merge pull request 'chore(deps): update dependency phpunit/phpunit to v11.5.55' (#26) from renovate/phpunit-phpunit-11.x-lockfile into main
Reviewed-on: #26
2026-05-21 03:42:44 +00:00
4b0ffa95cb fix(deps): update dependency pinia to v3
Some checks failed
Build Test / test (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Failing after 27s
PHP Unit Tests / test (pull_request) Successful in 1m20s
2026-05-21 03:42:33 +00:00
bc389ed4c0 chore(deps): update dependency phpunit/phpunit to v11.5.55
Some checks failed
JS Unit Tests / test (pull_request) Failing after 31s
Build Test / test (pull_request) Successful in 33s
PHP Unit Tests / test (pull_request) Successful in 1m50s
2026-05-21 03:42:28 +00:00
da696cd104 Merge pull request 'chore(deps): update dependency typescript to v6' (#29) from renovate/typescript-6.x into main
Reviewed-on: #29
2026-05-21 03:39:09 +00:00
111decce15 Merge pull request 'chore(deps): update vitest monorepo to v4.1.7' (#36) from renovate/vitest-monorepo into main
Reviewed-on: #36
2026-05-21 03:38:54 +00:00
adabcd7c9a Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.1' (#35) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #35
2026-05-21 03:38:44 +00:00
751ec564ae chore(deps): update dependency typescript to v6
Some checks failed
JS Unit Tests / test (pull_request) Failing after 38s
Build Test / test (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 1m23s
2026-05-21 03:38:43 +00:00
9b46bb81a1 chore(deps): update vitest monorepo to v4.1.7
Some checks failed
Build Test / test (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Failing after 30s
PHP Unit Tests / test (pull_request) Successful in 1m25s
2026-05-21 03:38:40 +00:00
2e14440b95 chore(deps): update dependency vue-tsc to v3.3.1
Some checks failed
Build Test / test (pull_request) Successful in 42s
JS Unit Tests / test (pull_request) Failing after 43s
PHP Unit Tests / test (pull_request) Successful in 1m27s
2026-05-21 03:38:35 +00:00
aaec58c7e6 Merge pull request 'fix(deps): update dependency vuetify to v4' (#33) from renovate/vuetify-4.x into main
Reviewed-on: #33
2026-05-21 03:35:22 +00:00
f56cc681a6 Merge pull request 'chore(deps): update dependency @vue/tsconfig to ^0.9.0' (#28) from renovate/vue-tsconfig-0.x into main
Reviewed-on: #28
2026-05-21 03:34:54 +00:00
ef94d6dede Merge pull request 'chore(deps): update dependency vite to v8' (#30) from renovate/vite-8.x into main
Reviewed-on: #30
2026-05-21 03:34:46 +00:00
b4e379bccd fix(deps): update dependency vuetify to v4
Some checks failed
JS Unit Tests / test (pull_request) Failing after 34s
Build Test / test (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m7s
2026-05-21 01:44:09 +00:00
b67d1498e3 chore(deps): update dependency vite to v8
Some checks failed
JS Unit Tests / test (pull_request) Failing after 44s
Build Test / test (pull_request) Successful in 48s
PHP Unit Tests / test (pull_request) Successful in 1m37s
2026-05-21 01:44:00 +00:00
3567fa6f20 chore(deps): update dependency @vue/tsconfig to ^0.9.0
Some checks failed
JS Unit Tests / test (pull_request) Failing after 47s
Build Test / test (pull_request) Successful in 50s
PHP Unit Tests / test (pull_request) Successful in 2m25s
2026-05-21 01:43:49 +00:00
efb7b799d0 Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#25) from renovate/vitejs-plugin-vue-6.x-lockfile into main
All checks were successful
Renovate / renovate (push) Successful in 1m37s
Reviewed-on: #25
2026-05-21 01:41:26 +00:00
7302df283c chore(deps): update dependency @vitejs/plugin-vue to v6.0.7
Some checks failed
Build Test / test (pull_request) Successful in 36s
JS Unit Tests / test (pull_request) Failing after 43s
PHP Unit Tests / test (pull_request) Successful in 1m45s
2026-05-21 01:39:52 +00:00
9d67c0bc56 Merge pull request 'feat: implement entity patch' (#24) from feat/implement-patch into main
Reviewed-on: #24
2026-05-21 01:37:40 +00:00
453d720046 feat: implement entity patch
Some checks failed
Build Test / test (pull_request) Successful in 35s
JS Unit Tests / test (pull_request) Failing after 34s
PHP Unit Tests / test (pull_request) Successful in 1m9s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-20 21:37:14 -04:00
aff17840ed Merge pull request 'refactor: list and fetch' (#23) from refactor/clean-up-list-and-fetch into main
All checks were successful
Renovate / renovate (push) Successful in 3m14s
Reviewed-on: #23
2026-05-17 21:55:45 +00:00
71f523e1f9 refactor: list and fetch
Some checks failed
JS Unit Tests / test (pull_request) Failing after 29s
Build Test / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Successful in 51s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-17 17:55:28 -04:00
ec9d778a44 Merge pull request 'fix: testing fixes' (#22) from fix/testing-fixes into main
Some checks failed
Renovate / renovate (push) Failing after 1m35s
Reviewed-on: #22
2026-05-16 04:10:39 +00:00
7afee695c6 fix: testing fixes
Some checks failed
Build Test / test (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Failing after 27s
PHP Unit Tests / test (pull_request) Successful in 54s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-16 00:10:02 -04:00
e8612c4e10 Merge pull request 'fix: clean up manager logic' (#21) from fix/cleanup-manager-logic into main
Some checks failed
Renovate / renovate (push) Failing after 1m52s
Reviewed-on: #21
2026-05-15 23:07:29 +00:00
72c2972777 fix: clean up manager logic
Some checks failed
Build Test / test (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Failing after 29s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-15 19:06:06 -04:00
493dd1c015 Merge pull request 'refactor: use resource identifiers' (#20) from refactor/use-resource-identifiers into main
Some checks failed
Renovate / renovate (push) Failing after 2m54s
Reviewed-on: #20
2026-05-15 02:34:52 +00:00
c7ef2c5495 refactor: use resource identifiers
Some checks failed
Build Test / test (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Failing after 29s
PHP Unit Tests / test (pull_request) Successful in 56s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-14 22:34:18 -04:00
69d4b2f42c Merge pull request 'feat: entity patch' (#19) from feat/implement-entity-patch into main
Some checks failed
Renovate / renovate (push) Failing after 2m21s
Reviewed-on: #19
2026-05-09 21:00:19 +00:00
23df5b1151 feat: entity patch
Some checks failed
Build Test / test (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Failing after 30s
PHP Unit Tests / test (pull_request) Successful in 1m1s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-09 16:57:54 -04:00
5794da2bf5 Merge pull request 'recator: entity move and delete' (#18) from refactor/entity-move-and-delete into main
Some checks failed
Renovate / renovate (push) Failing after 1h12m55s
Reviewed-on: #18
2026-05-08 03:54:41 +00:00
c1dbcc9a7d recator: entity move and delete
Some checks failed
Build Test / test (pull_request) Successful in 39s
JS Unit Tests / test (pull_request) Failing after 38s
PHP Unit Tests / test (pull_request) Successful in 1m2s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-07 23:53:21 -04:00
c33a74003d Merge pull request 'chore: renovate' (#17) from chore/renovate into main
Some checks failed
Renovate / renovate (push) Failing after 1m46s
Reviewed-on: #17
2026-05-07 02:18:49 +00:00
64b813b184 chore: renovate
Some checks failed
Build Test / test (pull_request) Successful in 41s
JS Unit Tests / test (pull_request) Failing after 41s
PHP Unit Tests / test (pull_request) Successful in 1m9s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 22:11:38 -04:00
a95e16e61a Merge pull request 'feat: collection move' (#16) from feat/collection-move into main
Reviewed-on: #16
2026-05-06 16:03:26 +00:00
fbc8ac2d7e feat: collection move
Some checks failed
JS Unit Tests / test (pull_request) Failing after 26s
Build Test / test (pull_request) Successful in 32s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 12:03:05 -04:00
9b6823c481 Merge pull request 'feat: colleciton delete' (#15) from feat/collection-delete into main
Some checks failed
Renovate / renovate (push) Failing after 1m13s
Reviewed-on: #15
2026-05-06 02:33:21 +00:00
b682b0629c feat: colleciton delete
Some checks failed
Build Test / test (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Failing after 28s
PHP Unit Tests / test (pull_request) Successful in 1m7s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-05 22:32:52 -04:00
5988a372bc Merge pull request 'feat: lots more improvements' (#14) from feat/lots-more-improvements into main
Some checks failed
Renovate / renovate (push) Failing after 2m28s
Reviewed-on: #14
2026-04-25 19:41:45 +00:00
99a68737d1 feat: lots more improvements
Some checks failed
JS Unit Tests / test (pull_request) Failing after 29s
Build Test / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Successful in 1m12s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-25 15:41:16 -04:00
86e4772d45 Merge pull request 'chore: bunch of improvements' (#13) from refactor/bunch-of-improvements into main
Some checks failed
Renovate / renovate (push) Failing after 1m39s
Reviewed-on: #13
2026-04-24 02:01:16 +00:00
3362afb7ec chore: bunch of improvements
All checks were successful
JS Unit Tests / test (pull_request) Successful in 33s
Build Test / test (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m12s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-23 22:00:50 -04:00
b617234b40 Merge pull request 'feat: entity move' (#12) from feat/entity-move into main
Some checks failed
Renovate / renovate (push) Failing after 1m29s
Reviewed-on: #12
2026-03-28 16:37:14 +00:00
1c918ca55c feat: entity move
All checks were successful
Build Test / test (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-28 09:32:04 -04:00
7322bb16da Merge pull request 'refactor: improvemets' (#11) from refactor/improvements into main
Some checks failed
Renovate / renovate (push) Failing after 1m22s
Reviewed-on: #11
2026-03-24 23:14:21 +00:00
78449a702b refactor: improvemets
All checks were successful
Build Test / test (pull_request) Successful in 1m58s
JS Unit Tests / test (pull_request) Successful in 1m58s
PHP Unit Tests / test (pull_request) Successful in 2m29s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-24 19:12:02 -04:00
4bcfbf6d0e Merge pull request 'refactor: unify streaming' (#10) from refactor/unify-streaming into main
Some checks failed
Renovate / renovate (push) Failing after 2m11s
Reviewed-on: #10
2026-03-07 03:57:09 +00:00
cceaf809d9 refactor: unify streaming
All checks were successful
Build Test / test (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Successful in 1m8s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:53:08 -05:00
5bfe5dd249 Merge pull request 'refactor: standardize manager design' (#9) from refactor/standardize-desing into main
Some checks failed
Renovate / renovate (push) Failing after 1m33s
Reviewed-on: #9
2026-02-25 05:20:38 +00:00
65435b526c refactor: standardize manager design
All checks were successful
Build Test / test (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 1m5s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-25 00:18:28 -05:00
4a7fe7aeb4 Merge pull request 'refactor: module federation' (#8) from refactor/module-federation into main
Some checks failed
Renovate / renovate (push) Failing after 1m44s
Reviewed-on: #8
2026-02-22 21:54:16 +00:00
ad0a20613e refactor: module federation
All checks were successful
JS Unit Tests / test (pull_request) Successful in 50s
Build Test / test (pull_request) Successful in 54s
PHP Unit Tests / test (pull_request) Successful in 57s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 16:54:00 -05:00
6e5627f83b Merge pull request 'feat/entity-streaming' (#7) from feat/entity-streaming into main
Some checks failed
Renovate / renovate (push) Failing after 1m47s
Reviewed-on: #7
2026-02-21 15:08:31 +00:00
83c04e659b fix: export all objects
All checks were successful
Build Test / test (pull_request) Successful in 52s
JS Unit Tests / test (pull_request) Successful in 50s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:07:49 -05:00
0310a30f22 chore: remove unneeded files
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:06:47 -05:00
f520b8e5ac feat: streaming entities
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 10:06:07 -05:00
1f8a6d2d07 Merge pull request 'chore: code cleanup' (#6) from chore/code-cleanup into main
Some checks failed
Renovate / renovate (push) Failing after 1m20s
Reviewed-on: #6
2026-02-20 05:06:11 +00:00
e5eeeeb546 chore: code cleanup
All checks were successful
Build Test / test (pull_request) Successful in 48s
JS Unit Tests / test (pull_request) Successful in 51s
PHP Unit Tests / test (pull_request) Successful in 1m3s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 00:16:33 -05:00
6716e507c4 Merge pull request 'chore: implemement basic tests' (#5) from feat/implement-basic-tests into main
Some checks failed
Renovate / renovate (push) Failing after 1m43s
Reviewed-on: #5
2026-02-16 03:38:45 +00:00
f704e2e392 chore: implemement basic tests
All checks were successful
JS Unit Tests / test (pull_request) Successful in 22s
Build Test / test (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 22:37:31 -05:00
54 changed files with 7443 additions and 4041 deletions

View File

@@ -18,6 +18,9 @@
"require": {
"php": ">=8.2 <=8.5"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"KTXM\\MailManager\\": "lib/"

1792
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,428 +0,0 @@
# Mail Manager - Interface Relationships
This document visualizes all the interfaces in the mail_manager module and their relationships.
## Overview
The mail manager uses a hierarchical structure where interfaces are organized by their domain responsibilities:
- **Common Types**: Base types and selectors
- **Providers**: Mail service providers (Gmail, IMAP, etc.)
- **Services**: Individual mail accounts/services
- **Collections**: Mailboxes and folders
- **Messages**: Email messages and their parts
---
## Complete Interface Diagram
```mermaid
classDiagram
%% Common/Base Types
class SourceSelector {
+string provider
+string service
+string collection
+string message
}
class ApiRequest~T~ {
+T data
}
class ApiResponse~T~ {
+T data
+Error error
}
class ListRange {
+number offset
+number limit
}
%% Provider Interfaces
class ProviderInterface {
+string @type
+string identifier
+string label
+ProviderCapabilitiesInterface capabilities
}
class ProviderCapabilitiesInterface {
+boolean ServiceList
+boolean ServiceFetch
+boolean ServiceExtant
+boolean ServiceCreate
+boolean ServiceModify
+boolean ServiceDestroy
+boolean ServiceDiscover
+boolean ServiceTest
}
class ProviderListRequest {
+SourceSelector sources
}
class ProviderListResponse {
+ProviderInterface[identifier] providers
}
class ProviderFetchRequest {
+string identifier
}
class ProviderFetchResponse {
<<extends ProviderInterface>>
}
class ProviderExtantRequest {
+SourceSelector sources
}
class ProviderExtantResponse {
+boolean[identifier] exists
}
%% Service Interfaces
class ServiceInterface {
+string @type
+string identifier
+string provider
+string label
+ServiceCapabilitiesInterface capabilities
+object configuration
}
class ServiceCapabilitiesInterface {
+boolean CollectionList
+boolean CollectionFetch
+boolean CollectionExtant
+boolean CollectionCreate
+boolean CollectionModify
+boolean CollectionDestroy
}
class ServiceListRequest {
+SourceSelector sources
+ListRange range
}
class ServiceListResponse {
+ServiceInterface[identifier] services
}
class ServiceFetchRequest {
+string provider
+string identifier
}
class ServiceFetchResponse {
<<extends ServiceInterface>>
}
class ServiceExtantRequest {
+SourceSelector sources
}
class ServiceExtantResponse {
+boolean[identifier] exists
}
class ServiceCreateRequest {
+string provider
+string label
+object configuration
}
class ServiceCreateResponse {
<<extends ServiceInterface>>
}
class ServiceModifyRequest {
+string provider
+string identifier
+string label
+object configuration
}
class ServiceModifyResponse {
<<extends ServiceInterface>>
}
class ServiceDestroyRequest {
+string provider
+string identifier
}
class ServiceDestroyResponse {
+boolean success
}
%% Collection Interfaces
class CollectionInterface {
+string @type
+string identifier
+string service
+string provider
+string label
+CollectionCapabilitiesInterface capabilities
+string[] flags
+number messageCount
}
class CollectionCapabilitiesInterface {
+boolean MessageList
+boolean MessageFetch
+boolean MessageExtant
+boolean MessageCreate
+boolean MessageModify
+boolean MessageDestroy
}
class CollectionListRequest {
+SourceSelector sources
+ListRange range
}
class CollectionListResponse {
+CollectionInterface[identifier] collections
}
class CollectionFetchRequest {
+string provider
+string service
+string identifier
}
class CollectionFetchResponse {
<<extends CollectionInterface>>
}
%% Message Interfaces
class MessageInterface {
+string @type
+string identifier
+string collection
+string service
+string provider
+string[] flags
+Date receivedDate
+Date internalDate
+MessageHeadersInterface headers
+MessagePartInterface[] parts
}
class MessageHeadersInterface {
+string from
+string[] to
+string[] cc
+string[] bcc
+string subject
+string messageId
+string[] references
+string inReplyTo
+Date date
}
class MessagePartInterface {
+string partId
+string mimeType
+string filename
+number size
+MessagePartInterface[] subParts
+object headers
+string body
}
class MessageListRequest {
+SourceSelector sources
+ListRange range
+string[] flags
}
class MessageListResponse {
+MessageInterface[identifier] messages
}
class MessageFetchRequest {
+string provider
+string service
+string collection
+string identifier
}
class MessageFetchResponse {
<<extends MessageInterface>>
}
%% Relationships
ProviderInterface --> ProviderCapabilitiesInterface
ProviderFetchResponse --|> ProviderInterface
ProviderListResponse --> ProviderInterface
ServiceInterface --> ServiceCapabilitiesInterface
ServiceFetchResponse --|> ServiceInterface
ServiceCreateResponse --|> ServiceInterface
ServiceModifyResponse --|> ServiceInterface
ServiceListResponse --> ServiceInterface
CollectionInterface --> CollectionCapabilitiesInterface
CollectionFetchResponse --|> CollectionInterface
CollectionListResponse --> CollectionInterface
MessageInterface --> MessageHeadersInterface
MessageInterface --> MessagePartInterface
MessagePartInterface --> MessagePartInterface : subParts
MessageFetchResponse --|> MessageInterface
MessageListResponse --> MessageInterface
%% Selector Usage
ProviderListRequest --> SourceSelector
ProviderExtantRequest --> SourceSelector
ServiceListRequest --> SourceSelector
ServiceExtantRequest --> SourceSelector
CollectionListRequest --> SourceSelector
MessageListRequest --> SourceSelector
```
---
## Hierarchical Structure
```mermaid
graph TD
A[SourceSelector] --> B[Provider Level]
B --> C[Service Level]
C --> D[Collection Level]
D --> E[Message Level]
B --> B1[ProviderInterface]
B --> B2[ProviderCapabilities]
C --> C1[ServiceInterface]
C --> C2[ServiceCapabilities]
D --> D1[CollectionInterface]
D --> D2[CollectionCapabilities]
E --> E1[MessageInterface]
E --> E2[MessageHeaders]
E --> E3[MessagePart]
```
---
## Request/Response Pattern
All operations follow a consistent request/response pattern:
```mermaid
sequenceDiagram
participant Client
participant API
participant Provider
Client->>API: {Operation}Request
API->>Provider: Process Request
Provider->>API: Data
API->>Client: {Operation}Response
```
### Operations by Level:
**Provider Level:**
- List, Fetch, Extant
**Service Level:**
- List, Fetch, Extant, Create, Modify, Destroy
**Collection Level:**
- List, Fetch, Extant, Create, Modify, Destroy
**Message Level:**
- List, Fetch, Extant, Create, Modify, Destroy
---
## Capability Inheritance
```mermaid
graph LR
A[ProviderCapabilities] -->|enables| B[ServiceCapabilities]
B -->|enables| C[CollectionCapabilities]
C -->|enables| D[Message Operations]
```
Capabilities cascade down the hierarchy - if a provider doesn't support `ServiceList`, then no services can be listed for that provider.
---
## Key Patterns
### 1. **Extends Pattern**
Response interfaces extend their base interface:
- `ProviderFetchResponse extends ProviderInterface`
- `ServiceFetchResponse extends ServiceInterface`
### 2. **Dictionary Pattern**
List responses use identifier as key:
```typescript
{
[identifier: string]: Interface
}
```
### 3. **SourceSelector Pattern**
Resources are selected hierarchically:
```typescript
{
provider: "gmail",
service: "user@example.com",
collection: "INBOX",
message: "msg123"
}
```
### 4. **Recursive Structure**
MessagePart can contain subParts:
```typescript
MessagePartInterface {
subParts?: MessagePartInterface[]
}
```
---
## Usage Examples
### Selecting a specific message:
```typescript
const selector: SourceSelector = {
provider: "gmail",
service: "user@example.com",
collection: "INBOX",
message: "12345"
};
```
### Listing all services for a provider:
```typescript
const request: ServiceListRequest = {
sources: {
provider: "gmail"
},
range: {
offset: 0,
limit: 50
}
};
```
---
## Interface Files
- `common.ts` - Base types and selectors
- `provider.ts` - Provider-level interfaces
- `service.ts` - Service-level interfaces
- `collection.ts` - Collection-level interfaces
- `message.ts` - Message-level interfaces

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2273
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,18 +18,18 @@
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"pinia": "^2.3.1",
"pinia": "^3.0.0",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.2"
"vue-router": "^5.0.0",
"vuetify": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
}
}

View File

@@ -1,3 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
"$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
}

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, shallowRef, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ProviderDiscoveryStatus, ServiceLocation, ServiceIdentity } from '@MailManager/types'
import type { ServiceObject } from '@MailManager/models/service'
import DiscoveryStatusStep from '@MailManager/components/steps/DiscoveryStatusStep.vue'
import ProviderSelectionStep from '@MailManager/components/steps/ProviderSelectionStep.vue'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
import DiscoveryEntryStep from '@MailManager/components/steps/DiscoveryEntryStep.vue'
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'
// ==================== Step Constants ====================
// Discovery flow: Entry → Discovery → Auth → Test
@@ -38,6 +39,7 @@ const emit = defineEmits<{
'saved': []
}>()
const integrationStore = useIntegrationStore()
const servicesStore = useServicesStore()
const providersStore = useProvidersStore()
@@ -56,19 +58,10 @@ const discoverSecret = ref<string | null>(null)
const discoverHostname = ref<string | null>(null)
// Step 2: Discovery Status / Provider Selection
const selectedProviderId = ref<string | undefined>(undefined)
const selectedProviderLabel = ref<string>('')
// Step 3: Config (manual only) OR Auth (both paths)
const configuredLocation = ref<ServiceLocation | null>(null)
// Step 4: Auth (both paths)
const configuredIdentity = ref<ServiceIdentity | null>(null)
const authValid = ref(false)
const selectedProvider = shallowRef<ProviderObject | null>(null)
const selectedService = shallowRef<ServiceObject | null>(null)
// Step 5: Test & Save
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
const testAndSaveValid = ref(false)
// Local discovery state (not stored in global store)
@@ -137,19 +130,58 @@ const showSaveButton = computed(() => {
const canProceedToNext = computed(() => {
if (isManualMode.value) {
if (currentStep.value === MANUAL_STEPS.CONFIG) {
return !!configuredLocation.value
return !!selectedService.value?.location
}
if (currentStep.value === MANUAL_STEPS.AUTH) {
return authValid.value
return !!selectedService.value?.identity
}
} else {
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
return authValid.value
return !!selectedService.value?.identity
}
}
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) {
@@ -171,6 +203,7 @@ function handleNextStep() {
async function handleDiscover() {
// Move to discovery status screen
isManualMode.value = false
currentStep.value = DISCOVERY_STEPS.DISCOVERY
// Extract provider IDs
@@ -198,23 +231,24 @@ async function handleDiscover() {
discoveryStatus.value[identifier].status = 'discovering'
try {
const services = await servicesStore.discover(
let discoveredService: any = undefined
await servicesStore.discover(
discoverAddress.value,
discoverSecret.value || undefined,
discoverHostname.value || undefined,
identifier
identifier,
(service) => { discoveredService = service }
)
// Success - check if we got results for this provider
const service = services.find(s => s.provider === identifier)
if (service && service.location) {
if (discoveredService && discoveredService.location) {
discoveryStatus.value[identifier] = {
provider: identifier,
status: 'success',
location: service.location,
metadata: extractLocationMetadata(service.location)
location: discoveredService.location,
metadata: extractLocationMetadata(discoveredService.location)
}
discoveredServices.value.push(service)
discoveredServices.value.push(discoveredService)
} else {
// No configuration found for this provider
discoveryStatus.value[identifier].status = 'failed'
@@ -258,12 +292,18 @@ function extractLocationMetadata(location: ServiceLocation) {
async function handleProviderSelect(identifier: string) {
// User clicked "Select" on discovered provider - skip config, go to auth
const service = discoveredServices.value.find(s => s.provider === identifier)
if (!service || !service.location) return
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
configuredLocation.value = service.location
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)
// Discovery path: Entry → Discovery → Auth → Test
currentStep.value = DISCOVERY_STEPS.AUTH // Go to auth step
@@ -271,11 +311,17 @@ async function handleProviderSelect(identifier: string) {
function handleProviderAdvanced(identifier: string) {
// User clicked "Advanced" - show manual config with pre-filled values
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
const service = discoveredServices.value.find(s => s.provider === identifier)
configuredLocation.value = service?.location || null
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)
isManualMode.value = true
// Manual path: Entry → Discovery → Config → Auth → Test
@@ -292,8 +338,15 @@ function handleManualMode() {
function handleProviderManualSelect(identifier: string) {
// User selected a provider in manual mode
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
const service = createServiceObject(identifier, {
label: discoverAddress.value,
enabled: true,
primaryAddress: discoverAddress.value,
location: null,
identity: null
})
setSelectedProviderAndService(identifier, service)
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
}
@@ -302,10 +355,20 @@ function goBackToIdentity() {
isManualMode.value = false
discoveredServices.value = []
discoveryStatus.value = {}
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
}
async function testConnection() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) {
if (!selectedProvider.value || !selectedService.value) {
return {
success: false,
message: 'Missing configuration'
}
}
if (!selectedService.value.location || !selectedService.value.identity) {
return {
success: false,
message: 'Missing configuration'
@@ -313,31 +376,35 @@ async function testConnection() {
}
const testResult = await servicesStore.test(
selectedProviderId.value,
selectedProvider.value.identifier,
null,
configuredLocation.value,
configuredIdentity.value
selectedService.value.location,
selectedService.value.identity
)
return testResult
}
async function saveAccount() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return
if (!selectedProvider.value || !selectedService.value) return
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || discoverAddress.value,
email: discoverAddress.value,
enabled: accountEnabled.value,
location: configuredLocation.value,
identity: configuredIdentity.value
label: serviceData.label || discoverAddress.value,
primaryAddress: serviceData.primaryAddress || discoverAddress.value,
enabled: serviceData.enabled,
location: serviceData.location,
identity: serviceData.identity,
auxiliary: serviceData.auxiliary
}
await servicesStore.create(
selectedProviderId.value,
selectedProvider.value.identifier,
accountData
)
@@ -363,13 +430,8 @@ function resetForm() {
discoverAddress.value = ''
discoverSecret.value = null
discoverHostname.value = null
selectedProviderId.value = undefined
selectedProviderLabel.value = ''
configuredLocation.value = null
configuredIdentity.value = null
authValid.value = false
accountLabel.value = ''
accountEnabled.value = true
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
discoveredServices.value = []
discoveryStatus.value = {}
@@ -406,7 +468,7 @@ function resetForm() {
<!-- Step 1: Discovery Entry -->
<template #item.1>
<v-card flat class="pa-6">
<DiscoveryEntryStep
<DiscoveryEntryPanel
v-model:address="discoverAddress"
v-model:secret="discoverSecret"
v-model:hostname="discoverHostname"
@@ -420,7 +482,7 @@ function resetForm() {
<template #item.2>
<v-card flat class="pa-6">
<!-- Discovery path -->
<DiscoveryStatusStep
<DiscoveryStatusPanel
v-if="!isManualMode"
:address="discoverAddress"
:status="discoveryStatus"
@@ -431,7 +493,7 @@ function resetForm() {
/>
<!-- Manual path - provider picker -->
<ProviderSelectionStep
<ProviderSelectionPanel
v-else
@select="handleProviderManualSelect"
@back="goBackToIdentity"
@@ -443,24 +505,18 @@ function resetForm() {
<template #item.3>
<v-card flat class="pa-6">
<!-- Manual path: Protocol Configuration -->
<ProviderConfigStep
v-if="isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:discovered-location="configuredLocation || undefined"
v-model="configuredLocation"
@valid="() => { /* Can proceed to next step */ }"
<ProviderProtocolPanel
v-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"
<ProviderAuthPanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
</v-card>
</template>
@@ -468,31 +524,21 @@ function resetForm() {
<!-- Step 4: Auth (manual) OR Test (discovery) -->
<template #item.4>
<v-card flat class="pa-6">
<ProviderAuthStep
v-if="isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="discoverAddress"
:prefilled-secret="discoverSecret || undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
<ProviderAuthPanel
v-if="isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
<!-- Discovery path: Test & Save -->
<TestAndSaveStep
v-else-if="!isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
<TestAndSavePanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>
@@ -500,18 +546,13 @@ function resetForm() {
<!-- Step 5: Test & Save (manual only) -->
<template #item.5>
<v-card flat class="pa-6">
<TestAndSaveStep
v-if="selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
<TestAndSavePanel
v-if="selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>

View File

@@ -1,18 +1,14 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, shallowRef, computed, watch } from 'vue'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ServiceLocation, ServiceIdentity } from '@MailManager/types'
import type { ServiceObject } from '@MailManager/models/service'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
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'
const EDIT_STEPS = {
CONFIG: 1,
AUTH: 2,
TEST: 3
} as const
type EditTab = 'general' | 'auxiliary' | 'protocol' | 'auth'
const props = defineProps<{
modelValue: boolean
@@ -33,146 +29,90 @@ const dialogOpen = computed({
set: (val) => emit('update:modelValue', val)
})
const currentStep = ref<number>(EDIT_STEPS.CONFIG)
const currentTab = ref<EditTab>('general')
const saving = ref(false)
const loading = ref(false)
const loadError = ref<string | 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)
const localProvider = shallowRef<ProviderObject | null>(null)
const localService = shallowRef<ServiceObject | null>(null)
// Validation states
const configValid = ref(false)
const authValid = ref(false)
const testAndSaveValid = ref(false)
// Load service data when dialog opens
watch(dialogOpen, async (isOpen) => {
if (isOpen) {
await loadService()
}
})
async function loadService() {
loading.value = true
try {
// Load providers if not already loaded
if (!providersStore.has) {
await providersStore.list()
}
// Fetch the service
service.value = await servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
// Set initial values
accountLabel.value = service.value.label || ''
accountEnabled.value = service.value.enabled
configuredLocation.value = service.value.location
configuredIdentity.value = service.value.identity
// Get provider label
const provider = providersStore.provider(props.serviceProvider)
providerLabel.value = provider?.label || props.serviceProvider
// Mark config as valid if location exists
configValid.value = !!configuredLocation.value
authValid.value = !!configuredIdentity.value
} catch (error) {
console.error('Failed to load service:', error)
} finally {
loading.value = false
}
function serviceRequiresConnectionTest(service: ServiceObject | null): boolean {
return !!(service?.location?.mutated() || service?.identity?.mutated())
}
// Stepper configuration
const stepperItems = [
{ title: 'Protocol', value: EDIT_STEPS.CONFIG },
{ title: 'Authentication', value: EDIT_STEPS.AUTH },
{ title: 'Test & Save', value: EDIT_STEPS.TEST }
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
}
]
const canSave = computed(() => {
return testAndSaveValid.value
return !serviceRequiresConnectionTest(localService.value) || testAndSaveValid.value
})
// Navigation button visibility
const showPreviousButton = computed(() => currentStep.value > EDIT_STEPS.CONFIG)
const showNextButton = computed(() => currentStep.value < EDIT_STEPS.TEST)
const showSaveButton = computed(() => currentStep.value === EDIT_STEPS.TEST)
const showSaveButton = computed(() => currentTab.value === 'general')
const accountReady = computed(() => localProvider.value !== null && localService.value !== null)
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'
// 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 testResult = await servicesStore.test(
service.value.provider,
service.value.identifier,
configuredLocation.value,
configuredIdentity.value
)
return testResult
}
async function saveAccount() {
if (!service.value || !configuredLocation.value || !configuredIdentity.value) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || service.value.label,
enabled: accountEnabled.value,
location: configuredLocation.value,
identity: configuredIdentity.value
}
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)
])
await servicesStore.update(
service.value.provider,
service.value.identifier as string | number,
accountData
)
emit('saved')
close()
localProvider.value = provider.clone()
localService.value = service.clone()
} catch (error) {
console.error('Failed to save account:', error)
// TODO: Show error message to user
console.error('[Mail Manager][Edit Account Dialog] - Failed to load service:', error)
loadError.value = 'Failed to load service details'
} finally {
saving.value = false
loading.value = false
}
}
@@ -183,21 +123,90 @@ function close() {
}
function resetForm() {
currentStep.value = EDIT_STEPS.CONFIG
service.value = null
accountLabel.value = ''
accountEnabled.value = true
configuredLocation.value = null
configuredIdentity.value = null
configValid.value = false
authValid.value = false
testAndSaveValid.value = false
currentTab.value = 'general'
localService.value = null
localProvider.value = null
loadError.value = null
}
// Watch for location changes
watch(configuredLocation, (newLocation) => {
configValid.value = !!newLocation
})
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
}
}
</script>
<template>
@@ -219,105 +228,88 @@ watch(configuredLocation, (newLocation) => {
<v-divider />
<v-card-text v-if="loading" class="text-center py-8">
<v-card-text v-if="loading || (!loadError && !accountReady)" 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">
<!-- 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-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>
<v-divider />
<v-stepper
v-model="currentStep"
:items="stepperItems"
alt-labels
flat
hide-actions
>
<!-- Step 1: Protocol Configuration -->
<template #item.1>
<v-window v-model="currentTab">
<v-window-item value="general">
<v-card flat class="pa-6">
<ProviderConfigStep
v-if="service"
:provider-id="service.provider"
:discovered-location="configuredLocation || undefined"
v-model="configuredLocation"
@valid="(valid) => configValid = valid"
/>
</v-card>
</template>
<!-- Step 2: Authentication -->
<template #item.2>
<v-card flat class="pa-6">
<ProviderAuthStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="service.primaryAddress || ''"
:prefilled-secret="undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
/>
</v-card>
</template>
<!-- Step 3: Test & Save -->
<template #item.3>
<v-card flat class="pa-6">
<TestAndSaveStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="accountLabel"
<TestAndSavePanel
v-if="localProvider && localService"
:provider="localProvider!"
:service="localService!"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleUpdate"
/>
</v-card>
</template>
</v-stepper>
</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>
</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
@@ -326,18 +318,7 @@ watch(configuredLocation, (newLocation) => {
>
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"

View File

@@ -36,6 +36,14 @@ 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>
@@ -55,6 +63,7 @@ const rules = {
autocomplete="off"
:rules="[rules.required, rules.email]"
class="mb-4"
@keydown.enter.prevent="handleDiscoverOnEnter"
/>
<!-- Advanced Options -->
@@ -143,6 +152,8 @@ const rules = {
<v-btn
variant="text"
block
class="manual-action-btn"
prepend-icon="mdi-tune"
@click="$emit('manual')"
>
Manual Configuration
@@ -155,4 +166,8 @@ const rules = {
.gap-3 {
gap: 12px;
}
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ProviderDiscoveryStatus } from '@MailManager/types/service'
import type { ProviderDiscoveryStatus } from '@MailManager/types'
const props = defineProps<{
address: string
@@ -16,7 +16,7 @@ const emit = defineEmits<{
const sortedStatus = computed(() => {
const statusArray = Object.values(props.status)
const order = { success: 0, discovering: 1, pending: 2, failed: 3 }
const order: Record<string, number> = { success: 0, discovering: 1, pending: 2, failed: 3 }
return statusArray.sort((a, b) => order[a.status] - order[b.status])
})
@@ -191,6 +191,22 @@ 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>
@@ -217,4 +233,8 @@ function getProviderLabel(providerId: string): string {
.gap-3 {
gap: 12px;
}
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style>

View File

@@ -0,0 +1,123 @@
<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>

View File

@@ -1,163 +0,0 @@
<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>

View File

@@ -0,0 +1,119 @@
<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>

View File

@@ -1,124 +0,0 @@
<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>

View File

@@ -0,0 +1,123 @@
<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>

View File

@@ -14,7 +14,7 @@ const selected = ref<string | null>(null)
// Get provider metadata from integrations
const providerMetadata = computed(() => {
const metadata = integrationStore.getItems('mail_provider_metadata')
const metadata = integrationStore.getItems('mail_provider_details')
return metadata.reduce((acc: any, meta: any) => {
acc[meta.id] = meta
return acc

View File

@@ -1,3 +1,121 @@
<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>
@@ -16,7 +134,7 @@
<v-icon>mdi-label</v-icon>
</template>
<v-list-item-title>Account Name</v-list-item-title>
<v-list-item-subtitle>{{ accountLabel }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.label || localService.primaryAddress || 'New Account' }}</v-list-item-subtitle>
</v-list-item>
<!-- Email Address -->
@@ -25,7 +143,7 @@
<v-icon>mdi-email</v-icon>
</template>
<v-list-item-title>Email Address</v-list-item-title>
<v-list-item-subtitle>{{ emailAddress }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.primaryAddress }}</v-list-item-subtitle>
</v-list-item>
<!-- Provider -->
@@ -34,48 +152,48 @@
<v-icon>mdi-cloud</v-icon>
</template>
<v-list-item-title>Provider</v-list-item-title>
<v-list-item-subtitle>{{ providerLabel }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localProvider.label }}</v-list-item-subtitle>
</v-list-item>
<!-- Location Details -->
<template v-if="location">
<template v-if="serviceLocation">
<v-divider class="my-2" />
<v-list-item v-if="location.type === 'URI'">
<v-list-item v-if="serviceLocation.type === 'URI'">
<template #prepend>
<v-icon>mdi-web</v-icon>
</template>
<v-list-item-title>Service URL</v-list-item-title>
<v-list-item-subtitle>
{{ location.scheme }}://{{ location.host }}:{{ location.port }}{{ location.path || '' }}
{{ serviceLocation.scheme }}://{{ serviceLocation.host }}:{{ serviceLocation.port }}{{ serviceLocation.path || '' }}
</v-list-item-subtitle>
</v-list-item>
<template v-if="location.type === 'SOCKET_SOLE'">
<template v-if="serviceLocation.type === 'SOCKET_SOLE'">
<v-list-item>
<template #prepend>
<v-icon>mdi-server</v-icon>
</template>
<v-list-item-title>Server</v-list-item-title>
<v-list-item-subtitle>{{ location.host }}:{{ location.port }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ serviceLocation.host }}:{{ serviceLocation.port }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-shield-lock</v-icon>
</template>
<v-list-item-title>Security</v-list-item-title>
<v-list-item-subtitle>{{ location.encryption.toUpperCase() }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ serviceLocation.encryption.toUpperCase() }}</v-list-item-subtitle>
</v-list-item>
</template>
<template v-if="location.type === 'SOCKET_SPLIT'">
<template v-if="serviceLocation.type === 'SOCKET_SPLIT'">
<v-list-item>
<template #prepend>
<v-icon>mdi-inbox-arrow-down</v-icon>
</template>
<v-list-item-title>Incoming Mail</v-list-item-title>
<v-list-item-subtitle>
{{ location.inbound.protocol.toUpperCase() }} - {{ location.inbound.host }}:{{ location.inbound.port }} ({{ location.inbound.encryption.toUpperCase() }})
{{ serviceLocation.inboundHost }}:{{ serviceLocation.inboundPort }} ({{ serviceLocation.inboundEncryption.toUpperCase() }})
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
@@ -84,7 +202,7 @@
</template>
<v-list-item-title>Outgoing Mail</v-list-item-title>
<v-list-item-subtitle>
{{ location.outbound.protocol.toUpperCase() }} - {{ location.outbound.host }}:{{ location.outbound.port }} ({{ location.outbound.encryption.toUpperCase() }})
{{ serviceLocation.outboundHost }}:{{ serviceLocation.outboundPort }} ({{ serviceLocation.outboundEncryption.toUpperCase() }})
</v-list-item-subtitle>
</v-list-item>
</template>
@@ -94,10 +212,10 @@
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon>{{ getAuthIcon(identity?.type) }}</v-icon>
<v-icon>{{ getAuthIcon(serviceIdentity?.type) }}</v-icon>
</template>
<v-list-item-title>Authentication</v-list-item-title>
<v-list-item-subtitle>{{ getAuthLabel(identity?.type) }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ getAuthLabel(serviceIdentity?.type) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
@@ -105,7 +223,7 @@
<!-- Account Label Input -->
<v-text-field
v-model="localAccountLabel"
v-model="localService.label"
label="Account Name"
variant="outlined"
hint="A friendly name for this account (e.g., Work Email)"
@@ -116,7 +234,7 @@
<!-- Enable Account Toggle -->
<v-switch
v-model="accountEnabled"
v-model="localService.enabled"
label="Enable this account"
color="primary"
class="mb-4"
@@ -176,120 +294,4 @@
Please test the connection before saving
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
providerLabel: string
emailAddress: string
location: ServiceLocation | null
identity: ServiceIdentity | null
prefilledLabel?: string
onTest: () => Promise<any>
}>()
const emit = defineEmits<{
'update:label': [value: string]
'update:enabled': [value: boolean]
'tested': [success: boolean]
'valid': [value: boolean]
}>()
// Local state
const localAccountLabel = ref(props.prefilledLabel || props.emailAddress || '')
const accountEnabled = ref(true)
const testing = ref(false)
const testResult = ref<any>(null)
// Computed
const accountLabel = computed(() => localAccountLabel.value || props.emailAddress || 'New Account')
const testSuccess = computed(() => testResult.value?.success === true)
const isValid = computed(() => {
return testSuccess.value && !!localAccountLabel.value
})
// Helper functions
function getAuthIcon(type?: string): string {
switch (type) {
case 'NA': return 'mdi-lock-open-variant'
case 'BA': return 'mdi-account-key'
case 'TA': return 'mdi-key'
case 'OA': return 'mdi-shield-account'
case 'CC': return 'mdi-certificate'
default: return 'mdi-help-circle'
}
}
function getAuthLabel(type?: string): string {
switch (type) {
case 'NA': return 'No Authentication'
case 'BA': return 'Username & Password'
case 'TA': return 'API Token'
case 'OA': return 'OAuth 2.0'
case 'CC': return 'Client Certificate'
default: return 'Unknown'
}
}
function formatCapabilities(capabilities: any): string {
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
const caps = Object.entries(capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => key)
.slice(0, 5)
const total = caps.length
const display = caps.slice(0, 3).join(', ')
if (total > 3) {
return `${display}, +${total - 3} more`
}
return display
}
async function handleTest() {
if (!props.location || !props.identity) {
testResult.value = {
success: false,
message: 'Missing configuration'
}
return
}
testing.value = true
testResult.value = null
try {
const result = await props.onTest()
testResult.value = result
emit('tested', result.success)
} catch (error: any) {
testResult.value = {
success: false,
message: error.message || 'Connection test failed'
}
emit('tested', false)
} finally {
testing.value = false
}
}
// Watch for changes and emit
watch(localAccountLabel, (value) => {
emit('update:label', value)
})
watch(accountEnabled, (value) => {
emit('update:enabled', value)
})
watch(isValid, (value) => {
emit('valid', value)
}, { immediate: true })
</script>
</template>

View File

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

View File

@@ -1,21 +1,13 @@
import type { App as Vue } from 'vue'
import routes from '@/routes'
import integrations from '@/integrations'
import { useCollectionsStore } from '@/stores/collectionsStore'
import { useEntitiesStore } from '@/stores/entitiesStore'
import { useProvidersStore } from '@/stores/providersStore'
import { useServicesStore } from '@/stores/servicesStore'
/**
* Mail Manager Module Boot Script
*
* This script is executed when the mail_manager module is loaded.
* It initializes the stores which manage mail providers, services, collections, and messages.
* Mail Manager Module Boot
*/
console.log('[MailManager] Booting Mail Manager module...')
import routes from '@/routes'
import integrations from '@/integrations'
console.log('[MailManager] Mail Manager module booted successfully')
console.log('[Mail Manager] Booting module...')
console.log('[Mail Manager] Module booted successfully...')
// CSS will be injected by build process
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
@@ -23,12 +15,14 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// Export routes and integrations for module system
export { routes, integrations }
// Export stores for external use if needed
export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore }
// Export services, stores and models for external use
export * from '@/services'
export * from '@/stores'
export * from '@/models'
// Default export for Vue plugin installation
export default {
install(app: Vue) {
// Module initialization if needed
}
}
// 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'

27
src/models/clone-plain.ts Normal file
View File

@@ -0,0 +1,27 @@
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));
}

View File

@@ -2,57 +2,46 @@
* Class model for Collection Interface
*/
import type { CollectionInterface, CollectionPropertiesInterface } from "@/types/collection";
import type { CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface, CollectionPropertiesModelInterface } from "@/types/collection";
import { clonePlain } from './clone-plain';
import type { CollectionIdentifier, ServiceIdentifier } from "@/services";
export class CollectionObject implements CollectionInterface {
export class CollectionObject implements CollectionModelInterface {
_data!: CollectionInterface;
_data!: CollectionInterface<CollectionPropertiesInterface>;
_properties: CollectionPropertiesObject | undefined = undefined;
constructor() {
this._data = {
'@type': 'mail:collection',
version: 1,
provider: '',
service: '',
collection: null,
identifier: '',
signature: null,
created: null,
modified: null,
properties: {
'@type': 'mail.collection',
version: 1,
total: 0,
unread: 0,
label: '',
role: null,
rank: 0,
subscribed: true,
},
properties: {'@type': 'mail:folder', label: ''},
};
}
fromJson(data: CollectionInterface): CollectionObject {
this._data = data;
if (data.properties) {
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
} else {
this._data.properties = new CollectionPropertiesObject();
}
this._data = clonePlain(data);
this._properties = undefined;
return this;
}
toJson(): CollectionInterface {
const json = { ...this._data };
if (this._data.properties instanceof CollectionPropertiesObject) {
json.properties = this._data.properties.toJson();
}
return json;
const json = this._properties
? {
...this._data,
properties: this._properties.toJson(),
}
: this._data;
return clonePlain(json);
}
clone(): CollectionObject {
const cloned = new CollectionObject();
cloned._data = { ...this._data };
cloned._data.properties = this.properties.clone();
return cloned;
return new CollectionObject().fromJson(this.toJson());
}
/** Immutable Properties */
@@ -61,16 +50,16 @@ export class CollectionObject implements CollectionInterface {
return this._data.provider;
}
get service(): string | number {
return this._data.service;
get service(): ServiceIdentifier {
return this._data.service as ServiceIdentifier;
}
get collection(): string | number | null {
return this._data.collection;
get collection(): CollectionIdentifier | null {
return this._data.collection as CollectionIdentifier | null;
}
get identifier(): string | number {
return this._data.identifier;
get identifier(): CollectionIdentifier {
return this._data.identifier as CollectionIdentifier;
}
get signature(): string | null | undefined {
@@ -86,70 +75,54 @@ export class CollectionObject implements CollectionInterface {
}
get properties(): CollectionPropertiesObject {
if (this._data.properties instanceof CollectionPropertiesObject) {
return this._data.properties;
if (this._properties) {
return this._properties;
}
if (this._data.properties) {
const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface);
this._data.properties = hydrated;
return hydrated;
else if (this._data.properties) {
const properties = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface);
this._properties = properties;
return properties;
}
return new CollectionPropertiesObject();
}
set properties(value: CollectionPropertiesObject) {
if (value instanceof CollectionPropertiesObject) {
this._data.properties = value as any;
} else {
this._data.properties = value;
}
this._properties = value;
}
}
export class CollectionPropertiesObject implements CollectionPropertiesInterface {
export class CollectionPropertiesObject implements CollectionPropertiesModelInterface {
_data!: CollectionPropertiesInterface;
private _data!: CollectionPropertiesInterface;
constructor() {
this._data = {
'@type': 'mail.collection',
version: 1,
'@type': 'mail:folder',
total: 0,
unread: 0,
label: '',
role: null,
label: '',
rank: 0,
subscribed: true,
};
}
fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject {
this._data = data;
this._data = clonePlain(data);
return this;
}
toJson(): CollectionPropertiesInterface {
return this._data;
return clonePlain(this._data);
}
clone(): CollectionPropertiesObject {
const cloned = new CollectionPropertiesObject();
cloned._data = { ...this._data };
return cloned;
return new CollectionPropertiesObject().fromJson(this.toJson());
}
/** 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;
}

View File

@@ -2,62 +2,51 @@
* Class model for Message/Entity Interface
*/
import type { EntityInterface } from "@/types/entity";
import type { MessageInterface, MessagePartInterface } from "@/types/message";
import type { EntityInterface, EntityModelInterface } from "@/types/entity";
import type { MessageInterface } from "@/types/message";
import { MessageObject } from "./message";
import { clonePlain } from './clone-plain';
import type { CollectionIdentifier, EntityIdentifier, ServiceIdentifier } from "@/services";
export class EntityObject {
export class EntityObject implements EntityModelInterface {
_data!: EntityInterface<MessageInterface>;
_message!: MessageObject;
private _data!: EntityInterface<MessageInterface>;
private _properties: MessageObject | undefined = undefined;
constructor() {
this._data = {
'@type': 'mail.entity',
'@type': 'mail:entity',
version: 1,
provider: '',
service: '',
collection: '',
identifier: '',
collection: null,
identifier: null,
signature: null,
created: null,
modified: null,
properties: {
'@type': 'mail.message',
version: 1,
urid: '',
size: 0,
receivedDate: undefined,
date: undefined,
subject: '',
snippet: '',
from: undefined,
to: [],
cc: [],
bcc: [],
replyTo: [],
flags: {},
body: undefined,
attachments: [],
}
properties: {'@type': 'mail:message'},
};
}
fromJson(data: EntityInterface<MessageInterface>): EntityObject {
this._data = data;
this._data = clonePlain(data);
this._properties = undefined;
return this;
}
toJson(): EntityInterface<MessageInterface> {
return this._data;
const json = this._properties
? {
...this._data,
properties: this._properties.toJson(),
}
: this._data;
return clonePlain(json);
}
clone(): EntityObject {
const cloned = new EntityObject();
cloned._data = {
...this._data,
properties: { ...this._data.properties }
};
return cloned;
return new EntityObject().fromJson(this.toJson());
}
/** Metadata Properties */
@@ -66,16 +55,16 @@ export class EntityObject {
return this._data.provider;
}
get service(): string {
return this._data.service;
get service(): ServiceIdentifier {
return this._data.service as ServiceIdentifier;
}
get collection(): string|number {
return this._data.collection;
get collection(): CollectionIdentifier {
return this._data.collection as CollectionIdentifier;
}
get identifier(): string|number {
return this._data.identifier;
get identifier(): EntityIdentifier {
return this._data.identifier as EntityIdentifier;
}
get signature(): string | null {
@@ -93,15 +82,10 @@ export class EntityObject {
/** Message Object Properties */
get properties(): MessageObject {
if (!this._message) {
this._message = new MessageObject(this._data.properties);
if (!this._properties) {
this._properties = new MessageObject().fromJson(this._data.properties as MessageInterface);
}
return this._message;
}
// Alias for backward compatibility
get object(): MessageObject {
return this.properties;
return this._properties;
}
}

View File

@@ -10,13 +10,55 @@ import type {
ServiceIdentityOAuth,
ServiceIdentityCertificate
} from '@/types/service';
import { MutationProxy } from './mutation-proxy';
import { clonePlain } from './clone-plain';
/**
* Base Identity class
*/
export abstract class Identity {
abstract toJson(): ServiceIdentity;
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;
}
static fromJson(data: ServiceIdentity): Identity {
switch (data.type) {
case 'NA':
@@ -38,81 +80,109 @@ export abstract class Identity {
/**
* No authentication
*/
export class IdentityNone extends Identity {
readonly type = 'NA' as const;
export class IdentityNone extends Identity<ServiceIdentityNone> {
constructor() {
super({
type: 'NA'
});
}
static fromJson(_data: ServiceIdentityNone): IdentityNone {
return new IdentityNone();
}
toJson(): ServiceIdentityNone {
return {
type: this.type
};
clone(): IdentityNone {
return IdentityNone.fromJson(this.toJson());
}
get type(): 'NA' {
return this._data.type;
}
}
/**
* Basic authentication (username/password)
*/
export class IdentityBasic extends Identity {
readonly type = 'BA' as const;
identity: string;
secret: string;
export class IdentityBasic extends Identity<ServiceIdentityBasic> {
constructor(identity: string = '', secret: string = '') {
super();
this.identity = identity;
this.secret = secret;
super({
type: 'BA',
identity,
secret
});
}
static fromJson(data: ServiceIdentityBasic): IdentityBasic {
return new IdentityBasic(data.identity, data.secret);
return new IdentityBasic().load(data);
}
toJson(): ServiceIdentityBasic {
return {
type: this.type,
identity: this.identity,
secret: this.secret
};
clone(): IdentityBasic {
return IdentityBasic.fromJson(this.toJson());
}
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 {
readonly type = 'TA' as const;
token: string;
export class IdentityToken extends Identity<ServiceIdentityToken> {
constructor(token: string = '') {
super();
this.token = token;
super({
type: 'TA',
token
});
}
static fromJson(data: ServiceIdentityToken): IdentityToken {
return new IdentityToken(data.token);
return new IdentityToken().load(data);
}
toJson(): ServiceIdentityToken {
return {
type: this.type,
token: this.token
};
clone(): IdentityToken {
return IdentityToken.fromJson(this.toJson());
}
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 {
readonly type = 'OA' as const;
accessToken: string;
accessScope?: string[];
accessExpiry?: number;
refreshToken?: string;
refreshLocation?: string;
export class IdentityOAuth extends Identity<ServiceIdentityOAuth> {
constructor(
accessToken: string = '',
@@ -121,33 +191,22 @@ export class IdentityOAuth extends Identity {
refreshToken?: string,
refreshLocation?: string
) {
super();
this.accessToken = accessToken;
this.accessScope = accessScope;
this.accessExpiry = accessExpiry;
this.refreshToken = refreshToken;
this.refreshLocation = refreshLocation;
super({
type: 'OA',
accessToken,
accessScope,
accessExpiry,
refreshToken,
refreshLocation
});
}
static fromJson(data: ServiceIdentityOAuth): IdentityOAuth {
return new IdentityOAuth(
data.accessToken,
data.accessScope,
data.accessExpiry,
data.refreshToken,
data.refreshLocation
);
return new IdentityOAuth().load(data);
}
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 })
};
clone(): IdentityOAuth {
return IdentityOAuth.fromJson(this.toJson());
}
isExpired(): boolean {
@@ -159,38 +218,101 @@ export class IdentityOAuth extends Identity {
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 {
readonly type = 'CC' as const;
certificate: string;
privateKey: string;
passphrase?: string;
export class IdentityCertificate extends Identity<ServiceIdentityCertificate> {
constructor(certificate: string = '', privateKey: string = '', passphrase?: string) {
super();
this.certificate = certificate;
this.privateKey = privateKey;
this.passphrase = passphrase;
super({
type: 'CC',
certificate,
privateKey,
passphrase
});
}
static fromJson(data: ServiceIdentityCertificate): IdentityCertificate {
return new IdentityCertificate(
data.certificate,
data.privateKey,
data.passphrase
);
return new IdentityCertificate().load(data);
}
toJson(): ServiceIdentityCertificate {
return {
type: this.type,
certificate: this.certificate,
privateKey: this.privateKey,
...(this.passphrase && { passphrase: this.passphrase })
};
clone(): IdentityCertificate {
return IdentityCertificate.fromJson(this.toJson());
}
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;
}
}

View File

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

View File

@@ -9,12 +9,50 @@ import type {
ServiceLocationSocketSplit,
ServiceLocationFile
} from '@/types/service';
import { MutationProxy } from './mutation-proxy';
import { clonePlain } from './clone-plain';
/**
* Base Location class
*/
export abstract class Location {
abstract toJson(): ServiceLocation;
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;
}
static fromJson(data: ServiceLocation): Location {
switch (data.type) {
@@ -36,14 +74,7 @@ export abstract class Location {
* URI-based service location for API and web services
* Used by: JMAP, Gmail API, etc.
*/
export class LocationUri extends Location {
readonly type = 'URI' as const;
scheme: string;
host: string;
port: number;
path?: string;
verifyPeer: boolean;
verifyHost: boolean;
export class LocationUri extends Location<ServiceLocationUri> {
constructor(
scheme: string = 'https',
@@ -53,55 +84,89 @@ export class LocationUri extends Location {
verifyPeer: boolean = true,
verifyHost: boolean = true
) {
super();
this.scheme = scheme;
this.host = host;
this.port = port;
this.path = path;
this.verifyPeer = verifyPeer;
this.verifyHost = verifyHost;
super({
type: 'URI',
scheme,
host,
port,
...(path !== undefined && { path }),
verifyPeer,
verifyHost,
});
}
static fromJson(data: ServiceLocationUri): LocationUri {
return new LocationUri(
data.scheme,
data.host,
data.port,
data.path,
data.verifyPeer ?? true,
data.verifyHost ?? true
);
}
toJson(): ServiceLocationUri {
return {
type: this.type,
scheme: this.scheme,
host: this.host,
port: this.port,
...(this.path && { path: this.path }),
...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }),
...(this.verifyHost !== undefined && { verifyHost: this.verifyHost })
};
return new LocationUri().load(data);
}
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 {
readonly type = 'SOCKET_SOLE' as const;
host: string;
port: number;
encryption: 'none' | 'ssl' | 'tls' | 'starttls';
verifyPeer: boolean;
verifyHost: boolean;
export class LocationSocketSole extends Location<ServiceLocationSocketSole> {
constructor(
host: string = '',
@@ -110,52 +175,75 @@ export class LocationSocketSole extends Location {
verifyPeer: boolean = true,
verifyHost: boolean = true
) {
super();
this.host = host;
this.port = port;
this.encryption = encryption;
this.verifyPeer = verifyPeer;
this.verifyHost = verifyHost;
super({
type: 'SOCKET_SOLE',
host,
port,
encryption,
verifyPeer,
verifyHost,
});
}
static fromJson(data: ServiceLocationSocketSole): LocationSocketSole {
return new LocationSocketSole(
data.host,
data.port,
data.encryption,
data.verifyPeer ?? true,
data.verifyHost ?? true
);
return new LocationSocketSole().load(data);
}
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 })
};
clone(): LocationSocketSole {
return LocationSocketSole.fromJson(structuredClone(this.toJson()));
}
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 {
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;
export class LocationSocketSplit extends Location<ServiceLocationSocketSplit> {
constructor(
inboundHost: string = '',
@@ -169,72 +257,146 @@ export class LocationSocketSplit extends Location {
outboundVerifyPeer: boolean = true,
outboundVerifyHost: boolean = true
) {
super();
this.inboundHost = inboundHost;
this.inboundPort = inboundPort;
this.inboundEncryption = inboundEncryption;
this.outboundHost = outboundHost;
this.outboundPort = outboundPort;
this.outboundEncryption = outboundEncryption;
this.inboundVerifyPeer = inboundVerifyPeer;
this.inboundVerifyHost = inboundVerifyHost;
this.outboundVerifyPeer = outboundVerifyPeer;
this.outboundVerifyHost = outboundVerifyHost;
super({
type: 'SOCKET_SPLIT',
inboundHost,
inboundPort,
inboundEncryption,
outboundHost,
outboundPort,
outboundEncryption,
inboundVerifyPeer,
inboundVerifyHost,
outboundVerifyPeer,
outboundVerifyHost,
});
}
static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit {
return new LocationSocketSplit(
data.inboundHost,
data.inboundPort,
data.inboundEncryption,
data.outboundHost,
data.outboundPort,
data.outboundEncryption,
data.inboundVerifyPeer ?? true,
data.inboundVerifyHost ?? true,
data.outboundVerifyPeer ?? true,
data.outboundVerifyHost ?? true
);
return new LocationSocketSplit().load(data);
}
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 })
};
clone(): LocationSocketSplit {
return LocationSocketSplit.fromJson(structuredClone(this.toJson()));
}
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 {
readonly type = 'FILE' as const;
path: string;
export class LocationFile extends Location<ServiceLocationFile> {
constructor(path: string = '') {
super();
this.path = path;
super({
type: 'FILE',
path,
});
}
static fromJson(data: ServiceLocationFile): LocationFile {
return new LocationFile(data.path);
return new LocationFile().load(data);
}
toJson(): ServiceLocationFile {
return {
type: this.type,
path: this.path
};
clone(): LocationFile {
return LocationFile.fromJson(structuredClone(this.toJson()));
}
get type(): 'FILE' {
return this._data.type;
}
get path(): string {
return this._data.path;
}
set path(value: string) {
this._data.path = value;
}
}

View File

@@ -1,94 +1,338 @@
/**
* Message and MessagePart model classes
*/
import type { MessageInterface, MessagePartInterface } from "@/types/message";
import type {
MessageAddressInterface,
MessageInterface,
MessageModelInterface,
MessagePartInterface,
MessagePartModelInterface
} from "@/types/message";
import { clonePlain } from './clone-plain';
/**
* MessagePart class for working with message body parts
* Message class for working with message objects
*/
export class MessagePartObject {
_data: MessagePartInterface;
export class MessageObject implements MessageModelInterface {
constructor(data?: Partial<MessagePartInterface>) {
_data: MessageInterface;
_body: MessagePartObject | null = null;
constructor() {
this._data = {
partId: data?.partId ?? null,
blobId: data?.blobId ?? null,
size: data?.size ?? null,
name: data?.name ?? null,
type: data?.type ?? undefined,
charset: data?.charset ?? null,
disposition: data?.disposition ?? null,
cid: data?.cid ?? null,
language: data?.language ?? null,
location: data?.location ?? null,
content: data?.content ?? undefined,
subParts: data?.subParts ?? undefined,
'@type': 'mail:message',
};
this._body = null;
}
fromJson(data: MessagePartInterface): MessagePartObject {
this._data = data;
fromJson(data: MessageInterface): MessageObject {
this._data = clonePlain(data);
this._body = null;
return this;
}
toJson(): MessagePartInterface {
return this._data;
toJson(): MessageInterface {
const json = this._body
? {
...this._data,
body: this._body.toJson(),
}
: this._data;
return clonePlain(json);
}
clone(): MessagePartObject {
return new MessagePartObject(JSON.parse(JSON.stringify(this._data)));
clone(): MessageObject {
return new MessageObject().fromJson(this.toJson());
}
/** Properties */
get partId(): string | null | undefined {
return this._data.partId;
get size(): number {
return this._data.size ?? 0;
}
get headers(): Record<string, string> {
return clonePlain(this._data.headers ?? {});
}
get blobId(): string | null | undefined {
return this._data.blobId;
get urid(): string | null{
return this._data.urid ?? null;
}
get size(): number | null | undefined {
return this._data.size;
get inReplyTo(): string | null {
return this._data.inReplyTo ?? null;
}
get name(): string | null | undefined {
return this._data.name;
get references(): string | null {
return this._data.references ?? null;
}
get type(): string | undefined {
return this._data.type;
get received(): string | null {
return this._data.received ?? null;
}
get charset(): string | null | undefined {
return this._data.charset;
get sent(): string | null {
return this._data.sent ?? null;
}
get disposition(): string | null | undefined {
return this._data.disposition;
get sender(): MessageAddressObject | null {
return this._data.sender ? new MessageAddressObject(this._data.sender) : null;
}
get cid(): string | null | undefined {
return this._data.cid;
get from(): MessageAddressObject | null {
return this._data.from ? new MessageAddressObject(this._data.from) : null;
}
get language(): string | null | undefined {
return this._data.language;
get replyTo(): Array<MessageAddressObject> | null {
return this._data.replyTo ? this._data.replyTo.map(addr => new MessageAddressObject(addr)) : null;
}
get location(): string | null | undefined {
return this._data.location;
get to(): Array<MessageAddressObject> | null {
return this._data.to ? this._data.to.map(addr => new MessageAddressObject(addr)) : null;
}
get content(): string | undefined {
return this._data.content;
get cc(): Array<MessageAddressObject> | null {
return this._data.cc ? this._data.cc.map(addr => new MessageAddressObject(addr)) : null;
}
get subParts(): MessagePartInterface[] | undefined {
return this._data.subParts;
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;
}
}
/**
* MessagePart class for working with message body parts
*/
export class MessagePartObject implements MessagePartModelInterface {
_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 ?? []),
};
}
fromJson(data: MessagePartInterface): MessagePartObject {
this._data = clonePlain(data);
this._subParts = [];
return this;
}
toJson(): MessagePartInterface {
const json = this._subParts.length > 0
? {
...this._data,
subParts: this._subParts.map(subPart => subPart.toJson()),
}
: this._data;
return clonePlain(json);
}
clone(): MessagePartObject {
return new MessagePartObject().fromJson(this.toJson());
}
/** Properties */
get partId(): string | null {
return this._data.partId ?? null;
}
get blobId(): string | null {
return this._data.blobId ?? null;
}
get size(): number | null {
return this._data.size ?? null;
}
get name(): string | null {
return this._data.name ?? null;
}
get type(): string | null {
return this._data.type ?? null;
}
get charset(): string | null {
return this._data.charset ?? null;
}
get disposition(): string | null {
return this._data.disposition ?? null;
}
get cid(): string | null {
return this._data.cid ?? null;
}
get language(): string | null {
return this._data.language ?? null;
}
get location(): string | null {
return this._data.location ?? null;
}
get content(): string | null {
return this._data.content ?? null;
}
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 [];
}
/** Helper methods */
@@ -98,7 +342,7 @@ export class MessagePartObject {
}
hasSubParts(): boolean {
return !!this._data.subParts && this._data.subParts.length > 0;
return this.subParts.length > 0;
}
isMultipart(): boolean {
@@ -206,171 +450,3 @@ export class MessagePartObject {
}
/**
* 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) : [];
}
}

View File

@@ -0,0 +1,61 @@
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,
}),
});
}
}

View File

@@ -4,16 +4,19 @@
import type {
ProviderInterface,
ProviderCapabilitiesInterface
ProviderCapabilitiesInterface,
ProviderModelInterface
} from "@/types/provider";
import { clonePlain } from './clone-plain';
export class ProviderObject implements ProviderInterface {
export class ProviderObject implements ProviderModelInterface {
_data!: ProviderInterface;
constructor() {
this._data = {
'@type': 'mail.provider',
'@type': 'mail:provider',
version: 1,
identifier: '',
label: '',
capabilities: {},
@@ -21,12 +24,16 @@ export class ProviderObject implements ProviderInterface {
}
fromJson(data: ProviderInterface): ProviderObject {
this._data = data;
this._data = clonePlain(data);
return this;
}
toJson(): ProviderInterface {
return this._data;
return clonePlain(this._data);
}
clone(): ProviderObject {
return new ProviderObject().fromJson(this.toJson());
}
capable(capability: keyof ProviderCapabilitiesInterface): boolean {
@@ -43,10 +50,6 @@ export class ProviderObject implements ProviderInterface {
/** Immutable Properties */
get '@type'(): string {
return this._data['@type'];
}
get identifier(): string {
return this._data.identifier;
}
@@ -56,7 +59,7 @@ export class ProviderObject implements ProviderInterface {
}
get capabilities(): ProviderCapabilitiesInterface {
return this._data.capabilities;
return clonePlain(this._data.capabilities);
}
}

View File

@@ -5,34 +5,91 @@
import type {
ServiceInterface,
ServiceCapabilitiesInterface,
ServiceIdentity,
ServiceLocation
ServiceLocation,
ServiceModelInterface
} 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 ServiceInterface {
export class ServiceObject implements ServiceModelInterface {
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._data = {
this._original = {
'@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._data = data;
this._original = clonePlain(data);
this._mutated = {};
this._data = this._mutationProxy.create();
this._location = undefined;
this._identity = undefined;
return this;
}
toJson(): ServiceInterface {
return this._data;
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);
}
capable(capability: keyof ServiceCapabilitiesInterface): boolean {
@@ -49,10 +106,6 @@ export class ServiceObject implements ServiceInterface {
/** Immutable Properties */
get '@type'(): string {
return this._data['@type'];
}
get provider(): string {
return this._data.provider;
}
@@ -61,18 +114,26 @@ export class ServiceObject implements ServiceInterface {
return this._data.identifier;
}
get capabilities(): ServiceCapabilitiesInterface | undefined {
return this._data.capabilities;
get capabilities(): ServiceCapabilitiesInterface {
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 {
@@ -91,46 +152,48 @@ export class ServiceObject implements ServiceInterface {
this._data.enabled = value;
}
get location(): ServiceLocation | null {
return this._data.location ?? null;
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;
}
set location(value: ServiceLocation | null) {
this._data.location = value;
set location(value: Location | null) {
this._location = value;
}
get identity(): ServiceIdentity | null {
return this._data.identity ?? null;
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;
}
set identity(value: ServiceIdentity | null) {
this._data.identity = value;
set identity(value: Identity | null) {
this._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);
}
}

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useServicesStore } from '@/stores/servicesStore'
import AddAccountDialog from '@/components/AddAccountDialog.vue'
import type { ServiceObject } from '@/models'
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'
const servicesStore = useServicesStore()
const integrationStore = useIntegrationStore()
const showAddDialog = ref(false)
const showEditDialog = ref(false)
@@ -12,13 +15,20 @@ 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 () => {
@@ -31,44 +41,18 @@ onMounted(async () => {
})
function getProviderIcon(providerId: string): string {
const icons: Record<string, string> = {
'smtp': 'mdi-email-multiple',
'jmap': 'mdi-api',
'exchange': 'mdi-microsoft',
}
return icons[providerId] || 'mdi-email'
return providerMetadata.value[providerId]?.icon || 'mdi-email'
}
function getProviderLabel(providerId: string): string {
const labels: Record<string, string> = {
'smtp': 'SMTP/IMAP',
'jmap': 'JMAP',
'exchange': 'Microsoft Exchange',
}
return labels[providerId] || providerId.toUpperCase()
return providerMetadata.value[providerId]?.label || providerId.toUpperCase()
}
function editAccount(account: any) {
selectedAccount.value = { ...account }
function editAccount(account: ServiceObject) {
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
@@ -94,9 +78,7 @@ async function testAccount(service: ServiceObject) {
try {
const result = await servicesStore.test(
service.provider,
service.identifier,
service.location,
service.identity
service.identifier
)
testResult.value = result
showTestResult.value = true
@@ -112,6 +94,7 @@ async function testAccount(service: ServiceObject) {
}
async function handleAccountSaved() {
showEditDialog.value = false
await servicesStore.list()
}
</script>
@@ -292,42 +275,12 @@ async function handleAccountSaved() {
/>
<!-- Edit Account Dialog -->
<v-dialog
<EditAccountDialog
v-model="showEditDialog"
max-width="600"
>
<v-card>
<v-card-title>Edit Account</v-card-title>
<v-card-text>
<v-text-field
v-model="selectedAccount.label"
label="Account Name"
variant="outlined"
/>
<v-switch
v-model="selectedAccount.enabled"
label="Enable this account"
color="primary"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="showEditDialog = false"
>
Cancel
</v-btn>
<v-btn
color="primary"
:loading="saving"
@click="saveEdit"
>
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
:service-provider="selectedAccount?.provider || ''"
:service-identifier="selectedAccount?.identifier || ''"
@saved="handleAccountSaved"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog

View File

@@ -17,6 +17,8 @@ import type {
CollectionDeleteResponse,
CollectionDeleteRequest,
CollectionInterface,
CollectionMoveRequest,
CollectionMoveResponse,
} from '../types/collection';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { CollectionObject, CollectionPropertiesObject } from '../models/collection';
@@ -70,9 +72,16 @@ export const collectionService = {
*
* @returns Promise with collection object
*/
async fetch(request: CollectionFetchRequest): Promise<CollectionObject> {
async fetch(request: CollectionFetchRequest): Promise<Record<string, CollectionObject>> {
const response = await transceivePost<CollectionFetchRequest, CollectionFetchResponse>('collection.fetch', request);
return createCollectionObject(response);
// Convert response to CollectionObject instances
const list: Record<string, CollectionObject> = {};
Object.entries(response).forEach(([identifier, entity]) => {
list[entity.identifier] = createCollectionObject(entity);
});
return list;
},
/**
@@ -123,9 +132,27 @@ export const collectionService = {
*
* @returns Promise with deletion result
*/
async delete(request: CollectionDeleteRequest): Promise<CollectionDeleteResponse> {
return await transceivePost<CollectionDeleteRequest, CollectionDeleteResponse>('collection.delete', request);
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;
},
/**
* 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;

View File

@@ -2,10 +2,8 @@
* Entity management service
*/
import { transceivePost } from './transceive';
import { transceivePost, transceiveStream, transceiveDownload } from './transceive';
import type {
EntityListRequest,
EntityListResponse,
EntityFetchRequest,
EntityFetchResponse,
EntityExtantRequest,
@@ -18,9 +16,18 @@ 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';
@@ -47,8 +54,8 @@ export const entityService = {
*
* @returns Promise with entity object list grouped by provider, service, collection, and entity identifier
*/
async list(request: EntityListRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
const response = await transceivePost<EntityListRequest, EntityListResponse>('entity.list', request);
async listBulk(request: EntityListBulkRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
const response = await transceivePost<EntityListBulkRequest, EntityListBulkResponse>('entity.listBulk', request);
// Convert nested response to EntityObject instances
const providerList: Record<string, Record<string, Record<string, Record<string, EntityObject>>>> = {};
@@ -71,6 +78,27 @@ 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
*
@@ -83,8 +111,8 @@ export const entityService = {
// Convert response to EntityObject instances
const list: Record<string, EntityObject> = {};
Object.entries(response).forEach(([identifier, entityData]) => {
list[identifier] = createEntityObject(entityData);
Object.entries(response).forEach(([, entity]) => {
list[entity.identifier] = createEntityObject(entity);
});
return list;
@@ -126,11 +154,32 @@ export const entityService = {
},
/**
* Delete an entity
* 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
*
* @param request - delete request parameters
*
* @returns Promise with deletion result
* @returns Promise with deletion results keyed by source entity identifier
*/
async delete(request: EntityDeleteRequest): Promise<EntityDeleteResponse> {
return await transceivePost<EntityDeleteRequest, EntityDeleteResponse>('entity.delete', request);
@@ -147,6 +196,17 @@ 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
*
@@ -157,6 +217,16 @@ 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;

View File

@@ -5,24 +5,24 @@
import type {
ServiceListRequest,
ServiceListResponse,
ServiceExtantRequest,
ServiceExtantResponse,
ServiceFetchRequest,
ServiceFetchResponse,
ServiceDiscoverRequest,
ServiceDiscoverResponse,
ServiceTestRequest,
ServiceTestResponse,
ServiceInterface,
ServiceExtantRequest,
ServiceExtantResponse,
ServiceCreateResponse,
ServiceCreateRequest,
ServiceUpdateResponse,
ServiceUpdateRequest,
ServiceDeleteResponse,
ServiceDeleteRequest,
ServiceDiscoverRequest,
ServiceTestRequest,
ServiceTestResponse,
ServiceInterface,
ServiceDiscoverResponse,
} from '../types/service';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { transceivePost } from './transceive';
import { transceivePost, transceiveStream } 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_service_factory', data.provider) as any;
const factoryItem = integrationStore.getItemById('mail_provider_factory_service', data.provider) as any;
const factory = factoryItem?.factory;
// Use provider factory if available, otherwise base class
@@ -87,31 +87,32 @@ export const serviceService = {
},
/**
* Retrieve discoverable services for a given source selector, sorted by provider
* Discover services, streaming results as each provider responds
*
* @param request - discover request parameters
* @param request - discover request parameters
* @param onService - called for each discovered service as it arrives
*
* @returns Promise with array of discovered services sorted by provider
* @returns Promise resolving to { total } when the stream completes
*/
async discover(request: ServiceDiscoverRequest): Promise<ServiceObject[]> {
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));
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));
}
);
},
/**

View File

@@ -3,8 +3,8 @@
* Provides a centralized way to make API calls with envelope wrapping/unwrapping
*/
import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core';
import type { ApiRequest, ApiResponse } from '../types/common';
import { createFetchWrapper } from '@KTXC';
import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common';
const fetchWrapper = createFetchWrapper();
const API_URL = '/m/mail_manager/v1';
@@ -48,3 +48,177 @@ 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 Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -4,13 +4,21 @@
import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia'
import { collectionService } from '../services'
import {
type ServiceIdentifier,
type CollectionIdentifier,
type ListFilter,
type ListSort,
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)
/**
@@ -33,13 +41,20 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*/
const collectionsByService = computed(() => {
const groups: Record<string, CollectionObject[]> = {}
Object.values(_collections.value).forEach((collection) => {
const serviceKey = `${collection.provider}:${collection.service}`
if (!groups[serviceKey]) {
groups[serviceKey] = []
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
}
groups[serviceKey].push(collection)
const firstCollection = collectionsForKey[0]
const serviceKey = `${firstCollection.provider}:${firstCollection.service}`
groups[serviceKey] = collectionsForKey
})
return groups
@@ -55,14 +70,13 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*
* @returns Collection object or null
*/
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)
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])
}
return _collections.value[key] || null
return _collections.value[target] || null
}
/**
@@ -75,50 +89,89 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
* @returns Array of collection objects
*/
function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] {
const serviceKeyPrefix = `${provider}:${service}:`
const serviceCollections = Object.entries(_collections.value)
.filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, collection]) => collection)
const serviceIdentifier = `${provider}:${service}` as ServiceIdentifier
const serviceCollections = collectionObjectsForKeys(
_collectionsByServiceIndex.value[serviceIdentifier] ?? [],
)
if (retrieve === true && serviceCollections.length === 0) {
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`)
const sources: SourceSelector = {
[provider]: {
[String(service)]: true
}
}
list(sources)
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${serviceIdentifier}"`)
list([serviceIdentifier])
}
return serviceCollections
}
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)
/**
* 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] ?? [],
)
if (retrieve === true && nestedCollections.length === 0) {
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`)
const sources: SourceSelector = {
[provider]: {
[String(service)]: {
[String(collectionId)]: true
}
}
}
list(sources)
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${collectionIdentifier}"`)
list([collectionIdentifier])
}
return nestedCollections
}
/**
* Create unique key for a collection
*/
function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string {
return `${provider}:${service ?? ''}:${identifier ?? ''}`
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
}
// Actions
@@ -132,7 +185,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*
* @returns Promise with collection object list keyed by provider, service, and collection identifier
*/
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
async function list(sources?: ServiceIdentifier[] | CollectionIdentifier[], filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
transceiving.value = true
try {
const response = await collectionService.list({ sources, filter, sort })
@@ -142,14 +195,20 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
Object.entries(response).forEach(([_providerId, providerServices]) => {
Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => {
Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => {
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
collections[key] = collectionObj
if (_collections.value[collectionObj.identifier]) {
deindexCollection(_collections.value[collectionObj.identifier])
}
collections[collectionObj.identifier] = 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
@@ -170,19 +229,25 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*
* @returns Promise with collection object
*/
async function fetch(provider: string, service: string | number, identifier: string | number): Promise<CollectionObject> {
async function fetch(targets: CollectionIdentifier[]): Promise<Record<string, CollectionObject>> {
transceiving.value = true
try {
const response = await collectionService.fetch({ provider, service, collection: identifier })
const response = await collectionService.fetch({ targets })
// Merge fetched collection into state
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
Object.values(response).forEach(collectionObj => {
if (_collections.value[collectionObj.identifier]) {
deindexCollection(_collections.value[collectionObj.identifier])
}
console.debug('[Mail Manager][Store] - Successfully fetched collection:', key)
_collections.value[collectionObj.identifier] = collectionObj
indexCollection(collectionObj)
})
console.debug('[Mail Manager][Store] - Successfully fetched collections:', Object.keys(response).join(', '))
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to fetch collection:', error)
console.error('[Mail Manager][Store] - Failed to fetch collections:', error)
throw error
} finally {
transceiving.value = false
@@ -196,12 +261,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*
* @returns Promise with collection availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: CollectionIdentifier[]): Promise<Record<string, Record<string, Record<string, boolean>>>> {
transceiving.value = true
try {
const response = await collectionService.extant({ sources })
const response = await collectionService.extant({ targets })
console.debug('[Mail Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections')
console.debug('[Mail Manager][Store] - Successfully checked', targets ? targets.length : 0, 'collections')
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to check collections:', error)
@@ -221,21 +286,22 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
*
* @returns Promise with created collection object
*/
async function create(provider: string, service: string | number, collection: string | number | null, data: CollectionPropertiesObject): Promise<CollectionObject> {
async function create(provider: string, service: string | number, properties: CollectionPropertiesObject, target?: CollectionIdentifier): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.create({
provider,
service,
collection,
properties: data
const response = await collectionService.create({
provider,
service,
target,
properties: properties.toJson()
})
// Merge created collection into state
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
if (response instanceof CollectionObject) {
_collections.value[response.identifier] = response
indexCollection(response)
}
console.debug('[Mail Manager][Store] - Successfully created collection:', key)
console.debug('[Mail Manager][Store] - Successfully created collection:', response.identifier)
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to create collection:', error)
@@ -246,30 +312,31 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
}
/**
* Update an existing collection with given provider, service, identifier, and data
* Update an existing collection with given target and properties
*
* @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
* @param target - collection identifier for the collection to update
* @param properties - collection properties for update
*
* @returns Promise with updated collection object
*/
async function update(provider: string, service: string | number, identifier: string | number, data: CollectionPropertiesObject): Promise<CollectionObject> {
async function update(target: CollectionIdentifier, properties: CollectionPropertiesObject): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.update({
provider,
service,
identifier,
properties: data
target,
properties: properties.toJson()
})
// Merge updated collection into state
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
if (_collections.value[target]) {
deindexCollection(_collections.value[target])
}
if (response instanceof CollectionObject) {
_collections.value[response.identifier] = response
indexCollection(response)
}
console.debug('[Mail Manager][Store] - Successfully updated collection:', key)
console.debug('[Mail Manager][Store] - Successfully updated collection:', response.identifier)
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to update collection:', error)
@@ -280,24 +347,39 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
}
/**
* Delete a collection by provider, service, and identifier
* Delete a collection by identifier, with optional 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
* @param target - collection identifier for the collection to delete
* @param force - optional flag to force delete if collection is not empty
*
* @returns Promise with deletion result
*/
async function remove(provider: string, service: string | number, identifier: string | number): Promise<any> {
async function remove(target: CollectionIdentifier, force?: boolean): Promise<CollectionObject | boolean> {
transceiving.value = true
try {
await collectionService.delete({ provider, service, identifier })
const response = await collectionService.delete({ target, options: { force } })
// Remove deleted collection from state
const key = identifierKey(provider, service, identifier)
delete _collections.value[key]
if (response !== true && !(response instanceof CollectionObject)) {
console.warn('[Mail Manager][Store] - Delete failed. Received unexpected response from delete operation:', response)
return false
}
console.debug('[Mail Manager][Store] - Successfully deleted collection:', 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
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to delete collection:', error)
throw error
@@ -306,6 +388,45 @@ 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)
@@ -317,6 +438,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
collectionsByService,
collectionsForService,
collectionsInCollection,
hasChildrenInCollection,
// Actions
collection,
list,
@@ -325,5 +447,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
create,
update,
delete: remove,
move,
}
})

View File

@@ -5,9 +5,21 @@
import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia'
import { entityService } from '../services'
import { EntityObject } from '../models'
import type { EntityTransmitRequest, EntityTransmitResponse } from '../types/entity'
import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common'
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'
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
// State
@@ -40,14 +52,13 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
*
* @returns Entity object or null
*/
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])
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])
}
return _entities.value[key] || null
return _entities.value[target] || null
}
/**
@@ -60,34 +71,19 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
*
* @returns Array of entity objects
*/
function entitiesForCollection(provider: string, service: string | number, collection: string | number, retrieve: boolean = false): EntityObject[] {
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
function entitiesForCollection(target: CollectionIdentifier, retrieve: boolean = false): EntityObject[] {
const collectionEntities = Object.entries(_entities.value)
.filter(([key]) => key.startsWith(collectionKeyPrefix))
.filter(([key]) => key.startsWith(target))
.map(([_, entity]) => entity)
if (retrieve === true && collectionEntities.length === 0) {
console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`)
const sources: SourceSelector = {
[provider]: {
[String(service)]: {
[String(collection)]: true
}
}
}
list(sources)
console.debug(`[Mail Manager][Store] - Force fetching entities for collection "${target}"`)
list([target])
}
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
/**
@@ -100,26 +96,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
*
* @returns Promise with entity object list keyed by identifier
*/
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
async function list(sources: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
transceiving.value = true
try {
const response = await entityService.list({ sources, filter, sort, range })
// 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 }
await entityService.listStream({ sources, filter, sort, range }, (entity: EntityObject) => {
_entities.value[entity.identifier] = entity
entities[entity.identifier] = entity
})
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities')
return entities
@@ -141,17 +126,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
*
* @returns Promise with entity objects keyed by identifier
*/
async function fetch(provider: string, service: string | number, collection: string | number, identifiers: (string | number)[]): Promise<Record<string, EntityObject>> {
async function fetch(targets: EntityIdentifier[]): Promise<Record<string, EntityObject>> {
transceiving.value = true
try {
const response = await entityService.fetch({ provider, service, collection, identifiers })
const response = await entityService.fetch({ targets })
// Merge fetched entities into state
const entities: Record<string, EntityObject> = {}
Object.entries(response).forEach(([identifier, entityData]) => {
const key = identifierKey(provider, service, collection, identifier)
entities[key] = entityData
_entities.value[key] = entityData
Object.entries(response).forEach(([identifier, entity]) => {
entities[identifier] = entity
_entities.value[identifier] = entity
})
console.debug('[Mail Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities')
@@ -165,16 +149,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
/**
* Retrieve entity availability status for a given source selector
* Retrieve entity availability status for a given set of entity identifiers
*
* @param sources - source selector to check availability for
* @param targets - array of entity identifiers to check availability for
*
* @returns Promise with entity availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: EntityIdentifier[]) {
transceiving.value = true
try {
const response = await entityService.extant({ sources })
const response = await entityService.extant({ targets })
console.debug('[Mail Manager][Store] - Successfully checked entity availability')
return response
} catch (error: any) {
@@ -186,25 +170,82 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
/**
* Create a new entity with given provider, service, collection, and data
* Retrieve delta changes for entities
*
* @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
* @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
*
* @returns Promise with created entity object
*/
async function create(provider: string, service: string | number, collection: string | number, data: any): Promise<EntityObject> {
async function create(target: CollectionIdentifier, properties: MessageInterface | MessageObject): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.create({ provider, service, collection, properties: data })
if (properties instanceof MessageObject) {
properties = properties.toJson()
}
const response = await entityService.create({ target, properties })
// Add created entity to state
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
_entities.value[response.identifier] = response
console.debug('[Mail Manager][Store] - Successfully created entity:', key)
console.debug('[Mail Manager][Store] - Successfully created entity:', response.identifier)
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to create entity:', error)
@@ -225,16 +266,18 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
*
* @returns Promise with updated entity object
*/
async function update(provider: string, service: string | number, collection: string | number, identifier: string | number, data: any): Promise<EntityObject> {
async function update(target: EntityIdentifier, properties: MessageInterface | MessageObject): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.update({ provider, service, collection, identifier, properties: data })
if (properties instanceof MessageObject) {
properties = properties.toJson()
}
const response = await entityService.update({ target, properties })
// Update entity in state
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
_entities.value[response.identifier] = response
console.debug('[Mail Manager][Store] - Successfully updated entity:', key)
console.debug('[Mail Manager][Store] - Successfully updated entity:', response.identifier)
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to update entity:', error)
@@ -245,28 +288,57 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
/**
* 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
* 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
*/
async function remove(provider: string, service: string | number, collection: string | number, identifier: string | number): Promise<any> {
async function remove(targets: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> {
transceiving.value = true
try {
const response = await entityService.delete({ provider, service, collection, identifier })
// Remove entity from state
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
const response = await entityService.delete({ targets })
const successes: EntityIdentifier[] = []
const failures: EntityIdentifier[] = []
console.debug('[Mail Manager][Store] - Successfully deleted entity:', key)
return response
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 entity:', error)
console.error('[Mail Manager][Store] - Failed to delete entities:', error)
throw error
} finally {
transceiving.value = false
@@ -274,51 +346,108 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
/**
* 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.
* Patch existing entities with new properties
*/
async function delta(sources: SourceSelector) {
async function patch(properties: MessageInterface | MessageObject, targets: EntityIdentifier[]) {
transceiving.value = true
try {
const response = await entityService.delta({ sources })
if (properties instanceof MessageObject) {
properties = properties.toJson()
}
const response = await entityService.patch({ properties, targets })
const successes: EntityIdentifier[] = []
const failures: EntityIdentifier[] = []
// 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
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
}
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 processed delta changes')
return response
console.debug('[Mail Manager][Store] - Successfully patched', successes.length, 'entities')
return { successes, failures }
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to process delta:', error)
console.error('[Mail Manager][Store] - Failed to patch entities:', error)
throw error
} finally {
transceiving.value = false
}
}
/**
* Move entities to another collection.
*
* Updates local store keys for successfully moved entities when they are
* already present in cache.
*
* @param target - target collection identifier
* @param sources - source entity identifiers
*
* @returns Promise with move results keyed by source identifier
*/
async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> {
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,
})
_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 }
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to move entities:', error)
throw error
} finally {
transceiving.value = false
@@ -346,6 +475,39 @@ 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)
@@ -355,15 +517,18 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
has,
entities,
entitiesForCollection,
// Actions
entity,
list,
fetch,
extant,
fresh,
create,
update,
patch,
delete: remove,
delta,
move,
transmit,
download,
}
})

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

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

View File

@@ -7,11 +7,16 @@ 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
@@ -33,6 +38,11 @@ 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
*/
@@ -59,13 +69,27 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
* @returns Service object or null
*/
function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null {
const key = identifierKey(provider, identifier)
if (retrieve === true && !_services.value[key]) {
console.debug(`[Mail Manager][Store] - Force fetching service "${key}"`)
fetch(provider, identifier)
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)
}
return _services.value[key] || null
return _services.value[identifier] ?? null
}
/**
@@ -91,8 +115,8 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
/**
* Unique key for a service
*/
function identifierKey(provider: string, identifier: string | number | null): string {
return `${provider}:${identifier ?? ''}`
function identifierKey(provider: string, identifier: string | number | null): ServiceIdentifier {
return `${provider}:${identifier ?? ''}` as ServiceIdentifier
}
// Actions
@@ -212,14 +236,23 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
*
* @param provider - provider identifier for the service to update
* @param identifier - service identifier for the service to update
* @param data - partial service data for update
* @param delta - whether the update is a delta (partial) update or a full replacement
* @param data - service data for update
*
* @returns Promise with updated service object
*/
async function update(provider: string, identifier: string | number, data: Partial<ServiceInterface>): Promise<ServiceObject> {
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>): Promise<ServiceObject> {
transceiving.value = true
try {
const service = await serviceService.update({ provider, identifier, data })
// 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 })
// Merge updated service into state
const key = identifierKey(service.provider, service.identifier)
@@ -268,22 +301,29 @@ 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 with list of discovered service objects
* @returns Promise resolving to { total } when the stream completes
*/
async function discover(
identity: string,
secret: string | undefined,
location: string | undefined,
provider: string | undefined,
): Promise<ServiceObject[]> {
onService?: (service: ServiceObject) => void,
): Promise<{ total: number }> {
transceiving.value = true
try {
const services = await serviceService.discover({identity, secret, location, provider})
const result = await serviceService.discover(
{ identity, secret, location, provider },
(service: ServiceObject) => {
onService?.(service)
}
)
console.debug('[Mail Manager][Store] - Successfully discovered', services.length, 'services')
return services
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
return result
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to discover service:', error)
throw error
@@ -305,11 +345,30 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
async function test(
provider: string,
identifier?: string | number | null,
location?: ServiceLocation | null,
identity?: ServiceIdentity | null,
location?: ServiceLocation | Location | null,
identity?: ServiceIdentity | Identity | 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)
@@ -330,10 +389,12 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
count,
has,
services,
servicesEnabled,
servicesByProvider,
// Actions
service,
serviceByIdentifier,
serviceForAddress,
list,
fetch,

View File

@@ -1,25 +1,30 @@
/**
* Collection type definitions
*/
import type { ListFilter, ListSort, SourceSelector } from './common';
import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier, SourceSelector } from './common';
/**
* Collection information
*/
export interface CollectionInterface {
export interface CollectionInterface<T = CollectionPropertiesInterface> {
'@type': string;
version: number;
provider: string;
service: string | number;
collection: string | number | null;
identifier: string | number;
collection: CollectionIdentifier | null;
identifier: CollectionIdentifier;
signature?: string | null;
created?: string | null;
modified?: string | null;
properties: CollectionPropertiesInterface;
properties: T;
}
export interface CollectionModelInterface extends Omit<CollectionInterface<CollectionPropertiesInterface>, '@type' | 'version' | 'properties'> {
properties: CollectionPropertiesModelInterface;
}
export interface CollectionBaseProperties {
'@type': string;
version: number;
}
export interface CollectionImmutableProperties extends CollectionBaseProperties {
@@ -36,11 +41,13 @@ export interface CollectionMutableProperties extends CollectionBaseProperties {
export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {}
export interface CollectionPropertiesModelInterface extends Omit<CollectionPropertiesInterface, '@type'> {}
/**
* Collection list
*/
export interface CollectionListRequest {
sources?: SourceSelector;
sources?: ServiceIdentifier[] | CollectionIdentifier[];
filter?: ListFilter;
sort?: ListSort;
}
@@ -57,18 +64,18 @@ export interface CollectionListResponse {
* Collection fetch
*/
export interface CollectionFetchRequest {
provider: string;
service: string | number;
collection: string | number;
targets: CollectionIdentifier[];
}
export interface CollectionFetchResponse extends CollectionInterface {}
export interface CollectionFetchResponse {
[identifier: CollectionIdentifier]: CollectionInterface;
}
/**
* Collection extant
*/
export interface CollectionExtantRequest {
sources: SourceSelector;
targets: CollectionIdentifier[];
}
export interface CollectionExtantResponse {
@@ -85,7 +92,7 @@ export interface CollectionExtantResponse {
export interface CollectionCreateRequest {
provider: string;
service: string | number;
collection?: string | number | null; // Parent Collection Identifier
target?: CollectionIdentifier; // Optional parent target for the new collection
properties: CollectionMutableProperties;
}
@@ -95,9 +102,7 @@ export interface CollectionCreateResponse extends CollectionInterface {}
* Collection modify
*/
export interface CollectionUpdateRequest {
provider: string;
service: string | number;
identifier: string | number;
target: CollectionIdentifier;
properties: CollectionMutableProperties;
}
@@ -107,15 +112,23 @@ export interface CollectionUpdateResponse extends CollectionInterface {}
* Collection delete
*/
export interface CollectionDeleteRequest {
provider: string;
service: string | number;
identifier: string | number;
target: CollectionIdentifier;
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 {
success: boolean;
disposition: 'deleted' | 'moved';
mutation?: CollectionInterface | null; // If moved, the new location of the collection
}
/**
* Collection move
*/
export interface CollectionMoveRequest {
target: CollectionIdentifier;
source: CollectionIdentifier;
}
export interface CollectionMoveResponse extends CollectionInterface {};

View File

@@ -43,6 +43,47 @@ 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.
*
@@ -72,6 +113,10 @@ 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

View File

@@ -1,34 +1,44 @@
/**
* Entity type definitions
*/
import type { SourceSelector, ListFilter, ListSort, ListRange } from './common';
import type { MessageInterface } from './message';
import type {
CollectionIdentifier,
EntityIdentifier,
ListFilter,
ListRange,
ListSort,
} from './common';
import type { MessageInterface, MessageModelInterface } from './message';
/**
* Entity definition
*/
export interface EntityInterface<T = MessageInterface> {
'@type': string;
version: number;
provider: string;
service: string;
collection: string | number;
identifier: string | number;
collection: CollectionIdentifier;
identifier: EntityIdentifier;
signature: string | null;
created: string | null;
modified: string | null;
properties: T;
}
export interface EntityModelInterface extends Omit<EntityInterface<MessageModelInterface>, '@type' | 'version'> {}
/**
* Entity list
* Entity list bulk
*/
export interface EntityListRequest {
sources?: SourceSelector;
export interface EntityListBulkRequest {
sources?: CollectionIdentifier[];
filter?: ListFilter;
sort?: ListSort;
range?: ListRange;
}
export interface EntityListResponse {
export interface EntityListBulkResponse {
[providerId: string]: {
[serviceId: string]: {
[collectionId: string]: {
@@ -38,14 +48,23 @@ export interface EntityListResponse {
};
}
/**
* 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 {
provider: string;
service: string | number;
collection: string | number;
identifiers: (string | number)[];
targets: EntityIdentifier[];
}
export interface EntityFetchResponse {
@@ -56,7 +75,7 @@ export interface EntityFetchResponse {
* Entity extant
*/
export interface EntityExtantRequest {
sources: SourceSelector;
targets: EntityIdentifier[];
}
export interface EntityExtantResponse {
@@ -69,50 +88,11 @@ 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: SourceSelector;
sources: CollectionIdentifier[];
}
export interface EntityDeltaResponse {
@@ -128,6 +108,74 @@ 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
*/
@@ -157,4 +205,32 @@ 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> {}

View File

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

View File

@@ -6,25 +6,23 @@
// ==================== Provider Panel Contracts ====================
/**
* Props all provider CONFIG panels receive
* Config panels handle protocol/location settings only
* Props all provider Protocol panels receive
* Protocol panels handle protocol/location settings only
*/
export interface ProviderConfigPanelProps {
export interface ProviderProtocolPanelProps {
/** Current service value for v-model binding */
service?: import('../models').ServiceObject;
/** Pre-filled location from discovery (if available) */
discoveredLocation?: import('./service').ServiceLocation;
/** Current location value for v-model binding */
modelValue?: import('./service').ServiceLocation;
}
/**
* Events all provider CONFIG panels emit
* Config panels emit location configuration and validation state
* Events all provider Protocol panels emit
* Protocol panels emit service configuration and validation state
*/
export interface ProviderConfigPanelEmits {
/** Emit updated location configuration */
'update:modelValue': [value: import('./service').ServiceLocation];
/** Emit validation state (true = valid, false = invalid) */
'valid': [value: boolean];
export interface ProviderProtocolPanelEmits {
/** Emit updated service configuration */
'update:service': [value: import('../models').ServiceObject];
}
/**
@@ -32,6 +30,8 @@ export interface ProviderConfigPanelEmits {
* 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,8 +40,6 @@ 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;
}
/**
@@ -49,10 +47,6 @@ export interface ProviderAuthPanelProps {
* Auth panels emit identity configuration, validation state, and errors
*/
export interface ProviderAuthPanelEmits {
/** Emit updated identity configuration */
'update:modelValue': [value: import('./service').ServiceIdentity];
/** Emit validation state (true = valid, false = invalid) */
'valid': [value: boolean];
/** Emit authentication errors for user feedback */
'error': [error: string];
/** Emit updated service configuration */
'update:service': [value: import('../models').ServiceObject];
}

View File

@@ -1,68 +1,66 @@
/**
* 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;
type?: string | null;
charset?: string | null;
disposition?: string | null;
cid?: string | null;
language?: string | null;
location?: string | null;
content?: string;
content?: string | null;
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;
}>;
}

View File

@@ -11,8 +11,8 @@ export interface ProviderCapabilitiesInterface {
ServiceFetch?: boolean;
ServiceExtant?: boolean;
ServiceCreate?: boolean;
ServiceModify?: boolean;
ServiceDestroy?: boolean;
ServiceUpdate?: boolean;
ServiceDelete?: boolean;
ServiceDiscover?: boolean;
ServiceTest?: boolean;
[key: string]: boolean | object | string[] | undefined;
@@ -23,11 +23,14 @@ 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
*/

View File

@@ -1,7 +1,12 @@
/**
* Service type definitions
*/
import type { SourceSelector, ListFilterComparisonOperator } from './common';
import type { Identity } from '@/models/identity';
import type { Location } from '@/models/location';
import type {
ListFilterComparisonOperator,
SourceSelector,
} from './common';
/**
* Service capabilities
@@ -16,6 +21,7 @@ export interface ServiceCapabilitiesInterface {
CollectionCreate?: boolean;
CollectionUpdate?: boolean;
CollectionDelete?: boolean;
CollectionMove?: boolean;
// Message capabilities
EntityList?: boolean;
EntityListFilter?: ServiceListFilterEntity;
@@ -39,6 +45,7 @@ export interface ServiceCapabilitiesInterface {
*/
export interface ServiceInterface {
'@type': string;
version: number;
provider: string;
identifier: string | number | null;
label: string | null;
@@ -51,6 +58,13 @@ 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
*/
@@ -103,6 +117,7 @@ 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>;
}
@@ -129,7 +144,20 @@ export interface ServiceDiscoverRequest {
}
export interface ServiceDiscoverResponse {
[provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union
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;
}
/**

View File

@@ -1,30 +0,0 @@
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)
})
})

View File

@@ -1,33 +0,0 @@
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/**',
],
},
},
})

View File

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

View File

@@ -16,5 +16,5 @@
"@MailManager/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

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