69 Commits

Author SHA1 Message Date
Sebastian f5e6820f9e chore(deps): update dependency vuetify to v4.1.8
Build Test / test (pull_request) Successful in 52s
JS Unit Tests / test (pull_request) Successful in 52s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m16s
PHP Unit Tests / test (pull_request) Successful in 2m17s
2026-08-08 03:03:49 +00:00
Sebastian 0d5b13c6d2 refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:52:29 -04:00
Sebastian 2521e9bc9d Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#65) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #65
2026-07-25 23:53:08 +00:00
Sebastian 282c82b2c9 Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#66) from renovate/vite-8.x-lockfile into main
Reviewed-on: #66
2026-07-25 23:53:01 +00:00
Sebastian f413dc7576 chore(deps): update dependency vite to v8.1.5
Build Test / test (pull_request) Successful in 58s
JS Unit Tests / test (pull_request) Successful in 58s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m38s
PHP Unit Tests / test (pull_request) Successful in 1m35s
2026-07-25 03:03:48 +00:00
Sebastian cffec87011 chore(deps): update dependency @vitejs/plugin-vue to v6.0.8
Build Test / test (pull_request) Successful in 48s
JS Unit Tests / test (pull_request) Successful in 53s
PHP Unit Tests / test (pull_request) Successful in 1m43s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m49s
2026-07-25 03:03:45 +00:00
Sebastian bdb09ceb96 Merge pull request 'chore: implement js test suites' (#64) from chore/js-test-suites into main
Reviewed-on: #64
2026-07-24 04:46:40 +00:00
Sebastian 0780f0b7d9 fix: update vue-router to 5.2.0 for pinia 4 peer dep support
Build Test / test (pull_request) Successful in 45s
JS Unit Tests / test (pull_request) Successful in 46s
PHP Unit Tests / test (pull_request) Successful in 1m14s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m28s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:42:42 -04:00
Sebastian 2cd5cbca12 chore: implement js test suites
JS Unit Tests / test (pull_request) Failing after 39s
Build Test / test (pull_request) Failing after 42s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m13s
PHP Unit Tests / test (pull_request) Successful in 1m42s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:32:42 -04:00
Sebastian 2b8e8598fe Merge pull request 'fix(deps): update dependency pinia to v4' (#62) from renovate/pinia-4.x into main
Reviewed-on: #62
2026-07-24 03:29:00 +00:00
Sebastian a30837a130 Merge pull request 'chore(deps): update mongo docker tag to v8' (#63) from renovate/mongo-8.x into main
Reviewed-on: #63
2026-07-24 03:28:51 +00:00
Sebastian 0a43bb8539 fix(deps): update dependency pinia to v4
JS Unit Tests / test (pull_request) Failing after 36s
Build Test / test (pull_request) Successful in 41s
PHP Integration Tests / Integration Tests (pull_request) Successful in 51s
PHP Unit Tests / test (pull_request) Successful in 54s
2026-07-24 03:24:21 +00:00
Sebastian c84924a5a1 chore(deps): update mongo docker tag to v8
Build Test / test (pull_request) Failing after 44s
JS Unit Tests / test (pull_request) Failing after 42s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m24s
PHP Unit Tests / test (pull_request) Successful in 1m40s
2026-07-24 03:24:18 +00:00
Sebastian 4d7844e90a Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#55) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #55
2026-07-24 03:14:21 +00:00
Sebastian e08f37b2cc Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#57) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #57
2026-07-24 03:14:12 +00:00
Sebastian e8495800e6 Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#58) from renovate/vite-8.x-lockfile into main
Reviewed-on: #58
2026-07-24 03:14:05 +00:00
Sebastian cbcb6d1dcb Merge pull request 'chore(deps): update dependency vue to v3.5.40' (#59) from renovate/vue-monorepo into main
Reviewed-on: #59
2026-07-24 03:12:42 +00:00
Sebastian 3d40bfdf76 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#60) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #60
2026-07-24 03:12:35 +00:00
Sebastian a3cfb4f40d Merge pull request 'chore(deps): update dependency typescript to v7' (#54) from renovate/typescript-7.x into main
Reviewed-on: #54
2026-07-24 03:12:19 +00:00
Sebastian 27ddd22a2d Merge pull request 'chore(deps): update vitest monorepo to v4.1.10' (#52) from renovate/vitest-monorepo into main
Reviewed-on: #52
2026-07-24 03:12:04 +00:00
Sebastian 8440a3daec Merge pull request 'chore(deps): update dependency vue-router to v5.2.0' (#61) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #61
2026-07-24 03:11:56 +00:00
Sebastian 39ab6cf745 chore(deps): update dependency typescript to v7
Build Test / test (pull_request) Successful in 39s
JS Unit Tests / test (pull_request) Failing after 37s
PHP Unit Tests / test (pull_request) Successful in 1m34s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m45s
2026-07-24 02:55:38 +00:00
Sebastian 0b71c51dd5 chore(deps): update dependency vue-router to v5.2.0
JS Unit Tests / test (pull_request) Failing after 43s
Build Test / test (pull_request) Successful in 44s
PHP Unit Tests / test (pull_request) Successful in 1m29s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m51s
2026-07-24 02:55:33 +00:00
Sebastian 6094dd4cd1 chore(deps): update vitest monorepo to v4.1.10
Build Test / test (pull_request) Successful in 53s
JS Unit Tests / test (pull_request) Failing after 54s
PHP Unit Tests / test (pull_request) Successful in 1m36s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m32s
2026-07-24 02:55:28 +00:00
Sebastian ee74991175 chore(deps): update dependency vuetify to v4.1.6
Build Test / test (pull_request) Successful in 47s
JS Unit Tests / test (pull_request) Failing after 38s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m36s
PHP Unit Tests / test (pull_request) Successful in 1m57s
2026-07-24 02:55:23 +00:00
Sebastian f7596976da chore(deps): update dependency vue-tsc to v3.3.8
Build Test / test (pull_request) Successful in 42s
JS Unit Tests / test (pull_request) Failing after 42s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m57s
PHP Unit Tests / test (pull_request) Successful in 2m6s
2026-07-24 02:55:20 +00:00
Sebastian b0f388161a chore(deps): update dependency vue to v3.5.40
Build Test / test (pull_request) Failing after 43s
JS Unit Tests / test (pull_request) Failing after 44s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m37s
PHP Unit Tests / test (pull_request) Successful in 1m35s
2026-07-24 02:55:17 +00:00
Sebastian df1ed31ef9 chore(deps): update dependency vite to v8.1.5
Build Test / test (pull_request) Successful in 51s
JS Unit Tests / test (pull_request) Failing after 54s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m27s
PHP Unit Tests / test (pull_request) Successful in 2m5s
2026-07-24 02:55:14 +00:00
Sebastian 0b8c50b93f chore(deps): update dependency @vitejs/plugin-vue to v6.0.8
JS Unit Tests / test (pull_request) Failing after 57s
Build Test / test (pull_request) Successful in 58s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m4s
PHP Unit Tests / test (pull_request) Successful in 2m24s
2026-07-24 02:55:12 +00:00
Sebastian 6061be119c Merge pull request 'chore: implement php test suites' (#56) from chore/php83-test-suites into main
Reviewed-on: #56
2026-07-24 02:43:42 +00:00
Sebastian 69fc2054d8 fix: install module dependencies before running integration tests
Build Test / test (pull_request) Successful in 28s
JS Unit Tests / test (pull_request) Failing after 29s
PHP Unit Tests / test (pull_request) Successful in 1m11s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m55s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:32:06 -04:00
Sebastian cbc2fcd7c7 chore: implement php test suites
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 1m3s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m4s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:20:36 -04:00
Sebastian c6339ad084 fix: automatically enable discovered account
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 18:20:03 -04:00
Sebastian c736e3ebf9 feat: create system user accounts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 06:55:01 -04:00
Sebastian 681e059870 refactor: rename main entry point
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-14 17:40:17 -04:00
Sebastian fcc8f924d3 fix(renovate): install renovate in runner temp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:12:07 -04:00
Sebastian c2de0aa7c0 Merge pull request 'chore(deps): update dependency vite to v8.1.4' (#47) from renovate/vite-8.x-lockfile into main
Reviewed-on: #47
2026-07-09 23:48:46 +00:00
Sebastian 9c3ab2e65d chore(deps): update dependency vite to v8.1.4
PHP Unit Tests / test (pull_request) Failing after 13m21s
JS Unit Tests / test (pull_request) Failing after 13m23s
Build Test / test (pull_request) Failing after 13m25s
2026-07-09 23:48:17 +00:00
Sebastian 0f0719f82f Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.7' (#46) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #46
2026-07-09 23:45:16 +00:00
Sebastian cdf9d87adc Merge pull request 'chore(deps): update dependency vue to v3.5.39' (#48) from renovate/vue-monorepo into main
Reviewed-on: #48
2026-07-09 23:45:03 +00:00
Sebastian d9f61437a4 Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#49) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #49
2026-07-09 23:44:55 +00:00
Sebastian db3f35b8f7 Merge pull request 'chore(deps): update dependency vuetify to v4.1.4' (#50) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #50
2026-07-09 23:44:50 +00:00
Sebastian 9399c06ec1 chore(deps): update dependency vuetify to v4.1.4
PHP Unit Tests / test (pull_request) Failing after 11m6s
JS Unit Tests / test (pull_request) Failing after 11m9s
Build Test / test (pull_request) Failing after 11m33s
2026-07-09 03:05:45 +00:00
Sebastian 81027d5914 chore(deps): update dependency vue-router to v5.1.0
PHP Unit Tests / test (pull_request) Failing after 11m40s
JS Unit Tests / test (pull_request) Failing after 11m42s
Build Test / test (pull_request) Failing after 11m57s
2026-07-09 03:05:42 +00:00
Sebastian 2b51266392 chore(deps): update dependency vue-tsc to v3.3.7
PHP Unit Tests / test (pull_request) Failing after 12m35s
JS Unit Tests / test (pull_request) Failing after 13m1s
Build Test / test (pull_request) Failing after 13m12s
2026-07-09 03:05:30 +00:00
Sebastian 400e9d8806 chore(deps): update dependency vue to v3.5.39
PHP Unit Tests / test (pull_request) Failing after 13m29s
JS Unit Tests / test (pull_request) Failing after 13m57s
Build Test / test (pull_request) Failing after 14m2s
2026-07-09 03:05:27 +00:00
Sebastian 8d40dc8242 recator: range construction
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 21:40:13 -04:00
Sebastian 1d38f6bf16 fix: use seen insstead of read flag
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:38:47 -04:00
Sebastian 556ff7d708 refactor: docs and styling
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 19:01:51 -04:00
Sebastian 3aef72b05c refactor: mail manager changes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 00:50:34 -04:00
Sebastian dfe308c046 refactor: remove sources selector from frontend
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 00:00:28 -04:00
Sebastian 9845262e62 refactor: remove sources selector in backend
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-19 23:56:46 -04:00
Sebastian af227f35f9 refactor: parameter name and function location
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-19 20:00:31 -04:00
Sebastian cd2c1570e3 Merge pull request 'feat: blobs fetch' (#45) from feat/blobs into main
Reviewed-on: #45
2026-06-18 23:39:45 +00:00
Sebastian bb349646c3 feat: blobs fetch
PHP Unit Tests / test (pull_request) Failing after 11m59s
JS Unit Tests / test (pull_request) Failing after 12m1s
Build Test / test (pull_request) Failing after 12m3s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-18 19:38:11 -04:00
Sebastian 61f4fef462 Merge pull request 'chore(deps): update vitest monorepo to v4.1.9' (#43) from renovate/vitest-monorepo into main
Reviewed-on: #43
2026-06-18 23:09:18 +00:00
Sebastian 1844a0f337 Merge pull request 'chore(deps): update vue monorepo to v3.5.38' (#44) from renovate/vue-monorepo into main
Reviewed-on: #44
2026-06-18 23:09:11 +00:00
Sebastian 48381f594b chore(deps): update vue monorepo to v3.5.38
PHP Unit Tests / test (pull_request) Failing after 13m50s
JS Unit Tests / test (pull_request) Failing after 14m7s
Build Test / test (pull_request) Failing after 14m24s
2026-06-18 03:05:11 +00:00
Sebastian 0570d8d9cf chore(deps): update vitest monorepo to v4.1.9
PHP Unit Tests / test (pull_request) Failing after 14m33s
JS Unit Tests / test (pull_request) Failing after 14m37s
Build Test / test (pull_request) Failing after 14m39s
2026-06-18 03:05:08 +00:00
Sebastian 105f69c3a5 Merge pull request 'chore(deps): update dependency vite to v8.0.16' (#38) from renovate/vite-8.x-lockfile into main
Reviewed-on: #38
2026-06-16 17:44:35 +00:00
Sebastian 40347b8b0e Merge pull request 'chore(deps): update vitest monorepo to v4.1.8' (#39) from renovate/vitest-monorepo into main
Reviewed-on: #39
2026-06-16 17:44:28 +00:00
Sebastian c33bfb50c2 Merge pull request 'chore(deps): update vue monorepo to v3.5.35' (#40) from renovate/vue-monorepo into main
Reviewed-on: #40
2026-06-16 17:44:19 +00:00
Sebastian 5d15b291ce Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.4' (#41) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #41
2026-06-16 17:44:13 +00:00
Sebastian ba0ba8bffc Merge pull request 'refactor: service address' (#42) from refactor/service-address into main
Reviewed-on: #42
2026-06-16 17:32:43 +00:00
Sebastian bea7c1d0fb refactor: service address
Build Test / test (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Failing after 37s
PHP Unit Tests / test (pull_request) Successful in 59s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 13:31:23 -04:00
Sebastian 9159463efd chore(deps): update vue-language-tools monorepo to v3.3.4
JS Unit Tests / test (pull_request) Failing after 38s
Build Test / test (pull_request) Successful in 42s
PHP Unit Tests / test (pull_request) Successful in 1m7s
2026-06-11 03:05:18 +00:00
Sebastian 06fada2c3e chore(deps): update vue monorepo to v3.5.35
Build Test / test (pull_request) Successful in 41s
JS Unit Tests / test (pull_request) Failing after 35s
PHP Unit Tests / test (pull_request) Successful in 2m53s
2026-06-11 03:05:16 +00:00
Sebastian 57f2083089 chore(deps): update dependency vite to v8.0.16
Build Test / test (pull_request) Successful in 43s
JS Unit Tests / test (pull_request) Failing after 41s
PHP Unit Tests / test (pull_request) Successful in 1m43s
2026-06-11 03:05:06 +00:00
Sebastian 6e79ae9d86 chore(deps): update vitest monorepo to v4.1.8
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 1m10s
2026-06-07 03:04:33 +00:00
38 changed files with 2595 additions and 1644 deletions
@@ -0,0 +1,59 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install server
uses: ./action-server-install
with:
install-php: 'true'
php-version: '8.5'
server-path: './server'
database-uri: 'mongodb://mongo:27017/?tls=false'
database-name: 'ktrix_ci'
app-environment: 'test'
- name: Checkout module under test
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Install module dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/mail_manager
- name: Install and enable module
working-directory: server
run: |
php bin/console module:install mail_manager
php bin/console module:enable mail_manager
- name: Run integration tests
working-directory: server/modules/mail_manager
run: composer test:integration
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: npm install -g renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }}
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+1 -5
View File
@@ -14,11 +14,7 @@ node_modules/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
*.cache
.phpactor/
# Editors
+6 -5
View File
@@ -10,16 +10,16 @@
"config": {
"optimize-autoloader": true,
"platform": {
"php": "8.2"
"php": "8.3"
},
"autoloader-suffix": "MailManager",
"vendor-dir": "lib/vendor"
},
"require": {
"php": ">=8.2 <=8.5"
"php": ">=8.3 <=8.5"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
@@ -36,7 +36,8 @@
],
"post-update-cmd": [
],
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
}
}
Generated
+252 -352
View File
File diff suppressed because it is too large Load Diff
+190 -177
View File
@@ -14,8 +14,8 @@ use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedNdJsonResponse;
use KTXC\Http\Response\StreamedResponse;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Json\JsonSerializable;
use KTXF\Resource\Identifier\CollectionIdentifier;
@@ -23,10 +23,8 @@ use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Identifier\ServiceIdentifier;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Selector\CollectionSelector;
use KTXF\Resource\Selector\ServiceSelector;
use KTXF\Resource\Selector\SourceSelector;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\MailManager\Manager;
use Psr\Log\LoggerInterface;
@@ -41,6 +39,7 @@ class DefaultController extends ControllerAbstract {
private const ERR_MISSING_SOURCES = 'Missing parameter: sources';
private const ERR_MISSING_TARGET = 'Missing parameter: target';
private const ERR_MISSING_TARGETS = 'Missing parameter: targets';
private const ERR_MISSING_SENDER = 'Missing parameter: sender';
private const ERR_INVALID_OPERATION = 'Invalid operation: ';
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string';
private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string';
@@ -48,14 +47,13 @@ class DefaultController extends ControllerAbstract {
private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array';
private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array';
private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array';
private const ERR_INVALID_SENDER = 'Invalid parameter: sender must be a string';
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
private const STREAM_FLUSH_INTERVAL = 1;
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private Manager $mailManager,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private Manager $manager,
private readonly LoggerInterface $logger
) {}
@@ -82,8 +80,26 @@ class DefaultController extends ControllerAbstract {
): Response {
// authorize request
$tenantId = $this->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
// acting-user override: only the reserved system context is permitted,
// gated on the system mail management permission
if ($user !== null && $user !== $userId) {
if ($user !== ProviderBaseInterface::USER_SYSTEM || !$this->identityContext->hasPermission('mail_manager.system')) {
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'error',
'data' => [
'code' => JsonResponse::HTTP_FORBIDDEN,
'message' => 'Not permitted to act as user: ' . $user
]
], JsonResponse::HTTP_FORBIDDEN);
}
$userId = $user;
}
try {
@@ -162,8 +178,9 @@ class DefaultController extends ControllerAbstract {
'entity.patch' => $this->entityPatch($tenantId, $userId, $data),
'entity.move' => $this->entityMove($tenantId, $userId, $data),
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
'entity.submit' => $this->entitySubmit($tenantId, $userId, $data),
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
'entity.blobs' => $this->entityBlobs($tenantId, $userId, $data),
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
};
@@ -173,40 +190,48 @@ class DefaultController extends ControllerAbstract {
private function providerList(string $tenantId, string $userId, array $data): mixed {
$sources = null;
if (isset($data['sources']) && is_array($data['sources'])) {
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
}
if (isset($data['targets'])) {
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
foreach ($data['targets'] as $target) {
if (!is_string($target)) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
}
}
return $this->mailManager->providerList($tenantId, $userId, $sources);
return $this->manager->providerList($tenantId, $userId, $data['targets'] ?? []);
}
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
return $this->mailManager->providerFetch($tenantId, $userId, $data['identifier']);
return $this->manager->providerFetch($tenantId, $userId, $data['target']);
}
private function providerExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
foreach ($data['targets'] as $target) {
if (!is_string($target)) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
}
return $this->mailManager->providerExtant($tenantId, $userId, $sources);
return $this->manager->providerExtant($tenantId, $userId, $data['targets']);
}
@@ -214,13 +239,17 @@ class DefaultController extends ControllerAbstract {
private function serviceList(string $tenantId, string $userId, array $data): mixed {
$sources = null;
if (isset($data['sources']) && is_array($data['sources'])) {
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
$targets = null;
if (isset($data['targets']) && is_array($data['targets'])) {
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof CollectionIdentifier && !$target instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
}
return $this->mailManager->serviceList($tenantId, $userId, $sources);
return $this->manager->serviceList($tenantId, $userId, $targets);
}
@@ -239,21 +268,25 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
return $this->manager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
}
private function serviceExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
return $this->mailManager->serviceExtant($tenantId, $userId, $sources);
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service identifiers');
}
}
return $this->manager->serviceExtant($tenantId, $userId, $targets);
}
private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
@@ -270,7 +303,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
return $this->mailManager->serviceCreate(
return $this->manager->serviceCreate(
$tenantId,
$userId,
$data['provider'],
@@ -301,7 +334,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Invalid parameter: delta must be a boolean');
}
return $this->mailManager->serviceUpdate(
return $this->manager->serviceUpdate(
$tenantId,
$userId,
$data['provider'],
@@ -325,7 +358,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
return $this->mailManager->serviceDelete(
return $this->manager->serviceDelete(
$tenantId,
$userId,
$data['provider'],
@@ -346,7 +379,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test');
}
return $this->mailManager->serviceTest(
return $this->manager->serviceTest(
$tenantId,
$userId,
$data['provider'],
@@ -367,7 +400,7 @@ class DefaultController extends ControllerAbstract {
$location = $data['location'] ?? null;
$secret = $data['secret'] ?? null;
$discoverGenerator = $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
$discoverGenerator = $this->manager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
$logger = $this->logger;
$response = (function () use ($discoverGenerator, $version, $transaction, $logger): \Generator {
@@ -405,20 +438,18 @@ class DefaultController extends ControllerAbstract {
private function collectionList(string $tenantId, string $userId, array $data): mixed {
$sources = null;
if (isset($data['sources']) && is_array($data['sources'])) {
// TODO: Refactor to use identifiers directly
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof CollectionIdentifier && !$source instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
$sources = $this->createSourceSelectorFromIdentifiers($sources);
}
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
return $this->mailManager->collectionList($tenantId, $userId, $sources, $filter, $sort);
return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort);
}
private function collectionFetch(string $tenantId, string $userId, array $data): mixed {
@@ -429,7 +460,6 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
// TODO: Refactor to use identifiers directly
$targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targetIdentifiers as $targetIdentifier) {
if (!$targetIdentifier instanceof CollectionIdentifier) {
@@ -437,16 +467,11 @@ class DefaultController extends ControllerAbstract {
}
}
$list = [];
foreach ($targetIdentifiers as $targetIdentifier) {
$list[(string)$targetIdentifier] = $this->mailManager->collectionFetch(
$tenantId,
$userId,
$targetIdentifier->provider(),
$targetIdentifier->service(),
$targetIdentifier->collection()
);
}
$list = $this->manager->collectionFetch(
$tenantId,
$userId,
$targetIdentifier
);
return $list;
}
@@ -458,16 +483,14 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
// TODO: Refactor to use identifiers directly
$sources = ResourceIdentifiers::fromArray($data['targets']);
foreach ($sources as $source) {
if (!$source instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
$sources = $this->createSourceSelectorFromIdentifiers($sources);
return $this->mailManager->collectionExtant($tenantId, $userId, $sources);
return $this->manager->collectionExtant($tenantId, $userId, $sources);
}
private function collectionCreate(string $tenantId, string $userId, array $data): mixed {
@@ -500,7 +523,7 @@ class DefaultController extends ControllerAbstract {
}
}
return $this->mailManager->collectionCreate(
return $this->manager->collectionCreate(
$tenantId,
$userId,
$data['provider'],
@@ -529,7 +552,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
return $this->mailManager->collectionUpdate(
return $this->manager->collectionUpdate(
$tenantId,
$userId,
$targetIdentifier,
@@ -550,7 +573,7 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
$result = $this->mailManager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] );
$result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] );
if (is_bool($result)) {
return [
@@ -593,7 +616,7 @@ class DefaultController extends ControllerAbstract {
}
return $this->mailManager->collectionMove($tenantId, $userId, $target, $source);
return $this->manager->collectionMove($tenantId, $userId, $target, $source);
}
// ==================== Entity Operations ====================
@@ -612,14 +635,12 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
$sources = $this->createSourceSelectorFromIdentifiers($sources);
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
$range = $data['range'] ?? null;
return $this->mailManager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range);
return $this->manager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range);
}
@@ -638,13 +659,11 @@ class DefaultController extends ControllerAbstract {
}
}
$sources = $this->createSourceSelectorFromIdentifiers($sources);
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
$range = $data['range'] ?? null;
$entityGenerator = $this->mailManager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range);
$entityGenerator = $this->manager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range);
$logger = $this->logger;
$responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator {
@@ -674,50 +693,6 @@ class DefaultController extends ControllerAbstract {
return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']);
}
private function createSourceSelectorFromIdentifiers(ResourceIdentifiers $identifiers): SourceSelector {
$sources = new SourceSelector();
foreach ($identifiers as $identifier) {
if (!$identifier instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
$provider = $identifier->provider();
$service = $identifier->service();
if (!isset($sources[$provider])) {
$sources[$provider] = new ServiceSelector();
}
$serviceSelector = $sources[$provider];
if (!$serviceSelector instanceof ServiceSelector) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection selectors');
}
if ($identifier instanceof ServiceIdentifier && !$identifier instanceof CollectionIdentifier) {
$serviceSelector[$service] = true;
continue;
}
if (isset($serviceSelector[$service]) && $serviceSelector[$service] === true) {
continue;
}
if (!isset($serviceSelector[$service])) {
$serviceSelector[$service] = new CollectionSelector();
}
$collectionSelector = $serviceSelector[$service];
if (!$collectionSelector instanceof CollectionSelector) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection selectors');
}
$collectionSelector[$identifier->collection()] = true;
}
return $sources;
}
private function entityFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
@@ -733,7 +708,7 @@ class DefaultController extends ControllerAbstract {
}
}
return $this->mailManager->entityFetchBulk(
return $this->manager->entityFetchBulk(
$tenantId,
$userId,
...$targets->all()
@@ -741,49 +716,39 @@ class DefaultController extends ControllerAbstract {
}
private function entityExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
return $this->mailManager->entityExtant($tenantId, $userId, $sources);
}
private function entityDelta(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$sources = new SourceSelector();
$sources->jsonDeserialize($data['sources']);
return $this->mailManager->entityDelta($tenantId, $userId, $sources);
}
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers');
if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:entity identifiers');
}
}
return $this->manager->entityExtant($tenantId, $userId, $targets);
}
private function entityDelta(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
return $this->mailManager->entityDelete($tenantId, $userId, ...$targets->all());
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:signature identifiers');
}
}
return $this->manager->entityDelta($tenantId, $userId, $targets);
}
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
@@ -807,7 +772,25 @@ class DefaultController extends ControllerAbstract {
}
}
return $this->mailManager->entityPatch($tenantId, $userId, $data['properties'], ...$targets->all());
return $this->manager->entityPatch($tenantId, $userId, $data['properties'], ...$targets->all());
}
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers');
}
}
return $this->manager->entityDelete($tenantId, $userId, ...$targets->all());
}
private function entityMove(string $tenantId, string $userId, array $data): mixed {
@@ -836,32 +819,24 @@ class DefaultController extends ControllerAbstract {
}
}
return $this->mailManager->entityMove($tenantId, $userId, $target, ...$sources->all());
return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all());
}
private function entityTransmit(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
private function entitySubmit(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sender'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SENDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
if (!is_string($data['sender'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SENDER);
}
if (!isset($data['service'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
}
if (!is_string($data['service'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
}
$jobId = $this->mailManager->entityTransmit(
return $this->manager->entitySubmit(
$tenantId,
$userId,
$data['provider'],
$data['service'],
$data['data']
$data['sender'],
$data['source'] ?? null,
$data['message'] ?? null,
);
return ['jobId' => $jobId];
}
private function entityDownload(string $tenantId, string $userId, array $data): mixed {
@@ -878,7 +853,7 @@ class DefaultController extends ControllerAbstract {
$target = ResourceIdentifier::fromString($data['target']);
$logger = $this->logger;
$result = $this->mailManager->entityDownload($tenantId, $userId, $target, $part);
$result = $this->manager->entityDownload($tenantId, $userId, $target, $part);
$filename = $result->filename();
$asciiFilename = preg_replace('/[^\x20-\x7E]|[\\\\"]/', '_', $filename);
@@ -905,5 +880,43 @@ class DefaultController extends ControllerAbstract {
'Cache-Control' => 'no-store',
]);
}
private function entityBlobs(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
if (!isset($data['parts']) || !is_array($data['parts']) || $data['parts'] === []) {
throw new InvalidArgumentException('At least one part selector is required');
}
$target = ResourceIdentifier::fromString($data['target']);
$results = [];
foreach ($data['parts'] as $part) {
if (!is_array($part)) {
throw new InvalidArgumentException('Invalid part selector');
}
$resource = $this->manager->entityDownload($tenantId, $userId, $target, $part);
$bytes = '';
foreach ($resource->stream() as $chunk) {
$bytes .= $chunk;
}
$results[] = [
'source' => $data['target'],
'part' => $part,
'mime' => $resource->mimeType(),
'filename' => $resource->filename(),
'bytes' => base64_encode($bytes),
];
}
return $results;
}
}
+212 -220
View File
@@ -10,6 +10,8 @@ use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionPropertiesMutableInterface;
use KTXF\Mail\Collection\ICollectionBase;
use KTXF\Mail\Entity\IMessageBase;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Provider\ProviderServiceDiscoverInterface;
@@ -19,20 +21,18 @@ use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Selector\CollectionSelector;
use KTXF\Resource\Selector\EntitySelector;
use KTXF\Resource\Selector\ServiceSelector;
use KTXF\Resource\Selector\SourceSelector;
use KTXF\Resource\Sort\ISort;
use Psr\Log\LoggerInterface;
@@ -51,15 +51,13 @@ class Manager {
/**
* Retrieve available providers
*
* @param SourceSelector|null $sources collection of provider identifiers
* @param ResourceIdentifiers|null $sources collection of provider identifiers
*
* @return array<string,ProviderBaseInterface> collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider]
*/
public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array {
// determine filter from sources
$filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null;
public function providerList(string $tenantId, string $userId, array|null $targets = []): array {
// retrieve providers from provider manager
return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter);
return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $targets ?: null);
}
/**
@@ -72,27 +70,27 @@ class Manager {
* @return ProviderBaseInterface
* @throws InvalidArgumentException
*/
public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface {
public function providerFetch(string $tenantId, string $userId, string $target): ProviderBaseInterface {
// retrieve provider
$providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true]));
if (!isset($providers[$provider])) {
throw new InvalidArgumentException("Provider '$provider' not found");
$providers = $this->providerList($tenantId, $userId, [$target]);
if (!isset($providers[$target])) {
throw new InvalidArgumentException("Provider '$target' not found");
}
return $providers[$provider];
return $providers[$target];
}
/**
* Confirm which providers are available
*
* @param SourceSelector|null $sources collection of provider identifiers to confirm
*
* @param array<string> $targets collection of provider identifiers to confirm
*
* @return array<string,bool> collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false]
*/
public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array {
public function providerExtant(string $tenantId, string $userId, array $targets): array {
// determine which providers are available
$providersResolved = $this->providerList($tenantId, $userId, $sources);
$providersResolved = $this->providerList($tenantId, $userId, $targets);
$providersAvailable = array_keys($providersResolved);
$providersUnavailable = array_diff($sources->identifiers(), $providersAvailable);
$providersUnavailable = array_diff($targets, $providersAvailable);
// construct response data
$responseData = array_merge(
array_fill_keys($providersAvailable, true),
@@ -106,18 +104,24 @@ class Manager {
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param SourceSelector|null $sources list of provider and service identifiers
* @param ResourceIdentifiers|null $targets list of provider and service identifiers
*
* @return array<string,<string,ServiceBaseInterface>> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]]
*/
public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array {
public function serviceList(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null): array {
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
$providerFilter = $targets !== null ? $targets->providers() : null;
$providers = $this->providerList($tenantId, $userId, $providerFilter);
// retrieve services for each provider
$responseData = [];
foreach ($providers as $provider) {
$serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : [];
$services = $provider->serviceList($tenantId, $userId, $serviceFilter);
if ($targets !== null) {
$servicesSelected = $targets?->byProvider($provider->identifier()) ?: [];
$servicesFilter = $servicesSelected->services();
} else {
$servicesFilter = [];
}
$services = $provider->serviceList($tenantId, $userId, $servicesFilter);
$responseData[$provider->identifier()] = $services;
}
return $responseData;
@@ -149,24 +153,23 @@ class Manager {
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param SourceSelector|null $sources collection of provider and service identifiers to confirm
*
* @param ResourceIdentifiers $targets collection of provider and service identifiers to confirm
*
* @return array<string,bool> collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]]
*/
public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array {
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
$providersRequested = $sources->identifiers();
public function serviceExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array {
// retrieve available providers
$providersRequested = $targets->providers();
$providers = $this->providerList($tenantId, $userId, $providersRequested);
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
// initialize response with unavailable providers
$responseData = array_fill_keys($providersUnavailable, false);
// retrieve services for each available provider
foreach ($providers as $provider) {
$serviceSelector = $sources[$provider->identifier()];
$serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers());
$responseData[$provider->identifier()] = $serviceAvailability;
foreach ($providers as $providerId => $provider) {
$servicesRequested = $targets->byProvider($providerId)->services();
$responseData[$providerId] = $provider->serviceExtant($tenantId, $userId, ...$servicesRequested);
}
return $responseData;
}
@@ -233,7 +236,14 @@ class Manager {
$serviceId = $provider->serviceCreate($tenantId, $userId, $service);
// Fetch and return the created service
return $provider->serviceFetch($tenantId, $userId, $serviceId);
$createdService = $provider->serviceFetch($tenantId, $userId, $serviceId);
if ($createdService === null) {
throw new \RuntimeException(
"Provider '$providerId' created service '$serviceId', but it could not be fetched"
);
}
return $createdService;
}
/**
@@ -337,7 +347,7 @@ class Manager {
string|null $location = null,
string|null $secret = null
): \Generator {
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null);
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? [$providerId] : null);
foreach ($providers as $currentProviderId => $provider) {
if ($provider instanceof ProviderServiceDiscoverInterface === false) {
@@ -454,26 +464,23 @@ class Manager {
*
* @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context
* @param SourceSelector|null $sources Provider/service sources
* @param ResourceIdentifiers|null $targets Provider/service sources
* @param IFilter|null $filter Collection filter
* @param ISort|null $sort Collection sort
*
* @return array<string, array<string|int, array<string|int, ICollectionBase>>> Collections grouped by provider/service
*/
public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array {
public function collectionList(string $tenantId, ?string $userId, ?ResourceIdentifiers $targets = null, ?IFilter $filter = null, ?ISort $sort = null): array {
// confirm that sources are provided
if ($sources === null) {
$sources = new SourceSelector([]);
if ($targets === null) {
$targets = new ResourceIdentifiers([]);
}
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// retrieve services for each provider
$responseData = [];
foreach ($providers as $provider) {
$serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : [];
/** @var ServiceBaseInterface[] $services */
$services = $provider->serviceList($tenantId, $userId, $serviceFilter);
// retrieve collections for each service
// retrieve collections for each service
foreach ($aggregateServices as $services) {
foreach ($services as $service) {
if ($service->getEnabled() === false) {
continue;
@@ -494,67 +501,18 @@ class Manager {
$collectionSort->condition($attribute, $direction);
}
}
$collections = $service->collectionList('', $collectionFilter, $collectionSort);
if ($collections !== []) {
$responseData[$provider->identifier()][$service->identifier()] = $collections;
}
}
}
return $responseData;
}
/**
* Check if collections exist
*
* @since 2025.05.01
*
* @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context
* @param SourceSelector $sources Collection sources with identifiers
*
* @return array<string, array<string|int, array<string|int, bool>>> Existence map grouped by provider/service
*/
public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array {
// retrieve available providers
$providers = $this->providerList($tenantId, $userId, $sources);
$providersRequested = $sources->identifiers();
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
// initialize response with unavailable providers
$responseData = array_fill_keys($providersUnavailable, false);
// check services and collections for each available provider
foreach ($providers as $provider) {
// extract services for this provider
$serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector->identifiers();
/** @var ServiceBaseInterface[] $servicesAvailable */
$servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested);
$servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable));
// mark unavailable services as false
if ($servicesUnavailable !== []) {
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
}
// confirm collections for each available service
foreach ($servicesAvailable as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
continue;
$collectionIdentifiers = $targets->byProvider($service->provider())->byService($service->identifier())->collections();
if ($collectionIdentifiers !== []) {
foreach ($collectionIdentifiers as $collectionIdentifier) {
$collections = array_merge($collections ?? [], $service->collectionList($collectionIdentifier, $collectionFilter, $collectionSort));
}
} else {
$collections = $service->collectionList('', $collectionFilter, $collectionSort);
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
if ($collectionsRequested === []) {
continue;
if ($collections !== []) {
$responseData[$service->provider()][$service->identifier()] = $collections;
}
// check each requested collection
$collectionsAvailable = $service->collectionExtant(...$collectionsRequested);
$collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable));
$responseData[$provider->identifier()][$service->identifier()] = array_merge(
$collectionsAvailable,
array_fill_keys($collectionsUnavailable, false)
);
}
}
return $responseData;
@@ -573,14 +531,62 @@ class Manager {
*
* @return CollectionBaseInterface|null
*/
public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface {
public function collectionFetch(string $tenantId, ?string $userId, CollectionIdentifier $target): ?CollectionBaseInterface {
// retrieve service
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
if ($service === null || $service->getEnabled() === false) {
return null;
}
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// retrieve collection
return $service->collectionFetch($collectionId);
return $service->collectionFetch($target->collection());
}
/**
* Check if collections exist
*
* @since 2025.05.01
*
* @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context
* @param ResourceIdentifiers $targets Collection sources with identifiers
*
* @return array<string, array<string|int, array<string|int, bool>>> Existence map grouped by provider/service
*/
public function collectionExtant(string $tenantId, ?string $userId, ResourceIdentifiers $targets): array {
// retrieve available services grouped by provider
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// initialize response with unavailable providers marked as false
$providersRequested = $targets->providers();
$providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices));
$responseData = array_fill_keys($providersUnavailable, false);
// check services and collections for each available provider
foreach ($aggregateServices as $providerId => $services) {
// mark unavailable services as false
$servicesRequested = $targets->byProvider($providerId)->services();
$servicesUnavailable = array_diff($servicesRequested, array_keys($services));
if ($servicesUnavailable !== []) {
$responseData[$providerId] = array_fill_keys($servicesUnavailable, false);
}
// confirm collections for each available service
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$providerId][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionsRequested = $targets->byProvider($providerId)->byService($service->identifier())->collections();
if ($collectionsRequested === []) {
continue;
}
// check each requested collection
$collectionsAvailable = $service->collectionExtant(...$collectionsRequested);
$collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable));
$responseData[$providerId][$service->identifier()] = array_merge(
$collectionsAvailable,
array_fill_keys($collectionsUnavailable, false)
);
}
}
return $responseData;
}
/**
@@ -725,31 +731,31 @@ class Manager {
*
* @param string $tenantId Tenant identifier
* @param string $userId User identifier
* @param SourceSelector $sources Message sources with collection identifiers
* @param ResourceIdentifiers|null $targets Message sources with collection identifiers
* @param array|null $filter Message filter
* @param array|null $sort Message sort
* @param array|null $range Message range/pagination
*
* @return array<string, array<string|int, array<string|int, array<string|int, IMessageBase>>>> Messages grouped by provider/service/collection
*/
public function entityListBulk(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array {
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
public function entityListBulk(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): array {
// confirm that sources are provided
if ($targets === null) {
$targets = new ResourceIdentifiers([]);
}
// retrieve services for each provider
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// retrieve entities for each service
$responseData = [];
foreach ($providers as $provider) {
// retrieve services for each provider
$serviceSelector = $sources[$provider->identifier()];
$servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers());
foreach ($aggregateServices as $services) {
/** @var ServiceBaseInterface $service */
foreach ($servicesSelected as $service) {
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
continue;
}
// retrieve collections for each service
$collectionSelector = $serviceSelector[$service->identifier()];
$collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
$collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections();
if ($collectionSelected === []) {
$collections = $service->collectionList('');
$collectionSelected = array_map(
@@ -779,29 +785,16 @@ class Manager {
// construct range for entities
$entityRange = null;
if ($range !== null && $range !== [] && isset($range['type'])) {
$entityRange = $service->entityListRange(RangeType::from($range['type']));
// Cast to IRangeTally if the range type is TALLY
if ($entityRange->type() === RangeType::TALLY) {
/** @var IRangeTally $entityRange */
if (isset($range['anchor'])) {
$entityRange->setAnchor(RangeAnchorType::from($range['anchor']));
}
if (isset($range['position'])) {
$entityRange->setPosition($range['position']);
}
if (isset($range['tally'])) {
$entityRange->setTally($range['tally']);
}
}
$entityRange = $service->entityListRange(RangeType::from($range['type']))->jsonDeserialize($range);
}
// retrieve entities for each collection
foreach ($collectionSelected as $collectionId) {
$entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null);
$entities = $service->entityListBulk($collectionId, $entityFilter, $entitySort, $entityRange, null);
// skip collections with no entities
if ($entities === []) {
continue;
}
$responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities;
$responseData[$service->provider()][$service->identifier()][$collectionId] = $entities;
}
}
}
@@ -816,29 +809,29 @@ class Manager {
*
* @param string $tenantId Tenant identifier
* @param string $userId User identifier
* @param SourceSelector $sources Message sources with collection identifiers
* @param ResourceIdentifiers|null $targets Message sources with collection identifiers
* @param array|null $filter Message filter
* @param array|null $sort Message sort
* @param array|null $range Message range/pagination
*
* @return \Generator<EntityBaseInterface> Yields each entity as it is retrieved
*/
public function entityListStream(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator {
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
public function entityListStream(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator {
// confirm that sources are provided
if ($targets === null) {
$targets = new ResourceIdentifiers([]);
}
// retrieve services for each provider
foreach ($providers as $provider) {
$serviceSelector = $sources[$provider->identifier()];
$servicesSelected = $provider->serviceList($tenantId, $userId, $serviceSelector->identifiers());
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
foreach ($aggregateServices as $services) {
/** @var ServiceBaseInterface $service */
foreach ($servicesSelected as $service) {
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
continue;
}
// retrieve collections for each service
$collectionSelector = $serviceSelector[$service->identifier()];
$collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
$collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections();
if ($collectionSelected === []) {
$collections = $service->collectionList('');
$collectionSelected = array_map(
@@ -868,19 +861,7 @@ class Manager {
// construct range for entities
$entityRange = null;
if ($range !== null && $range !== [] && isset($range['type'])) {
$entityRange = $service->entityListRange(RangeType::from($range['type']));
if ($entityRange->type() === RangeType::TALLY) {
/** @var IRangeTally $entityRange */
if (isset($range['anchor'])) {
$entityRange->setAnchor(RangeAnchorType::from($range['anchor']));
}
if (isset($range['position'])) {
$entityRange->setPosition($range['position']);
}
if (isset($range['tally'])) {
$entityRange->setTally($range['tally']);
}
}
$entityRange = $service->entityListRange(RangeType::from($range['type']))->jsonDeserialize($range);
}
// yield entities for each collection individually
foreach ($collectionSelected as $collectionId) {
@@ -905,7 +886,7 @@ class Manager {
// group identifiers by provider/service
$groupedIdentifiers = [];
foreach ($identifiers as $identifier) {
$groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier->entity();
$groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier;
}
// retrieve each service and fetch entities
$list = [];
@@ -966,43 +947,36 @@ class Manager {
*
* @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context
* @param SourceSelector $sources Message sources with identifiers
* @param ResourceIdentifiers $targets Message sources with identifiers
*
* @return array<string, array<string|int, array<string|int, array<string|int, bool>>>> Existence map grouped by provider/service/collection
*/
public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array {
// confirm that sources are provided
if ($sources === null) {
$sources = new SourceSelector([]);
}
// retrieve available providers
$providers = $this->providerList($tenantId, $userId, $sources);
$providersRequested = $sources->identifiers();
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
// initialize response with unavailable providers
public function entityExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array {
// retrieve available services grouped by provider
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// initialize response with unavailable providers marked as false
$providersRequested = $targets->providers();
$providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices));
$responseData = array_fill_keys($providersUnavailable, false);
// check services, collections, and entities for each available provider
foreach ($providers as $provider) {
// extract services requested for this provider
$serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector->identifiers();
/** @var ServiceBaseInterface[] $servicesAvailable */
$servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested);
$servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable));
foreach ($aggregateServices as $providerId => $services) {
// mark unavailable services as false
$servicesRequested = $targets->byProvider($providerId)->services();
$servicesUnavailable = array_diff($servicesRequested, array_keys($services));
if ($servicesUnavailable !== []) {
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
$responseData[$providerId] = array_fill_keys($servicesUnavailable, false);
}
// check collections and entities for each available service
foreach ($servicesAvailable as $service) {
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
$responseData[$providerId][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
$serviceTargets = $targets->byProvider($providerId)->byService($service->identifier());
$collectionsRequested = $serviceTargets->collections();
if ($collectionsRequested === []) {
continue;
}
@@ -1012,19 +986,18 @@ class Manager {
$collectionExists = $service->collectionExtant((string)$collectionId);
if (!$collectionExists) {
// collection doesn't exist, mark as false
$responseData[$provider->identifier()][$service->identifier()][$collectionId] = false;
$responseData[$providerId][$service->identifier()][$collectionId] = false;
continue;
}
// extract entity identifiers from collection selector
$entitySelector = $collectionSelector[$collectionId];
// handle both array of entity IDs and boolean true (meaning check if collection exists)
if ($entitySelector instanceof EntitySelector) {
// check specific entities within the collection
$responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers());
} elseif ($entitySelector === true) {
// extract entity identifiers requested for this collection
$entitiesRequested = $serviceTargets->byCollection($collectionId)->entities();
if ($entitiesRequested === []) {
// just checking if collection exists (already confirmed above)
$responseData[$provider->identifier()][$service->identifier()][$collectionId] = true;
$responseData[$providerId][$service->identifier()][$collectionId] = true;
continue;
}
// check specific entities within the collection
$responseData[$providerId][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitiesRequested);
}
}
}
@@ -1038,50 +1011,45 @@ class Manager {
*
* @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context
* @param SourceSelector $sources Message sources with signatures
* @param ResourceIdentifiers $targets Message sources with signatures
*
* @return array<string, array<string|int, array<string|int, array>>> Delta grouped by provider/service/collection
*/
public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array {
// confirm that sources are provided
if ($sources === null) {
$sources = new SourceSelector([]);
}
// retrieve providers
$providers = $this->providerList($tenantId, $userId, $sources);
$providersRequested = $sources->identifiers();
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
// initialize response with unavailable providers
public function entityDelta(string $tenantId, string $userId, ResourceIdentifiers $targets): array {
// retrieve available services grouped by provider
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// initialize response with unavailable providers marked as false
$providersRequested = $targets->providers();
$providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices));
$responseData = array_fill_keys($providersUnavailable, false);
// iterate through available providers
foreach ($providers as $provider) {
// extract services requested for this provider
$serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : [];
/** @var ServiceBaseInterface[] $services */
$services = $provider->serviceList($tenantId, $userId, $servicesRequested);
foreach ($aggregateServices as $providerId => $services) {
// mark unavailable services as false
$servicesRequested = $targets->byProvider($providerId)->services();
$servicesUnavailable = array_diff($servicesRequested, array_keys($services));
if ($servicesUnavailable !== []) {
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
$responseData[$providerId] = array_fill_keys($servicesUnavailable, false);
}
// iterate through available services
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
$responseData[$providerId][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
$serviceTargets = $targets->byProvider($providerId)->byService($service->identifier());
$collectionsRequested = $serviceTargets->collections();
if ($collectionsRequested === []) {
$responseData[$provider->identifier()][$service->identifier()] = false;
$responseData[$providerId][$service->identifier()] = false;
continue;
}
// check delta for each requested collection
foreach ($collectionsRequested as $collection) {
$entitySelector = $collectionSelector[$collection] ?? null;
$responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector);
// signature for the collection is carried in the entity slot of the identifier
$signature = $serviceTargets->byCollection($collection)->entities()[0] ?? '';
$responseData[$providerId][$service->identifier()][$collection] = $service->entityDelta($collection, $signature);
}
}
}
@@ -1302,4 +1270,28 @@ class Manager {
return $operationOutcome;
}
public function entitySubmit(string $tenantId, string $userId, AddressInterface|string $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|array|null $message = null): EntitySubmitResult {
if ($sender instanceof AddressInterface === false) {
$sender = new Address($sender);
}
$service = $this->serviceFindByAddress($tenantId, $userId, $sender->getAddress());
if ($service === null || $service->getEnabled() === false) {
throw new InvalidArgumentException("Service not found for sender '{$sender->getAddress()}' or service is disabled");
}
if ($service instanceof ServiceEntitySubmitInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity submission");
}
if ($source === null && $message === null) {
throw new InvalidArgumentException("At least one of source or message must be provided for entity submission");
}
if ($message !== null && $message instanceof MessagePropertiesMutableInterface === false) {
$message = $service->entityFresh()->getProperties()->jsonDeserialize($message);
}
return $service->entitySubmit($sender, $source, $message);
}
}
+5
View File
@@ -50,6 +50,11 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
'description' => 'View and access the mail manager module',
'group' => 'Mail Management'
],
'mail_manager.system' => [
'label' => 'Manage System Mail',
'description' => 'Manage system mail accounts and routing rules (act in the reserved system user context)',
'group' => 'Mail Management'
],
];
}
+1337 -622
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -18,18 +18,21 @@
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"pinia": "^3.0.0",
"pinia": "^4.0.0",
"vue": "^3.5.18",
"vue-router": "^5.0.0",
"vue-router": "^5.2.0",
"vuetify": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.18",
"vue-tsc": "^3.0.5"
}
}
+18 -9
View File
@@ -4,7 +4,7 @@ import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore'
import { ServiceObject, type ProviderObject } from '@MailManager/models'
import type { ProviderDiscoveryStatus, ServiceInterface, ServiceLocation } from '@MailManager/types'
import type { ProviderDiscoveryStatus, ServiceAddressInterface, 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'
@@ -32,6 +32,7 @@ const MANUAL_STEPS = {
const props = defineProps<{
modelValue: boolean
user?: string
}>()
const emit = defineEmits<{
@@ -57,6 +58,11 @@ const discoverAddress = ref<string>('')
const discoverSecret = ref<string | null>(null)
const discoverHostname = ref<string | null>(null)
// Address entered during discovery, as a service address object
const discoverServiceAddress = computed<ServiceAddressInterface | null>(() =>
discoverAddress.value ? { address: discoverAddress.value } : null
)
// Step 2: Discovery Status / Provider Selection
const selectedProvider = shallowRef<ProviderObject | null>(null)
const selectedService = shallowRef<ServiceObject | null>(null)
@@ -154,7 +160,7 @@ function createServiceObject(
identifier: null,
label: data.label ?? null,
enabled: data.enabled ?? true,
primaryAddress: data.primaryAddress ?? (discoverAddress.value || null),
primaryAddress: data.primaryAddress ?? discoverServiceAddress.value,
secondaryAddresses: data.secondaryAddresses ?? null,
location: data.location ?? null,
identity: data.identity ?? null,
@@ -237,7 +243,8 @@ async function handleDiscover() {
discoverSecret.value || undefined,
discoverHostname.value || undefined,
identifier,
(service) => { discoveredService = service }
(service) => { discoveredService = service },
props.user
)
// Success - check if we got results for this provider
@@ -300,7 +307,7 @@ async function handleProviderSelect(identifier: string) {
...discoveredJson,
label: discoveredJson.label || discoverAddress.value,
enabled: discoveredJson.enabled ?? true,
primaryAddress: discoveredJson.primaryAddress || discoverAddress.value,
primaryAddress: discoveredJson.primaryAddress ?? discoverServiceAddress.value,
location: discoveredJson.location
})
setSelectedProviderAndService(identifier, service)
@@ -317,7 +324,7 @@ function handleProviderAdvanced(identifier: string) {
...discoveredJson,
label: discoveredJson?.label || discoverAddress.value,
enabled: discoveredJson?.enabled ?? true,
primaryAddress: discoveredJson?.primaryAddress || discoverAddress.value,
primaryAddress: discoveredJson?.primaryAddress ?? discoverServiceAddress.value,
location: discoveredJson?.location ?? null
})
@@ -341,7 +348,7 @@ function handleProviderManualSelect(identifier: string) {
const service = createServiceObject(identifier, {
label: discoverAddress.value,
enabled: true,
primaryAddress: discoverAddress.value,
primaryAddress: discoverServiceAddress.value,
location: null,
identity: null
})
@@ -379,7 +386,8 @@ async function testConnection() {
selectedProvider.value.identifier,
null,
selectedService.value.location,
selectedService.value.identity
selectedService.value.identity,
props.user
)
return testResult
@@ -396,7 +404,7 @@ async function saveAccount() {
try {
const accountData = {
label: serviceData.label || discoverAddress.value,
primaryAddress: serviceData.primaryAddress || discoverAddress.value,
primaryAddress: serviceData.primaryAddress ?? discoverServiceAddress.value,
enabled: serviceData.enabled,
location: serviceData.location,
identity: serviceData.identity,
@@ -405,7 +413,8 @@ async function saveAccount() {
await servicesStore.create(
selectedProvider.value.identifier,
accountData
accountData,
props.user
)
emit('saved')
+17 -4
View File
@@ -14,6 +14,7 @@ const props = defineProps<{
modelValue: boolean
serviceProvider: string
serviceIdentifier: string | number
user?: string
}>()
const emit = defineEmits<{
@@ -103,7 +104,10 @@ async function load() {
try {
const [provider, service] = await Promise.all([
providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
// acting-user context always fetches fresh, bypassing the shared cache
props.user
? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier, props.user)
: servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
])
localProvider.value = provider.clone()
@@ -161,12 +165,16 @@ async function testConnection() {
localService.value.provider,
null,
localService.value.location,
localService.value.identity
localService.value.identity,
props.user
)
} else {
testResult = await servicesStore.test(
localService.value.provider,
localService.value.identifier
localService.value.identifier,
undefined,
undefined,
props.user
)
}
@@ -182,6 +190,10 @@ async function testConnection() {
}
async function saveAccount() {
if (!localService.value) {
console.error('[Mail Manager][Edit Account Dialog] - No service data to save')
return
}
// No changes made, just close the dialog
if (!localService.value.mutated() && !localService.value.location?.mutated() && !localService.value.identity?.mutated()) {
close()
@@ -195,7 +207,8 @@ async function saveAccount() {
localService.value.provider,
localService.value.identifier as string | number,
true, // delta update
localService.value
localService.value,
props.user
)
emit('saved')
+2 -2
View File
@@ -134,7 +134,7 @@ watch(
<v-icon>mdi-label</v-icon>
</template>
<v-list-item-title>Account Name</v-list-item-title>
<v-list-item-subtitle>{{ localService.label || localService.primaryAddress || 'New Account' }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.label || localService.primaryAddress?.address || 'New Account' }}</v-list-item-subtitle>
</v-list-item>
<!-- Email Address -->
@@ -143,7 +143,7 @@ watch(
<v-icon>mdi-email</v-icon>
</template>
<v-list-item-title>Email Address</v-list-item-title>
<v-list-item-subtitle>{{ localService.primaryAddress }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.primaryAddress?.address }}</v-list-item-subtitle>
</v-list-item>
<!-- Provider -->
+60 -89
View File
@@ -1,6 +1,6 @@
/**
* Background mail synchronization composable
*
*
* Periodically checks for changes in mailboxes using the delta method
*/
@@ -8,12 +8,10 @@ import { ref, onMounted, onUnmounted } from 'vue';
import type { Ref } from 'vue';
import { useEntitiesStore } from '../stores/entitiesStore';
import { useCollectionsStore } from '../stores/collectionsStore';
import type { CollectionIdentifier, EntityIdentifier } from '../types/common';
export interface SyncSource {
provider: string;
service: string | number;
collections: (string | number)[];
}
/** A sync source is a collection identifier (provider:service:collection) to monitor */
export type SyncSource = CollectionIdentifier;
interface SyncOptions {
/** Polling interval in milliseconds (default: 30000 = 30 seconds) */
@@ -28,9 +26,9 @@ 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;
sources: Ref<CollectionIdentifier[]>;
addSource: (source: CollectionIdentifier) => void;
removeSource: (source: CollectionIdentifier) => void;
clearSources: () => void;
sync: () => Promise<void>;
start: () => void;
@@ -47,26 +45,21 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
const entitiesStore = useEntitiesStore();
const collectionsStore = useCollectionsStore();
const isRunning = ref(false);
const lastSync = ref<Date | null>(null);
const error = ref<string | null>(null);
const sources = ref<SyncSource[]>([]);
const signatures = ref<Record<string, Record<string, Record<string, string>>>>({});
const sources = ref<CollectionIdentifier[]>([]);
// Last known signature per collection identifier (updated by delta)
const signatures = ref<Record<string, string>>({});
let syncInterval: ReturnType<typeof setInterval> | null = null;
/**
* Add a source to sync (mailbox to monitor)
*/
function addSource(source: SyncSource) {
const exists = sources.value.some(
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
if (!exists) {
function addSource(source: CollectionIdentifier) {
if (!sources.value.includes(source)) {
sources.value.push(source);
}
}
@@ -74,13 +67,8 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
/**
* Remove a source from sync
*/
function removeSource(source: SyncSource) {
const index = sources.value.findIndex(
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
function removeSource(source: CollectionIdentifier) {
const index = sources.value.indexOf(source);
if (index !== -1) {
sources.value.splice(index, 1);
}
@@ -104,91 +92,74 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
try {
error.value = null;
// Build sources structure for delta request
const deltaSources: any = {};
sources.value.forEach(source => {
if (!deltaSources[source.provider]) {
deltaSources[source.provider] = {};
}
if (!deltaSources[source.provider][source.service]) {
deltaSources[source.provider][source.service] = {};
}
// Add collections to check with their signatures
source.collections.forEach(collection => {
// Look up signature from local tracking (updated by delta)
let signature = signatures.value[source.provider]?.[String(source.service)]?.[String(collection)];
// Fallback to collection signature if not yet synced
if (!signature) {
const collectionData = collectionsStore.collection(source.provider, source.service, collection);
signature = collectionData?.signature || '';
}
// Build flat identifier list for the delta request, embedding the last known
// signature for each collection as the entity slot (provider:service:collection:signature)
const requestSignatures: Record<string, string> = {};
const targets: (CollectionIdentifier | EntityIdentifier)[] = [];
console.log(`[Sync] Collection ${source.provider}/${source.service}/${collection} signature: "${signature}"`);
// Map collection identifier to signature string
deltaSources[source.provider][source.service][collection] = signature || '';
});
sources.value.forEach(collectionId => {
// Look up signature from local tracking (updated by delta), falling back to the
// collection's own signature if it has not been synced yet
let signature = signatures.value[collectionId];
if (!signature) {
const collectionData = collectionsStore.collection(collectionId);
signature = collectionData?.signature || '';
}
requestSignatures[collectionId] = signature;
targets.push(signature ? `${collectionId}:${signature}` as EntityIdentifier : collectionId);
});
// Get delta changes
const deltaResponse = await entitiesStore.delta(deltaSources);
const deltaResponse = await entitiesStore.delta(targets);
// If fetchDetails is enabled, fetch full entity data for additions and modifications
if (fetchDetails) {
const fetchPromises: Promise<any>[] = [];
Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => {
if (providerData === false) {
return;
}
Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => {
if (serviceData === false) {
return;
}
Object.entries(serviceData).forEach(([collection, collectionData]: [string, any]) => {
// Skip if no changes (server returns false or string signature)
// Skip if no changes (server returns false or a bare signature string)
if (collectionData === false || typeof collectionData === 'string') {
return;
}
const collectionId = `${provider}:${service}:${collection}` as CollectionIdentifier;
// Update signature tracking
if (collectionData.signature) {
if (!signatures.value[provider]) {
signatures.value[provider] = {};
}
if (!signatures.value[provider][service]) {
signatures.value[provider][service] = {};
}
signatures.value[provider][service][collection] = collectionData.signature;
console.log(`[Sync] Updated signature for ${provider}/${service}/${collection}: "${collectionData.signature}"`);
signatures.value[collectionId] = collectionData.signature;
}
// Check if signature actually changed (if not, skip fetching)
const oldSignature = deltaSources[provider]?.[service]?.[collection];
// Skip fetching when the signature did not actually change
const oldSignature = requestSignatures[collectionId];
const newSignature = collectionData.signature;
if (oldSignature && newSignature && oldSignature === newSignature) {
// Signature unchanged - server bug returning additions anyway, skip fetch
console.log(`[Sync] Skipping fetch for ${provider}/${service}/${collection} - signature unchanged (${newSignature})`);
return;
}
const identifiersToFetch = [
const changedIds = [
...(collectionData.additions || []),
...(collectionData.modifications || []),
];
if (identifiersToFetch.length > 0) {
console.log(`[Sync] Fetching ${identifiersToFetch.length} entities for ${provider}/${service}/${collection}`);
fetchPromises.push(
entitiesStore.fetch(
provider,
service,
collection,
identifiersToFetch
)
if (changedIds.length > 0) {
const entityTargets = changedIds.map(
(id: string | number) => `${collectionId}:${id}` as EntityIdentifier
);
fetchPromises.push(entitiesStore.fetch(entityTargets));
}
});
});
});
// Fetch all in parallel
await Promise.allSettled(fetchPromises);
}
@@ -209,10 +180,10 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
}
isRunning.value = true;
// Do initial sync
sync();
// Set up periodic sync
syncInterval = setInterval(() => {
sync();
@@ -228,7 +199,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
}
isRunning.value = false;
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
@@ -260,7 +231,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
lastSync,
error,
sources,
// Methods
addSource,
removeSource,
+83
View File
@@ -0,0 +1,83 @@
/**
* Address implementation class for Mail Manager services
*
* Plain value object (no mutation tracking). ServiceObject getters return
* fresh instances built from raw data; mutating a getter result does not
* write back — assign through the setter to persist changes.
*/
import type { ServiceAddressInterface } from '@/types/service';
export class ServiceAddressObject implements ServiceAddressInterface {
_address: string;
_label: string | null;
constructor(address: string = '', label: string | null = null) {
this._address = address;
this._label = label;
}
static fromJson(data: ServiceAddressInterface): ServiceAddressObject {
return new ServiceAddressObject(data.address, data.label ?? null);
}
toJson(): ServiceAddressInterface {
const label = this._label?.trim() ?? '';
return {
address: this._address.trim(),
label: label.length > 0 ? label : null,
};
}
toJSON(): ServiceAddressInterface {
return this.toJson();
}
clone(): ServiceAddressObject {
return new ServiceAddressObject(this._address, this._label);
}
/** Case-insensitive address comparison, ignoring labels */
matches(address: string): boolean {
return this._address.trim().toLowerCase() === address.trim().toLowerCase();
}
equals(other: ServiceAddressObject | null | undefined): boolean {
if (!other) {
return false;
}
const current = this.toJson();
const next = other.toJson();
return current.address === next.address && (current.label ?? null) === (next.label ?? null);
}
/** Display form: "Label <address>" or bare address */
format(): string {
const { address, label } = this.toJson();
return label ? `${label} <${address}>` : address;
}
get empty(): boolean {
return this._address.trim().length === 0;
}
/** Properties (raw values for editing; normalization happens in toJson) */
get address(): string {
return this._address;
}
set address(value: string) {
this._address = value;
}
get label(): string | null {
return this._label;
}
set label(value: string | null) {
this._label = value;
}
}
+13 -5
View File
@@ -2,9 +2,17 @@
* Class model for Collection Interface
*/
import type { CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface, CollectionPropertiesModelInterface } from "@/types/collection";
import type {
CollectionInterface,
CollectionModelInterface,
CollectionPropertiesInterface,
CollectionPropertiesModelInterface
} from "@/types/collection";
import { clonePlain } from './clone-plain';
import type { CollectionIdentifier, ServiceIdentifier } from "@/services";
import type {
CollectionIdentifier,
ServiceIdentifier
} from "@/services";
export class CollectionObject implements CollectionModelInterface {
@@ -16,9 +24,9 @@ export class CollectionObject implements CollectionModelInterface {
'@type': 'mail:collection',
version: 1,
provider: '',
service: '',
collection: null,
identifier: '',
service: '' as ServiceIdentifier,
collection: null as CollectionIdentifier | null,
identifier: '' as CollectionIdentifier,
properties: {'@type': 'mail:folder', label: ''},
};
}
+2 -2
View File
@@ -19,8 +19,8 @@ export class EntityObject implements EntityModelInterface {
version: 1,
provider: '',
service: '',
collection: null,
identifier: null,
collection: '' as CollectionIdentifier,
identifier: '' as EntityIdentifier,
signature: null,
created: null,
modified: null,
+2
View File
@@ -1,5 +1,6 @@
export { ProviderObject } from './provider';
export { ServiceObject } from './service';
export { ServiceAddressObject } from './address';
export {
CollectionObject,
CollectionPropertiesObject
@@ -7,6 +8,7 @@ export {
export { EntityObject } from './entity';
export {
MessageObject,
MessageAddressObject,
MessagePartObject
} from './message';
export {
+3 -3
View File
@@ -119,19 +119,19 @@ export class MessageObject implements MessageModelInterface {
return this._data.attachments ? this._data.attachments.map(att => new MessagePartObject(att)) : [];
}
get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | {} {
get flags(): { seen?: 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 }) {
set flags(value: { seen?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean }) {
this._data.flags = clonePlain(value);
}
/** Helper methods */
get isRead(): boolean {
return this._data.flags?.read ?? false;
return this._data.flags?.seen ?? false;
}
get isFlagged(): boolean {
+9 -8
View File
@@ -8,6 +8,7 @@ import type {
ServiceLocation,
ServiceModelInterface
} from "@/types/service";
import { ServiceAddressObject } from './address';
import { Identity } from './identity';
import { Location } from './location';
import { MutationProxy } from './mutation-proxy';
@@ -118,20 +119,20 @@ export class ServiceObject implements ServiceModelInterface {
return this._data.capabilities ?? {};
}
get primaryAddress(): string | null {
return this._data.primaryAddress ?? null;
get primaryAddress(): ServiceAddressObject | null {
return this._data.primaryAddress ? ServiceAddressObject.fromJson(this._data.primaryAddress) : null;
}
set primaryAddress(value: string | null) {
this._data.primaryAddress = value;
set primaryAddress(value: ServiceAddressObject | null) {
this._data.primaryAddress = value ? value.toJson() : null;
}
get secondaryAddresses(): string[] {
return this._data.secondaryAddresses ?? [];
get secondaryAddresses(): ServiceAddressObject[] {
return (this._data.secondaryAddresses ?? []).map(entry => ServiceAddressObject.fromJson(entry));
}
set secondaryAddresses(value: string[] | null) {
this._data.secondaryAddresses = value;
set secondaryAddresses(value: ServiceAddressObject[] | null) {
this._data.secondaryAddresses = value ? value.map(entry => entry.toJson()) : null;
}
/** Mutable Properties */
@@ -212,7 +212,7 @@ async function handleAccountSaved() {
<div>
<h3 class="text-h6">{{ service.label }}</h3>
<p class="text-caption text-medium-emphasis">
{{ service.primaryAddress || (service.identity?.type === 'BA' ? service.identity.identity : 'No email configured') }}
{{ service.primaryAddress?.address || (service.identity?.type === 'BA' ? service.identity.identity : 'No email configured') }}
</p>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@ const routes = [
{
name: 'mail-accounts',
path: '/accounts',
component: () => import('@/pages/AccountsPage.vue'),
component: () => import('@/pages/Main.vue'),
meta: {
title: 'Mail Accounts',
requiresAuth: true
+33
View File
@@ -28,6 +28,9 @@ import type {
EntityPatchResponse,
EntityPatchRequest,
EntityDownloadRequest,
EntityBlobsRequest,
EntityBlobsResponse,
EntityBlobsWireResponse,
} from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models';
@@ -227,6 +230,36 @@ export const entityService = {
download(request: EntityDownloadRequest): { transaction: string } {
return transceiveDownload<EntityDownloadRequest>('entity.download', request);
},
/**
* Fetch one or more message parts (attachments) inline for rendering.
*
* Returns JSON with base64-encoded bytes; each result is decoded into a Blob
* so callers can create object URLs for preview.
*/
async blobs(request: EntityBlobsRequest): Promise<EntityBlobsResponse> {
const wire = await transceivePost<EntityBlobsRequest, EntityBlobsWireResponse>(
'entity.blobs',
request,
);
return wire.map((result) => ({
source: result.source,
part: result.part,
mime: result.mime,
filename: result.filename,
blob: base64ToBlob(result.bytes, result.mime),
}));
},
};
/** Decode base64 content into a Blob of the given MIME type. */
function base64ToBlob(base64: string, mime: string): Blob {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mime });
}
export default entityService;
+21 -19
View File
@@ -41,14 +41,14 @@ function createServiceObject(data: ServiceInterface): ServiceObject {
export const serviceService = {
/**
* Retrieve list of services, optionally filtered by source selector
* Retrieve list of services, optionally filtered by provider/service identifiers
*
* @param request - list request parameters
*
* @returns Promise with service object list grouped by provider and keyed by service identifier
*/
async list(request: ServiceListRequest = {}): Promise<Record<string, Record<string, ServiceObject>>> {
const response = await transceivePost<ServiceListRequest, ServiceListResponse>('service.list', request);
async list(request: ServiceListRequest = {}, user?: string): Promise<Record<string, Record<string, ServiceObject>>> {
const response = await transceivePost<ServiceListRequest, ServiceListResponse>('service.list', request, user);
// Convert nested response to ServiceObject instances
const providerList: Record<string, Record<string, ServiceObject>> = {};
@@ -70,20 +70,20 @@ export const serviceService = {
*
* @returns Promise with service object
*/
async fetch(request: ServiceFetchRequest): Promise<ServiceObject> {
const response = await transceivePost<ServiceFetchRequest, ServiceFetchResponse>('service.fetch', request);
async fetch(request: ServiceFetchRequest, user?: string): Promise<ServiceObject> {
const response = await transceivePost<ServiceFetchRequest, ServiceFetchResponse>('service.fetch', request, user);
return createServiceObject(response);
},
/**
* Retrieve service availability status for a given source selector
* Retrieve service availability status for the given service identifiers
*
* @param request - extant request parameters
*
* @returns Promise with service availability status
*/
async extant(request: ServiceExtantRequest): Promise<ServiceExtantResponse> {
return await transceivePost<ServiceExtantRequest, ServiceExtantResponse>('service.extant', request);
async extant(request: ServiceExtantRequest, user?: string): Promise<ServiceExtantResponse> {
return await transceivePost<ServiceExtantRequest, ServiceExtantResponse>('service.extant', request, user);
},
/**
@@ -96,7 +96,8 @@ export const serviceService = {
*/
async discover(
request: ServiceDiscoverRequest,
onService: (service: ServiceObject) => void
onService: (service: ServiceObject) => void,
user?: string
): Promise<{ total: number }> {
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
'service.discover',
@@ -107,11 +108,12 @@ export const serviceService = {
provider: service.provider,
identifier: null,
label: null,
enabled: false,
enabled: true,
location: service.location,
};
onService(createServiceObject(serviceData));
}
},
user
);
},
@@ -121,8 +123,8 @@ export const serviceService = {
* @param request - Service test request
* @returns Promise with test results
*/
async test(request: ServiceTestRequest): Promise<ServiceTestResponse> {
return await transceivePost<ServiceTestRequest, ServiceTestResponse>('service.test', request);
async test(request: ServiceTestRequest, user?: string): Promise<ServiceTestResponse> {
return await transceivePost<ServiceTestRequest, ServiceTestResponse>('service.test', request, user);
},
/**
@@ -132,8 +134,8 @@ export const serviceService = {
*
* @returns Promise with created service object
*/
async create(request: ServiceCreateRequest): Promise<ServiceObject> {
const response = await transceivePost<ServiceCreateRequest, ServiceCreateResponse>('service.create', request);
async create(request: ServiceCreateRequest, user?: string): Promise<ServiceObject> {
const response = await transceivePost<ServiceCreateRequest, ServiceCreateResponse>('service.create', request, user);
return createServiceObject(response);
},
@@ -144,8 +146,8 @@ export const serviceService = {
*
* @returns Promise with updated service object
*/
async update(request: ServiceUpdateRequest): Promise<ServiceObject> {
const response = await transceivePost<ServiceUpdateRequest, ServiceUpdateResponse>('service.update', request);
async update(request: ServiceUpdateRequest, user?: string): Promise<ServiceObject> {
const response = await transceivePost<ServiceUpdateRequest, ServiceUpdateResponse>('service.update', request, user);
return createServiceObject(response);
},
@@ -156,8 +158,8 @@ export const serviceService = {
*
* @returns Promise with deletion result
*/
async delete(request: { provider: string; identifier: string | number }): Promise<any> {
return await transceivePost<ServiceDeleteRequest, ServiceDeleteResponse>('service.delete', request);
async delete(request: { provider: string; identifier: string | number }, user?: string): Promise<any> {
return await transceivePost<ServiceDeleteRequest, ServiceDeleteResponse>('service.delete', request, user);
},
};
+35 -24
View File
@@ -8,6 +8,7 @@ import { entityService } from '../services'
import { EntityObject, MessageObject } from '../models'
import type {
EntityBlobSelector,
EntityBlobsResponse,
EntityDownloadRequest,
EntityTransmitRequest,
EntityTransmitResponse,
@@ -43,13 +44,10 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
/**
* Get a specific entity from store, with optional retrieval
*
* @param provider - provider identifier
* @param service - service identifier
* @param collection - collection identifier
* @param identifier - entity identifier
*
* @param target - entity identifier
* @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only
*
*
* @returns Entity object or null
*/
function entity(target: EntityIdentifier, retrieve: boolean = false): EntityObject | null {
@@ -63,12 +61,10 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
/**
* Get all entities for a specific collection
*
* @param provider - provider identifier
* @param service - service identifier
* @param collection - collection identifier
*
* @param target - collection identifier
* @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only
*
*
* @returns Array of entity objects
*/
function entitiesForCollection(target: CollectionIdentifier, retrieve: boolean = false): EntityObject[] {
@@ -116,14 +112,11 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
}
/**
* Retrieve specific entities by provider, service, collection, and identifiers
*
* @param provider - provider identifier
* @param service - service identifier
* @param collection - collection identifier
* @param identifiers - array of entity identifiers to fetch
*
/**
* Retrieve specific entities by their identifiers
*
* @param targets - array of entity identifiers to fetch
*
* @returns Promise with entity objects keyed by identifier
*/
async function fetch(targets: EntityIdentifier[]): Promise<Record<string, EntityObject>> {
@@ -172,17 +165,19 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
/**
* Retrieve delta changes for entities
*
* @param sources - source selector for delta check
*
* @param targets - collection identifiers (provider:service:collection), optionally
* suffixed with a known signature (provider:service:collection:signature)
* to request a delta relative to that signature
*
* @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[]) {
async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]) {
transceiving.value = true
try {
const response = await entityService.delta({ sources })
const response = await entityService.delta({ targets })
// Process delta and update store
Object.entries(response).forEach(([, providerData]) => {
@@ -508,6 +503,21 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
}
}
/**
* Fetch one or more message parts (attachments) inline as Blobs, for preview.
*/
async function blobs(
target: EntityIdentifier,
parts: EntityBlobSelector[],
): Promise<EntityBlobsResponse> {
try {
return await entityService.blobs({ target, parts })
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to fetch attachment blobs:', error)
throw error
}
}
// Return public API
return {
// State (readonly)
@@ -530,5 +540,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
move,
transmit,
download,
blobs,
}
})
+11 -11
View File
@@ -6,7 +6,7 @@ import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia'
import { providerService } from '../services'
import { ProviderObject } from '../models/provider'
import type { SourceSelector } from '../types'
import type { ProviderIdentifier } from '../types'
export const useProvidersStore = defineStore('mailProvidersStore', () => {
// State
@@ -50,14 +50,14 @@ export const useProvidersStore = defineStore('mailProvidersStore', () => {
/**
* Retrieve all or specific providers, optionally filtered by source selector
*
* @param request - list request parameters
* @param targets - list request parameters
*
* @returns Promise with provider object list keyed by provider identifier
*/
async function list(sources?: SourceSelector): Promise<Record<string, ProviderObject>> {
async function list(targets?: ProviderIdentifier[]): Promise<Record<string, ProviderObject>> {
transceiving.value = true
try {
const providers = await providerService.list({ sources })
const providers = await providerService.list({ targets })
// Merge retrieved providers into state
_providers.value = { ..._providers.value, ...providers }
@@ -75,14 +75,14 @@ export const useProvidersStore = defineStore('mailProvidersStore', () => {
/**
* Retrieve a specific provider by identifier
*
* @param identifier - provider identifier
* @param target - fetch target identifier
*
* @returns Promise with provider object
*/
async function fetch(identifier: string): Promise<ProviderObject> {
async function fetch(target: string): Promise<ProviderObject> {
transceiving.value = true
try {
const provider = await providerService.fetch({ identifier })
const provider = await providerService.fetch({ target })
// Merge fetched provider into state
_providers.value[provider.identifier] = provider
@@ -100,14 +100,14 @@ export const useProvidersStore = defineStore('mailProvidersStore', () => {
/**
* Retrieve provider availability status for a given source selector
*
* @param sources - source selector to check availability for
* @param targets - list of provider identifiers to check availability for
*
* @returns Promise with provider availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: ProviderIdentifier[]) {
transceiving.value = true
try {
const response = await providerService.extant({ sources })
const response = await providerService.extant({ targets })
Object.entries(response).forEach(([providerId, providerStatus]) => {
if (providerStatus === false) {
@@ -115,7 +115,7 @@ export const useProvidersStore = defineStore('mailProvidersStore', () => {
}
})
console.debug('[Mail Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers')
console.debug('[Mail Manager][Store] - Successfully checked', targets ? targets.length : 0, 'providers')
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to check providers:', error)
+51 -35
View File
@@ -7,9 +7,9 @@ import { defineStore } from 'pinia'
import { serviceService } from '../services'
import { ServiceObject } from '../models/service'
import type {
CollectionIdentifier,
ServiceIdentifier,
ServiceLocation,
SourceSelector,
ServiceIdentity,
ServiceInterface,
} from '../types'
@@ -101,7 +101,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
* @returns Service object or null
*/
function serviceForAddress(address: string, retrieve: boolean = false): ServiceObject | null {
const service = Object.values(_services.value).find(s => s.primaryAddress === address || s.secondaryAddresses?.includes(address))
const service = Object.values(_services.value).find(s =>
s.primaryAddress?.matches(address) ||
s.secondaryAddresses.some(candidate => candidate.matches(address)),
)
if (retrieve === true && !service) {
console.debug(`[Mail Manager][Store] - No service found for address "${address}", discovery may be needed`)
@@ -122,16 +125,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
// Actions
/**
* Retrieve all or specific services, optionally filtered by source selector
*
* @param sources - optional source selector
*
* Retrieve all or specific services, optionally filtered by provider/service identifiers
*
* @param targets - optional array of provider:service (or provider:service:collection) identifiers
*
* @returns Promise with service object list keyed by provider and service identifier
*/
async function list(sources?: SourceSelector): Promise<Record<string, ServiceObject>> {
async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[], user?: string): Promise<Record<string, ServiceObject>> {
transceiving.value = true
try {
const response = await serviceService.list({ sources })
const response = await serviceService.list({ targets }, user)
// Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object
const services: Record<string, ServiceObject> = {}
@@ -142,8 +145,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
})
})
// Merge retrieved services into state
_services.value = { ..._services.value, ...services }
// Merge retrieved services into state (acting-user context stays out of the shared cache)
if (!user) {
_services.value = { ..._services.value, ...services }
}
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services')
return services
@@ -163,14 +168,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
*
* @returns Promise with service object
*/
async function fetch(provider: string, identifier: string | number): Promise<ServiceObject> {
async function fetch(provider: string, identifier: string | number, user?: string): Promise<ServiceObject> {
transceiving.value = true
try {
const service = await serviceService.fetch({ provider, identifier })
const service = await serviceService.fetch({ provider, identifier }, user)
// Merge fetched service into state
// Merge fetched service into state (acting-user context stays out of the shared cache)
const key = identifierKey(service.provider, service.identifier)
_services.value[key] = service
if (!user) {
_services.value[key] = service
}
console.debug('[Mail Manager][Store] - Successfully fetched service:', key)
return service
@@ -183,18 +190,18 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
}
/**
* Retrieve service availability status for a given source selector
*
* @param sources - source selector to check availability for
*
* Retrieve service availability status for the given service identifiers
*
* @param targets - array of provider:service identifiers to check availability for
*
* @returns Promise with service availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: ServiceIdentifier[], user?: string) {
transceiving.value = true
try {
const response = await serviceService.extant({ sources })
console.debug('[Mail Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services')
const response = await serviceService.extant({ targets }, user)
console.debug('[Mail Manager][Store] - Successfully checked', targets?.length ?? 0, 'services')
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to check services:', error)
@@ -212,14 +219,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
*
* @returns Promise with created service object
*/
async function create(provider: string, data: Partial<ServiceInterface>): Promise<ServiceObject> {
async function create(provider: string, data: Partial<ServiceInterface>, user?: string): Promise<ServiceObject> {
transceiving.value = true
try {
const service = await serviceService.create({ provider, data })
const service = await serviceService.create({ provider, data }, user)
// Merge created service into state
// Merge created service into state (acting-user context stays out of the shared cache)
const key = identifierKey(service.provider, service.identifier)
_services.value[key] = service
if (!user) {
_services.value[key] = service
}
console.debug('[Mail Manager][Store] - Successfully created service:', key)
return service
@@ -241,7 +250,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
*
* @returns Promise with updated service object
*/
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>): Promise<ServiceObject> {
async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial<ServiceInterface>, user?: string): Promise<ServiceObject> {
transceiving.value = true
try {
// convert ServiceObject to JSON if needed
@@ -252,11 +261,13 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
payload = data
}
const service = await serviceService.update({ provider, identifier, delta, data: payload })
const service = await serviceService.update({ provider, identifier, delta, data: payload }, user)
// Merge updated service into state
// Merge updated service into state (acting-user context stays out of the shared cache)
const key = identifierKey(service.provider, service.identifier)
_services.value[key] = service
if (!user) {
_services.value[key] = service
}
console.debug('[Mail Manager][Store] - Successfully updated service:', key)
return service
@@ -276,14 +287,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
*
* @returns Promise with deletion result
*/
async function remove(provider: string, identifier: string | number): Promise<any> {
async function remove(provider: string, identifier: string | number, user?: string): Promise<any> {
transceiving.value = true
try {
await serviceService.delete({ provider, identifier })
await serviceService.delete({ provider, identifier }, user)
// Remove deleted service from state
const key = identifierKey(provider, identifier)
delete _services.value[key]
if (!user) {
delete _services.value[key]
}
console.debug('[Mail Manager][Store] - Successfully deleted service:', key)
} catch (error: any) {
@@ -311,6 +324,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
location: string | undefined,
provider: string | undefined,
onService?: (service: ServiceObject) => void,
user?: string,
): Promise<{ total: number }> {
transceiving.value = true
@@ -319,7 +333,8 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
{ identity, secret, location, provider },
(service: ServiceObject) => {
onService?.(service)
}
},
user
)
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
@@ -347,6 +362,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
identifier?: string | number | null,
location?: ServiceLocation | Location | null,
identity?: ServiceIdentity | Identity | null,
user?: string,
): Promise<any> {
transceiving.value = true
try {
@@ -369,7 +385,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
identity = identity.toJson()
}
const response = await serviceService.test({ provider, identifier, location, identity })
const response = await serviceService.test({ provider, identifier, location, identity }, user)
console.debug('[Mail Manager][Store] - Successfully tested service:', provider, identifier || location)
return response
+6 -1
View File
@@ -1,7 +1,12 @@
/**
* Collection type definitions
*/
import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier, SourceSelector } from './common';
import type {
ServiceIdentifier,
CollectionIdentifier,
ListFilter,
ListSort
} from './common';
/**
* Collection information
+4 -27
View File
@@ -85,34 +85,11 @@ export type ApiStreamResponse<T = any> =
| ApiStreamDataResponse<T>;
/**
* Selector for targeting specific providers, services, collections, or entities in list or extant operations.
*
* Example usage:
* {
* "provider1": true, // Select all services/collections/entities under provider1
* "provider2": {
* "serviceA": true, // Select all collections/entities under serviceA of provider2
* "serviceB": {
* "collectionX": true, // Select all entities under collectionX of serviceB of provider2
* "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2
* }
* }
* }
* Identifiers for targeting specific providers, services, collections, or entities in list or extant operations.
*
* Operations accept flat arrays of colon-separated identifier strings, e.g.
* ["imap:account1:INBOX", "imap:account1:Sent:1001"].
*/
export type SourceSelector = {
[provider: string]: boolean | ServiceSelector;
};
export type ServiceSelector = {
[service: string]: boolean | CollectionSelector;
};
export type CollectionSelector = {
[collection: string | number]: boolean | EntitySelector;
};
export type EntitySelector = (string | number)[];
export type ProviderIdentifier = `${string}`;
export type ServiceIdentifier = `${string}:${string}`;
export type CollectionIdentifier = `${string}:${string}:${string | number}`;
+16 -3
View File
@@ -8,7 +8,10 @@ import type {
ListRange,
ListSort,
} from './common';
import type { MessageInterface, MessageModelInterface } from './message';
import type {
MessageInterface,
MessageModelInterface
} from './message';
/**
* Entity definition
@@ -92,7 +95,9 @@ export interface EntityExtantResponse {
* Entity delta
*/
export interface EntityDeltaRequest {
sources: CollectionIdentifier[];
// Each target is provider:service:collection, or provider:service:collection:signature
// to request a delta relative to a known signature (the signature is the entity slot).
targets: (CollectionIdentifier | EntityIdentifier)[];
}
export interface EntityDeltaResponse {
@@ -227,9 +232,17 @@ export interface EntityBlobsRequest {
parts: EntityBlobSelector[];
}
export interface EntityBlobsResult {
export interface EntityBlobsWireResult {
source: EntityIdentifier;
part: EntityBlobSelector;
mime: string;
filename: string;
bytes: string;
}
export interface EntityBlobsWireResponse extends Array<EntityBlobsWireResult> {}
export interface EntityBlobsResult extends Omit<EntityBlobsWireResult, 'bytes'> {
blob: Blob;
}
+1 -1
View File
@@ -35,7 +35,7 @@ export interface MessageAddressInterface {
}
export interface MessageFlagsInterface {
read?: boolean;
seen?: boolean;
flagged?: boolean;
answered?: boolean;
draft?: boolean;
+6 -6
View File
@@ -1,7 +1,7 @@
/**
* Provider type definitions
*/
import type { SourceSelector } from "./common";
import type { ProviderIdentifier } from "./common";
/**
* Provider capabilities
@@ -35,18 +35,18 @@ export interface ProviderModelInterface extends Omit<ProviderInterface, '@type'
* Provider list
*/
export interface ProviderListRequest {
sources?: SourceSelector;
targets?: ProviderIdentifier[];
}
export interface ProviderListResponse {
[identifier: string]: ProviderInterface;
[identifier: ProviderIdentifier]: ProviderInterface;
}
/**
* Provider fetch
*/
export interface ProviderFetchRequest {
identifier: string;
target: ProviderIdentifier;
}
export interface ProviderFetchResponse extends ProviderInterface {}
@@ -55,9 +55,9 @@ export interface ProviderFetchResponse extends ProviderInterface {}
* Provider extant
*/
export interface ProviderExtantRequest {
sources: SourceSelector;
targets: ProviderIdentifier[];
}
export interface ProviderExtantResponse {
[identifier: string]: boolean;
[identifier: ProviderIdentifier]: boolean;
}
+19 -6
View File
@@ -1,11 +1,13 @@
/**
* Service type definitions
*/
import type { ServiceAddressObject } from '@/models/address';
import type { Identity } from '@/models/identity';
import type { Location } from '@/models/location';
import type {
ServiceIdentifier,
CollectionIdentifier,
ListFilterComparisonOperator,
SourceSelector,
} from './common';
/**
@@ -40,6 +42,15 @@ export interface ServiceCapabilitiesInterface {
[key: string]: boolean | object | string | string[] | undefined;
}
/**
* Service sender address (primary or secondary/alias)
* Mirrors the shared Mail AddressInterface JSON shape
*/
export interface ServiceAddressInterface {
address: string;
label?: string | null;
}
/**
* Service information
*/
@@ -53,23 +64,25 @@ export interface ServiceInterface {
capabilities?: ServiceCapabilitiesInterface;
location?: ServiceLocation | null;
identity?: ServiceIdentity | null;
primaryAddress?: string | null;
secondaryAddresses?: string[] | null;
primaryAddress?: ServiceAddressInterface | null;
secondaryAddresses?: ServiceAddressInterface[] | null;
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'> {
}, '@type' | 'version' | 'location' | 'identity' | 'primaryAddress' | 'secondaryAddresses'> {
location: Location | null;
identity: Identity | null;
primaryAddress: ServiceAddressObject | null;
secondaryAddresses: ServiceAddressObject[];
}
/**
* Service list
*/
export interface ServiceListRequest {
sources?: SourceSelector;
targets?: ServiceIdentifier[] | CollectionIdentifier[];
}
export interface ServiceListResponse {
@@ -92,7 +105,7 @@ export interface ServiceFetchResponse extends ServiceInterface {}
* Service extant
*/
export interface ServiceExtantRequest {
sources: SourceSelector;
targets: ServiceIdentifier[];
}
export interface ServiceExtantResponse {
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@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/**',
],
},
},
})
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace KTXT\MailManager\Tests\Integration;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
+10
View File
@@ -2,6 +2,16 @@
require dirname(__DIR__, 2).'/lib/vendor/autoload.php';
// When this module is checked out inside a full server (server/modules/<handle>,
// as it is in CI and in this monorepo checkout), also load the server's own
// core/shared autoloader so tests can reference framework (KTXC/KTXF) types.
// Standalone module checkouts without a server alongside them skip this.
define('SERVER_ROOT', dirname(__DIR__, 4));
$serverAutoload = SERVER_ROOT . '/vendor/autoload.php';
if (is_file($serverAutoload)) {
require $serverAutoload;
}
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
umask(0000);
}
@@ -21,6 +21,9 @@
<testsuite name="Unit Tests">
<directory>unit</directory>
</testsuite>
<testsuite name="Integration Tests">
<directory>Integration</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
@@ -29,8 +32,7 @@
restrictWarnings="true"
>
<include>
<directory>../../core/lib</directory>
<directory>../../shared/lib</directory>
<directory>../../lib</directory>
</include>
</source>