commit 4f979ced2226a67448585dff5d0e340936361859 Author: root Date: Sun Jan 4 22:05:37 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee134c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log + +# Old files +lib.old/ +src.old/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c0b1bda --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "ktxm/provider-jmapc", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "ProviderJmapc", + "vendor-dir": "lib/vendor", + "allow-plugins": { + "bamarni/composer-bin-plugin": true + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/sebastiankrupinski/jmap-client-php" + } + ], + "require": { + "php": ">=8.2 <=8.5", + "sebastiankrupinski/jmap-client-php": "dev-main" + }, + "autoload": { + "psr-4": { + "KTXM\\ProviderJmapc\\": "lib/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..25c3aec --- /dev/null +++ b/composer.lock @@ -0,0 +1,751 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "90c0c524644547841aee50f27be2c29b", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "sebastiankrupinski/jmap-client-php", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/SebastianKrupinski/jmap-client-php.git", + "reference": "bd754c3364a44273682e94a0118a0770aa3b7449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/bd754c3364a44273682e94a0118a0770aa3b7449", + "reference": "bd754c3364a44273682e94a0118a0770aa3b7449", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^7.0", + "php": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.89" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "JmapClient\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "JmapClient\\Tests\\Unit\\": "tests/unit/", + "JmapClient\\Tests\\Integration\\": "tests/integration/" + } + }, + "scripts": { + "test:unit": [ + "phpunit --testsuite 'Unit Tests'" + ], + "test:integration": [ + "phpunit --testsuite 'Integration Tests'" + ], + "cs:check": [ + "php-cs-fixer fix --dry-run --diff" + ], + "cs:fix": [ + "php-cs-fixer fix" + ] + }, + "license": [ + "AGL3" + ], + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com", + "homepage": "https://github.com/SebastianKrupinski/", + "role": "Just another Minion in the cogs of time" + } + ], + "description": "JMAP PHP Client", + "homepage": "https://github.com/SebastianKrupinski/jmap-client-php", + "keywords": [ + "enum" + ], + "support": { + "source": "https://github.com/SebastianKrupinski/jmap-client-php/tree/main", + "issues": "https://github.com/SebastianKrupinski/jmap-client-php/issues" + }, + "time": "2025-11-07T22:31:45+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + } + ], + "packages-dev": [ + { + "name": "bamarni/composer-bin-plugin", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/bamarni/composer-bin-plugin.git", + "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", + "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "ext-json": "*", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", + "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", + "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin" + }, + "autoload": { + "psr-4": { + "Bamarni\\Composer\\Bin\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "No conflicts for your bin dependencies", + "keywords": [ + "composer", + "conflict", + "dependency", + "executable", + "isolation", + "tool" + ], + "support": { + "issues": "https://github.com/bamarni/composer-bin-plugin/issues", + "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.8.2" + }, + "time": "2022-10-31T08:38:03+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "sebastiankrupinski/jmap-client-php": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.0 <=8.3" + }, + "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.3.0" +} diff --git a/lib/Exception/JmapUnknownMethod.php b/lib/Exception/JmapUnknownMethod.php new file mode 100644 index 0000000..a3752dd --- /dev/null +++ b/lib/Exception/JmapUnknownMethod.php @@ -0,0 +1,13 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Exception; + +class JmapUnknownMethod extends \Exception { +} diff --git a/lib/Jmap/FM/Request/Contacts/ContactEmailParameters.php b/lib/Jmap/FM/Request/Contacts/ContactEmailParameters.php new file mode 100644 index 0000000..1bb0f6b --- /dev/null +++ b/lib/Jmap/FM/Request/Contacts/ContactEmailParameters.php @@ -0,0 +1,40 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Contacts; + +use JmapClient\Requests\RequestParameters; + +class ContactEmailParameters extends RequestParameters { + + public function __construct(&$parameters = null) { + parent::__construct($parameters); + } + + public function type(string $value): self { + $this->parameter('type', $value); + return $this; + } + + public function value(string $value): self { + $this->parameter('value', $value); + return $this; + } + + public function label(string $value): self { + $this->parameter('label', $value); + return $this; + } + + public function default(bool $value): self { + $this->parameter('isDefault', $value); + return $this; + } + +} diff --git a/lib/Jmap/FM/Request/Contacts/ContactLocationParameters.php b/lib/Jmap/FM/Request/Contacts/ContactLocationParameters.php new file mode 100644 index 0000000..7f2ddfa --- /dev/null +++ b/lib/Jmap/FM/Request/Contacts/ContactLocationParameters.php @@ -0,0 +1,60 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Contacts; + +use JmapClient\Requests\RequestParameters; + +class ContactLocationParameters extends RequestParameters { + + public function __construct(&$parameters = null) { + parent::__construct($parameters); + } + + public function type(string $value): self { + $this->parameter('type', $value); + return $this; + } + + public function label(string $value): self { + $this->parameter('label', $value); + return $this; + } + + public function street(string $value): self { + $this->parameter('street', $value); + return $this; + } + + public function locality(string $value): self { + $this->parameter('locality', $value); + return $this; + } + + public function region(string $value): self { + $this->parameter('region', $value); + return $this; + } + + public function code(string $value): self { + $this->parameter('postcode', $value); + return $this; + } + + public function country(string $value): self { + $this->parameter('country', $value); + return $this; + } + + public function default(bool $value): self { + $this->parameter('isDefault', $value); + return $this; + } + +} diff --git a/lib/Jmap/FM/Request/Contacts/ContactOnlineParameters.php b/lib/Jmap/FM/Request/Contacts/ContactOnlineParameters.php new file mode 100644 index 0000000..09ef182 --- /dev/null +++ b/lib/Jmap/FM/Request/Contacts/ContactOnlineParameters.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Contacts; + +use JmapClient\Requests\RequestParameters; + +class ContactOnlineParameters extends RequestParameters { + + public function __construct(&$parameters = null) { + parent::__construct($parameters); + } + + public function type(string $value): self { + $this->parameter('type', $value); + return $this; + } + + public function value(string $value): self { + $this->parameter('value', $value); + return $this; + } + + public function label(string $value): self { + $this->parameter('label', $value); + return $this; + } + +} diff --git a/lib/Jmap/FM/Request/Contacts/ContactParameters.php b/lib/Jmap/FM/Request/Contacts/ContactParameters.php new file mode 100644 index 0000000..8a6d7d3 --- /dev/null +++ b/lib/Jmap/FM/Request/Contacts/ContactParameters.php @@ -0,0 +1,171 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Contacts; + +use JmapClient\Requests\RequestParameters; + +use function PHPUnit\Framework\isEmpty; + +class ContactParameters extends RequestParameters { + + public const DATE_FORMAT_ANNIVERSARY = 'YYYY-MM-DD'; + + public function __construct(&$parameters = null) { + parent::__construct($parameters); + } + + public function in(string $value): self { + if (isEmpty($value)) { + $this->parameter('addressbookId', 'Default'); + } else { + $this->parameter('addressbookId', $value); + } + return $this; + } + + public function id(string $value): self { + $this->parameter('id', $value); + return $this; + } + + public function uid(string $value): self { + $this->parameter('uid', $value); + return $this; + } + + public function type(string $value): self { + $this->parameter('kind', $value); + return $this; + } + + public function nameLast(string $value): self { + $this->parameter('lastName', $value); + return $this; + } + + public function nameFirst(string $value): self { + $this->parameter('firstName', $value); + return $this; + } + + public function namePrefix(string $value): self { + $this->parameter('prefix', $value); + return $this; + } + + public function nameSuffix(string $value): self { + $this->parameter('suffix', $value); + return $this; + } + + public function organizationName(string $value): self { + $this->parameter('company', $value); + return $this; + } + + public function organizationUnit(string $value): self { + $this->parameter('department', $value); + return $this; + } + + public function title(string $value): self { + $this->parameter('jobTitle', $value); + return $this; + } + + public function notes(string $value): self { + $this->parameter('notes', $value); + return $this; + } + + public function priority(int $value): self { + $this->parameter('importance', $value); + return $this; + } + + public function birthDay(string $value): self { + $this->parameter('birthday', $value); + return $this; + } + + public function nuptialDay(string $value): self { + $this->parameter('anniversary', $value); + return $this; + } + + public function email(?int $id = null): ContactEmailParameters { + // Ensure the parameter exists + if (!isset($this->_parameters->emails)) { + $this->_parameters->emails = []; + } + // If an ID is provided, ensure the specific email entry exists + if ($id !== null) { + if (!isset($this->_parameters->emails[$id])) { + $this->_parameters->emails[$id] = new \stdClass(); + } + return new ContactEmailParameters($this->_parameters->emails[$id]); + } + // If no ID is provided, create a new email entry + $this->_parameters->emails[] = new \stdClass(); + return new ContactEmailParameters(end($this->_parameters->emails)); + } + + public function phone(?int $id = null): ContactPhoneParameters { + // Ensure the parameter exists + if (!isset($this->_parameters->phones)) { + $this->_parameters->phones = []; + } + // If an ID is provided, ensure the specific phone entry exists + if ($id !== null) { + if (!isset($this->_parameters->phones[$id])) { + $this->_parameters->phones[$id] = new \stdClass(); + } + return new ContactPhoneParameters($this->_parameters->phones[$id]); + } + // If no ID is provided, create a new phone entry + $this->_parameters->phones[] = new \stdClass(); + return new ContactPhoneParameters(end($this->_parameters->phones)); + } + + public function location(?int $id = null): ContactLocationParameters { + // Ensure the parameter exists + if (!isset($this->_parameters->addresses)) { + $this->_parameters->addresses = []; + } + // If an ID is provided, ensure the specific address entry exists + if ($id !== null) { + if (!isset($this->_parameters->addresses[$id])) { + $this->_parameters->addresses[$id] = new \stdClass(); + } + return new ContactLocationParameters($this->_parameters->addresses[$id]); + } + // If no ID is provided, create a new address entry + $this->_parameters->addresses[] = new \stdClass(); + return new ContactLocationParameters(end($this->_parameters->addresses)); + } + + public function online(?int $id = null): ContactOnlineParameters { + // Ensure the parameter exists + if (!isset($this->_parameters->addresses)) { + $this->_parameters->addresses = []; + } + // If an ID is provided, ensure the specific address entry exists + if ($id !== null) { + if (!isset($this->_parameters->addresses[$id])) { + $this->_parameters->addresses[$id] = new \stdClass(); + } + return new ContactOnlineParameters($this->_parameters->addresses[$id]); + } + // If no ID is provided, create a new address entry + $this->_parameters->addresses[] = new \stdClass(); + return new ContactOnlineParameters(end($this->_parameters->addresses)); + } + +} diff --git a/lib/Jmap/FM/Request/Contacts/ContactPhoneParameters.php b/lib/Jmap/FM/Request/Contacts/ContactPhoneParameters.php new file mode 100644 index 0000000..865d933 --- /dev/null +++ b/lib/Jmap/FM/Request/Contacts/ContactPhoneParameters.php @@ -0,0 +1,40 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Contacts; + +use JmapClient\Requests\RequestParameters; + +class ContactPhoneParameters extends RequestParameters { + + public function __construct(&$parameters = null) { + parent::__construct($parameters); + } + + public function type(string $value): self { + $this->parameter('type', $value); + return $this; + } + + public function value(string $value): self { + $this->parameter('value', $value); + return $this; + } + + public function label(string $value): self { + $this->parameter('label', $value); + return $this; + } + + public function default(bool $value): self { + $this->parameter('isDefault', $value); + return $this; + } + +} diff --git a/lib/Jmap/FM/Request/Events/EventFilter.php b/lib/Jmap/FM/Request/Events/EventFilter.php new file mode 100644 index 0000000..25ce749 --- /dev/null +++ b/lib/Jmap/FM/Request/Events/EventFilter.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Request\Events; + +use JmapClient\Requests\Calendar\EventFilter as EventFilterJmap; + +class EventFilter extends EventFilterJmap { + + public function in(string $value): self { + + $this->condition('inCalendars', [$value]); + + return $this; + + } + +} diff --git a/lib/Jmap/FM/Response/Contacts/ContactEmailParameters.php b/lib/Jmap/FM/Response/Contacts/ContactEmailParameters.php new file mode 100644 index 0000000..9212e4a --- /dev/null +++ b/lib/Jmap/FM/Response/Contacts/ContactEmailParameters.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Response\Contacts; + +use JmapClient\Responses\ResponseParameters; + +class ContactEmailParameters extends ResponseParameters { + + public function type(): ?string { + return $this->parameter('type') ?? 'personal'; + } + + public function value(): ?string { + return $this->parameter('value'); + } + + public function label(): ?string { + return $this->parameter('label'); + } + + public function default(): bool { + return $this->parameter('isDefault'); + } + +} diff --git a/lib/Jmap/FM/Response/Contacts/ContactLocationParameters.php b/lib/Jmap/FM/Response/Contacts/ContactLocationParameters.php new file mode 100644 index 0000000..974c198 --- /dev/null +++ b/lib/Jmap/FM/Response/Contacts/ContactLocationParameters.php @@ -0,0 +1,44 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Response\Contacts; + +use JmapClient\Responses\ResponseParameters; + +class ContactLocationParameters extends ResponseParameters { + + public function type(): ?string { + return $this->parameter('type') ?? 'home'; + } + + public function label(): ?string { + return $this->parameter('label'); + } + + public function street(): ?string { + return $this->parameter('street'); + } + + public function locality(): ?string { + return $this->parameter('locality'); + } + + public function region(): ?string { + return $this->parameter('region'); + } + + public function code(): ?string { + return $this->parameter('postcode'); + } + + public function country(): ?string { + return $this->parameter('country'); + } + +} diff --git a/lib/Jmap/FM/Response/Contacts/ContactOnlineParameters.php b/lib/Jmap/FM/Response/Contacts/ContactOnlineParameters.php new file mode 100644 index 0000000..3055fcf --- /dev/null +++ b/lib/Jmap/FM/Response/Contacts/ContactOnlineParameters.php @@ -0,0 +1,28 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Response\Contacts; + +use JmapClient\Responses\ResponseParameters; + +class ContactOnlineParameters extends ResponseParameters { + + public function type(): ?string { + return $this->parameter('type') ?? 'other'; + } + + public function value(): ?string { + return $this->parameter('value'); + } + + public function label(): ?string { + return $this->parameter('label'); + } + +} diff --git a/lib/Jmap/FM/Response/Contacts/ContactParameters.php b/lib/Jmap/FM/Response/Contacts/ContactParameters.php new file mode 100644 index 0000000..aa29553 --- /dev/null +++ b/lib/Jmap/FM/Response/Contacts/ContactParameters.php @@ -0,0 +1,115 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Response\Contacts; + +use JmapClient\Responses\ResponseParameters; + +class ContactParameters extends ResponseParameters { + + /* Metadata Properties */ + + public function in(): ?array { + // return value of parameter + $value = $this->parameter('addressbookId'); + if ($value !== null) { + return [$value]; + } + return null; + } + + public function id(): ?string { + return $this->parameter('id'); + } + + public function uid(): ?string { + return $this->parameter('uid'); + } + + public function type(): ?string { + return $this->parameter('kind') ?? 'individual'; + } + + public function nameLast(): ?string { + return $this->parameter('lastName'); + } + + public function nameFirst(): ?string { + return $this->parameter('firstName'); + } + + public function namePrefix(): ?string { + return $this->parameter('prefix'); + } + + public function nameSuffix(): ?string { + return $this->parameter('suffix'); + } + + public function organizationName(): ?string { + return $this->parameter('company'); + } + + public function organizationUnit(): ?string { + return $this->parameter('department'); + } + + public function title(): ?string { + return $this->parameter('jobTitle'); + } + + public function notes(): ?string { + return $this->parameter('notes'); + } + + public function priority(): ?int { + return (int)$this->parameter('importance'); + } + + public function birthDay(): ?string { + return $this->parameter('birthday'); + } + + public function nuptialDay(): ?string { + return $this->parameter('anniversary'); + } + + public function email(): array { + $collection = $this->parameter('emails') ?? []; + foreach ($collection as $key => $data) { + $collection[$key] = new ContactEmailParameters($data); + } + return $collection; + } + + public function phone(): array { + $collection = $this->parameter('phones') ?? []; + foreach ($collection as $key => $data) { + $collection[$key] = new ContactPhoneParameters($data); + } + return $collection; + } + + public function location(): array { + $collection = $this->parameter('addresses') ?? []; + foreach ($collection as $key => $data) { + $collection[$key] = new ContactLocationParameters($data); + } + return $collection; + } + + public function online(): array { + $collection = $this->parameter('online') ?? []; + foreach ($collection as $key => $data) { + $collection[$key] = new ContactOnlineParameters($data); + } + return $collection; + } + +} diff --git a/lib/Jmap/FM/Response/Contacts/ContactPhoneParameters.php b/lib/Jmap/FM/Response/Contacts/ContactPhoneParameters.php new file mode 100644 index 0000000..8c55507 --- /dev/null +++ b/lib/Jmap/FM/Response/Contacts/ContactPhoneParameters.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Jmap\FM\Response\Contacts; + +use JmapClient\Responses\ResponseParameters; + +class ContactPhoneParameters extends ResponseParameters { + + public function type(): ?string { + return $this->parameter('type') ?? 'home'; + } + + public function value(): ?string { + return $this->parameter('value'); + } + + public function label(): ?string { + return $this->parameter('label'); + } + + public function default(): bool { + return (bool)$this->parameter('isDefault'); + } + +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..38aff7b --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,86 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc; + +use KTXC\Resource\ProviderManager; +use KTXF\Module\ModuleBrowserInterface; +use KTXF\Module\ModuleInstanceAbstract; +use KTXF\Resource\Provider\ProviderInterface; +use KTXM\ProviderJmapc\Providers\Mail\Provider as MailProvider; +use KTXM\ProviderJmapc\Providers\Chrono\Provider as ChronoProvider; +use KTXM\ProviderJmapc\Providers\People\Provider as PeopleProvider; + +/** + * JMAP Client Provider Module + * + * Provides mail, calendar, and contacts services via JMAP protocol. + */ +class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface +{ + public function __construct( + private readonly ProviderManager $providerManager, + ) {} + + public function handle(): string + { + return 'provider_jmapc'; + } + + public function label(): string + { + return 'JMAP Provider'; + } + + public function author(): string + { + return 'Ktrix'; + } + + public function description(): string + { + return 'JMAP provider module for Ktrix - provides mail, calendar, and contacts via JMAP protocol'; + } + + public function version(): string + { + return '0.0.1'; + } + + public function permissions(): array + { + return [ + 'provider_jmapc' => [ + 'label' => 'Access JMAP Provider', + 'description' => 'View and access the JMAP provider module', + 'group' => 'Providers' + ], + ]; + } + + public function boot(): void + { + // Register JMAP providers - all three share the same service store + $this->providerManager->register(ProviderInterface::TYPE_MAIL, 'jmap', MailProvider::class); + $this->providerManager->register(ProviderInterface::TYPE_CHRONO, 'jmap', ChronoProvider::class); + $this->providerManager->register(ProviderInterface::TYPE_PEOPLE, 'jmap', PeopleProvider::class); + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'ProviderJmapc', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description(), + 'boot' => 'static/module.mjs', + ]; + } +} diff --git a/lib/Objects/Mail/RemoteCollectionFilter.php b/lib/Objects/Mail/RemoteCollectionFilter.php new file mode 100644 index 0000000..78fea5c --- /dev/null +++ b/lib/Objects/Mail/RemoteCollectionFilter.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Objects\Mail; + +use KTXF\Resource\Filter\Filter; + +class RemoteCollectionFilter extends Filter { + + protected array $attributes = [ + 'in' => true, + 'name' => true, + 'role' => true, + 'hasRoles' => true, + 'subscribed' => true, + ]; + +} diff --git a/lib/Providers/Chrono/Provider.php b/lib/Providers/Chrono/Provider.php new file mode 100644 index 0000000..d04c6ed --- /dev/null +++ b/lib/Providers/Chrono/Provider.php @@ -0,0 +1,134 @@ + true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + self::CAPABILITY_SERVICE_FRESH => true, + self::CAPABILITY_SERVICE_CREATE => true, + self::CAPABILITY_SERVICE_MODIFY => true, + self::CAPABILITY_SERVICE_DESTROY => true, + ]; + } + + public function id(): string + { + return 'jmap'; + } + + public function label(): string + { + return 'JMAP Calendar Provider'; + } + + public function serviceList(string $tenantId, string $userId, array $filter): array + { + // Filter by Calendar capability + return $this->serviceStore->listServices($tenantId, $userId, [self::CALENDAR_CAPABILITY]); + } + + public function serviceExtant(string $tenantId, string $userId, array $identifiers): array + { + $result = []; + foreach ($identifiers as $id) { + $service = $this->serviceStore->getService($tenantId, $userId, $id); + $result[$id] = $service !== null; + } + return $result; + } + + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase + { + return $this->serviceStore->getService($tenantId, $userId, $identifier); + } + + public function serviceFresh(string $userId = ''): IServiceBase + { + return new Service( + scope: ServiceScope::User, + enabled: true, + ); + } + + public function serviceCreate(string $userId, IServiceBase $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + throw new \RuntimeException('Use Mail Provider interface for service creation'); + } + + public function serviceModify(string $userId, IServiceBase $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + throw new \RuntimeException('Use Mail Provider interface for service modification'); + } + + public function serviceDestroy(string $userId, IServiceBase $service): bool + { + if (!($service instanceof Service)) { + return false; + } + + throw new \RuntimeException('Use Mail Provider interface for service destruction'); + } + + public function jsonSerialize(): array + { + return [ + '@type' => 'chrono.provider', + 'id' => $this->id(), + 'label' => $this->label(), + 'capabilities' => $this->capabilities(), + ]; + } + + public function jsonDeserialize(array|string $data): static + { + return $this; + } +} diff --git a/lib/Providers/Mail/CollectionProperties.php b/lib/Providers/Mail/CollectionProperties.php new file mode 100644 index 0000000..280e099 --- /dev/null +++ b/lib/Providers/Mail/CollectionProperties.php @@ -0,0 +1,71 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Collection\CollectionPropertiesMutableAbstract; + +/** + * Mail Collection Properties Implementation + */ +class CollectionProperties extends CollectionPropertiesMutableAbstract { + + /** + * Convert JMAP parameters array to mail collection properties object + * + * @param array $parameters JMAP parameters array + */ + public function fromJmap(array $parameters): static { + + if (isset($parameters['totalEmails'])) { + $this->data['total'] = $parameters['totalEmails']; + } + if (isset($parameters['unreadEmails'])) { + $this->data['unread'] = $parameters['unreadEmails']; + } + if (isset($parameters['name'])) { + $this->data['label'] = $parameters['name']; + } + if (isset($parameters['role'])) { + $this->data['role'] = $parameters['role']; + } + if (isset($parameters['sortOrder'])) { + $this->data['rank'] = $parameters['sortOrder']; + } + if (isset($parameters['isSubscribed'])) { + $this->data['subscribed'] = $parameters['isSubscribed']; + } + + return $this; + } + + /** + * Convert mail collection properties object to JMAP parameters array + */ + public function toJmap(): array { + + $parameters = []; + + if (isset($this->data['label'])) { + $parameters['name'] = $this->data['label']; + } + if (isset($this->data['role'])) { + $parameters['role'] = $this->data['role']; + } + if (isset($this->data['rank'])) { + $parameters['sortOrder'] = $this->data['rank']; + } + if (isset($this->data['subscribed'])) { + $parameters['isSubscribed'] = $this->data['subscribed']; + } + + return $parameters; + } + +} diff --git a/lib/Providers/Mail/CollectionResource.php b/lib/Providers/Mail/CollectionResource.php new file mode 100644 index 0000000..072b994 --- /dev/null +++ b/lib/Providers/Mail/CollectionResource.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Collection\CollectionMutableAbstract; + +/** + * Mail Collection Resource Implementation + */ +class CollectionResource extends CollectionMutableAbstract { + + public function __construct( + string $provider = 'jmapc', + string|int|null $service = null, + ) { + parent::__construct($provider, $service); + } + + /** + * Converts JMAP parameters array to mail collection object + * + * @param array $parameters JMAP parameters array + */ + public function fromJmap(array $parameters): static { + + if (isset($parameters['parentId'])) { + $this->data['collection'] = $parameters['parentId']; + } + if (isset($parameters['id'])) { + $this->data['identifier'] = $parameters['id']; + } + if (isset($parameters['signature'])) { + $this->data['signature'] = $parameters['signature']; + } + + $this->getProperties()->fromJmap($parameters); + + return $this; + } + + /** + * Convert mail collection object to JMAP parameters array + */ + public function toJmap(): array { + + $parameters = []; + + if (isset($this->data['collection'])) { + $parameters['parentId'] = $this->data['collection']; + } + if (isset($this->data['identifier'])) { + $parameters['id'] = $this->data['identifier']; + } + + $parameters = array_merge($parameters, $this->getProperties()->toJmap()); + + return $parameters; + } + + /** + * @inheritDoc + */ + public function getProperties(): CollectionProperties { + if (!isset($this->properties)) { + $this->properties = new CollectionProperties([]); + } + return $this->properties; + } + +} diff --git a/lib/Providers/Mail/EntityResource.php b/lib/Providers/Mail/EntityResource.php new file mode 100644 index 0000000..7dbcc48 --- /dev/null +++ b/lib/Providers/Mail/EntityResource.php @@ -0,0 +1,82 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Entity\EntityMutableAbstract; + +/** + * Mail Entity Resource Implementation + */ +class EntityResource extends EntityMutableAbstract { + + public function __construct( + string $provider = 'jmapc', + string|int|null $service = null, + ) { + parent::__construct($provider, $service); + } + + /** + * Convert JMAP parameters array to mail entity object + * + * @param array $parameters JMAP parameters array + */ + public function fromJmap(array $parameters): static { + + if (isset($parameters['mailboxIds'])) { + $this->data['collection'] = array_keys($parameters['mailboxIds'])[0]; + } + if (isset($parameters['id'])) { + $this->data['identifier'] = $parameters['id']; + } + if (isset($parameters['signature'])) { + $this->data['signature'] = $parameters['signature']; + } + if (isset($parameters['receivedAt']) || isset($parameters['sentAt'])) { + $this->data['created'] = $parameters['receivedAt'] ?? $parameters['sentAt']; + } + if (isset($parameters['updated'])) { + $this->data['modified'] = $parameters['updated']; + } + + $this->getProperties()->fromJmap($parameters); + + return $this; + } + + /** + * Convert mail entity object to JMAP parameters array + */ + public function toJmap(): array { + + $parameters = []; + + if (isset($this->data['collection'])) { + $parameters['mailboxIds'] = [$this->data['collection']]; + } + if (isset($this->data['identifier'])) { + $parameters['id'] = $this->data['identifier']; + } + + $parameters = array_merge($parameters, $this->getProperties()->toJmap()); + + return $parameters; + } + + /** + * @inheritDoc + */ + public function getProperties(): MessageProperties { + if (!isset($this->properties)) { + $this->properties = new MessageProperties([]); + } + return $this->properties; + } +} diff --git a/lib/Providers/Mail/MessageAttachment.php b/lib/Providers/Mail/MessageAttachment.php new file mode 100644 index 0000000..90c712a --- /dev/null +++ b/lib/Providers/Mail/MessageAttachment.php @@ -0,0 +1,186 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +/** + * Mail Attachment Object + * + * @since 30.0.0 + */ +class MessageAttachment implements MessagePart { + protected MessagePart $_meta; + protected ?string $_contents = null; + + public function __construct(?MessagePart $meta = null, ?string $contents = null) { + // determine if meta data exists + // if meta data is missing create new + if ($meta === null) { + $meta = new MessagePart(); + $meta->setDisposition('attachment'); + $meta->setType('application/octet-stream'); + } + $this->setParameters($meta); + // determine if attachment contents exists + // if contents exists set the contents + if ($contents !== null) { + $this->setContents($contents); + } + } + + /** + * sets the attachments parameters + * + * @since 1.0.0 + * + * @param MessagePart|null $meta collection of all message parameters + * + * @return self return this object for command chaining + */ + public function setParameters(?MessagePart $meta): self { + + // replace meta data store + $this->_meta = $meta; + // return this object for command chaining + return $this; + } + + /** + * gets the attachments of this message + * + * @since 1.0.0 + * + * @return array collection of all message parameters + */ + public function getParameters(): MessagePart { + // evaluate if data store field exists and return value(s) or null otherwise + return $this->_meta; + } + + /** + * arbitrary unique text string identifying this message + * + * @since 1.0.0 + * + * @return string id of this message + */ + public function id(): string { + // return id of message + return $this->_meta->getBlobId(); + } + + /** + * sets the attachment file name + * + * @since 30.0.0 + * + * @param string $value file name (e.g example.txt) + * + * @return self return this object for command chaining + */ + public function setName(string $value): self { + $this->_meta->setName($value); + return $this; + } + + /** + * gets the attachment file name + * + * @since 30.0.0 + * + * @return string | null returns the attachment file name or null if not set + */ + public function getName(): ?string { + return $this->_meta->getName(); + } + + /** + * sets the attachment mime type + * + * @since 30.0.0 + * + * @param string $value mime type (e.g. text/plain) + * + * @return self return this object for command chaining + */ + public function setType(string $value): self { + $this->_meta->setType($value); + return $this; + } + + /** + * gets the attachment mime type + * + * @since 30.0.0 + * + * @return string | null returns the attachment mime type or null if not set + */ + public function getType(): ?string { + return $this->_meta->getType(); + } + + /** + * sets the attachment contents (actual data) + * + * @since 30.0.0 + * + * @param string $value binary contents of file + * + * @return self return this object for command chaining + */ + public function setContents(string $value): self { + $this->_contents = $value; + return $this; + } + + /** + * gets the attachment contents (actual data) + * + * @since 30.0.0 + * + * @return string | null returns the attachment contents or null if not set + */ + public function getContents(): ?string { + return $this->_contents; + } + + /** + * sets the embedded status of the attachment + * + * @since 30.0.0 + * + * @param bool $value true - embedded / false - not embedded + * + * @return self return this object for command chaining + */ + public function setEmbedded(bool $value): self { + if ($value) { + $this->_meta->setDisposition('inline'); + } else { + $this->_meta->setDisposition('attachment'); + } + return $this; + } + + /** + * gets the embedded status of the attachment + * + * @since 30.0.0 + * + * @return bool embedded status of the attachment + */ + public function getEmbedded(): bool { + if ($this->_meta->getDisposition() === 'inline') { + return true; + } else { + return false; + } + } + +} diff --git a/lib/Providers/Mail/MessagePart.php b/lib/Providers/Mail/MessagePart.php new file mode 100644 index 0000000..27c2132 --- /dev/null +++ b/lib/Providers/Mail/MessagePart.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Object\MessagePartMutableAbstract; + +class MessagePart extends MessagePartMutableAbstract { + + /** + * convert jmap parameters collection to message object + * + * @since 1.0.0 + * + * @param array $parameters jmap parameters collection + * @param bool $amend flag merged or replaced parameters + */ + public function fromJmap(array $parameters, bool $amend = false): self { + + if ($amend) { + // merge parameters with existing ones + $this->data = array_merge($this->data, $parameters); + } else { + // replace parameters store + $this->data = $parameters; + } + + // determine if parameters contains subparts + // if subParts exist convert them to a MessagePart object + // and remove subParts parameter + if (is_array($this->data['subParts'])) { + foreach ($this->data['subParts'] as $key => $entry) { + if (is_object($entry)) { + $entry = get_object_vars($entry); + } + $this->parts[$key] = (new MessagePart($parameters))->fromJmap($entry); + } + unset($this->data['subParts']); + } + + return $this; + } + + /** + * convert message object to jmap parameters array + * + * @since 1.0.0 + * + * @return array collection of all message parameters + */ + public function toJmap(): array { + + // copy parameter value + $parameters = $this->data; + // determine if this MessagePart has any sub MessageParts + // if sub MessageParts exist retrieve sub MessagePart parameters + // and add them to the subParts parameters, otherwise set the subParts parameter to nothing + if (count($this->parts) > 0) { + $parameters['subParts'] = []; + foreach ($this->parts as $entry) { + if ($entry instanceof MessagePart) { + $parameters['subParts'][] = $entry->toJmap(); + } + } + } else { + $parameters['subParts'] = null; + } + + return $parameters; + + } + +} diff --git a/lib/Providers/Mail/MessageProperties.php b/lib/Providers/Mail/MessageProperties.php new file mode 100644 index 0000000..dbf91cd --- /dev/null +++ b/lib/Providers/Mail/MessageProperties.php @@ -0,0 +1,264 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Object\MessagePropertiesMutableAbstract; + +/** + * Mail Message Properties Implementation + */ +class MessageProperties extends MessagePropertiesMutableAbstract { + + /** + * Convert JMAP parameters array to mail message properties object + * + * @param array $parameters JMAP parameters array + */ + public function fromJmap(array $parameters): static { + + if (isset($parameters['messageId'])) { + $this->data['urid'] = $parameters['messageId'][0]; + } + if (isset($parameters['size'])) { + $this->data['size'] = $parameters['size']; + } + if (isset($parameters['receivedAt'])) { + $this->data['receivedDate'] = $parameters['receivedAt']; + } + if (isset($parameters['sentAt'])) { + $this->data['date'] = $parameters['sentAt']; + } + if (isset($parameters['inReplyTo'])) { + $this->data['inReplyTo'] = $parameters['inReplyTo']; + } + if (isset($parameters['references'])) { + $this->data['references'] = is_array($parameters['references']) ? $parameters['references'] : []; + } + if (isset($parameters['subject'])) { + $this->data['subject'] = $parameters['subject']; + } + if (isset($parameters['preview'])) { + $this->data['snippet'] = $parameters['preview']; + } + if (isset($parameters['sender'])) { + $this->data['sender'] = $parameters['sender']; + } + if (isset($parameters['from']) && is_array($parameters['from']) && !empty($parameters['from'])) { + $this->data['from'] = [ + 'address' => $parameters['from'][0]['email'] ?? '', + 'label' => $parameters['from'][0]['name'] ?? null + ]; + } + if (isset($parameters['to']) && is_array($parameters['to'])) { + $this->data['to'] = []; + foreach ($parameters['to'] as $addr) { + $this->data['to'][] = [ + 'address' => $addr['email'] ?? '', + 'label' => $addr['name'] ?? null + ]; + } + } + if (isset($parameters['cc']) && is_array($parameters['cc'])) { + $this->data['cc'] = []; + foreach ($parameters['cc'] as $addr) { + $this->data['cc'][] = [ + 'address' => $addr['email'] ?? '', + 'label' => $addr['name'] ?? null + ]; + } + } + if (isset($parameters['bcc']) && is_array($parameters['bcc'])) { + $this->data['bcc'] = []; + foreach ($parameters['bcc'] as $addr) { + $this->data['bcc'][] = [ + 'address' => $addr['email'] ?? '', + 'label' => $addr['name'] ?? null + ]; + } + } + if (isset($parameters['replyTo']) && is_array($parameters['replyTo'])) { + $this->data['replyTo'] = []; + foreach ($parameters['replyTo'] as $addr) { + $this->data['replyTo'][] = [ + 'address' => $addr['email'] ?? '', + 'label' => $addr['name'] ?? null + ]; + } + } + if (isset($parameters['keywords']) && is_array($parameters['keywords'])) { + $this->data['flags'] = []; + foreach ($parameters['keywords'] as $keyword => $value) { + $flag = match($keyword) { + '$seen' => 'read', + '$flagged' => 'flagged', + '$answered' => 'answered', + '$draft' => 'draft', + '$deleted' => 'deleted', + default => $keyword + }; + $this->data['flags'][$flag] = $value; + } + } + if (isset($parameters['bodyStructure'])) { + $this->data['body'] = $parameters['bodyStructure']; + // Recursively add content from bodyValues to matching parts + if (isset($parameters['bodyValues']) && is_array($parameters['bodyValues'])) { + $addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) { + // If this part has a partId and matching bodyValue, add content + if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) { + $structure['content'] = $bodyValues[$structure['partId']]['value'] ?? null; + } + // Recursively process subParts + if (isset($structure['subParts']) && is_array($structure['subParts'])) { + foreach ($structure['subParts'] as &$subPart) { + $addContentToParts($subPart, $bodyValues); + } + } + }; + + $addContentToParts($this->data['body'], $parameters['bodyValues']); + } + } + if (isset($parameters['headers']) && is_array($parameters['headers'])) { + $this->data['headers'] = $parameters['headers']; + } + if (isset($parameters['attachments'])) { + $this->data['attachments'] = $parameters['attachments']; + } + + return $this; + } + + /** + * Convert mail message properties object to JMAP parameters array + */ + public function toJmap(): array { + + $parameters = []; + + if (isset($this->data['urid'])) { + $parameters['messageId'] = [$this->data['urid']]; + } + if (isset($this->data['size'])) { + $parameters['size'] = $this->data['size']; + } + if (isset($this->data['receivedDate'])) { + $parameters['receivedAt'] = $this->data['receivedDate']; + } + if (isset($this->data['date'])) { + $parameters['sentAt'] = $this->data['date']; + } + if (isset($this->data['inReplyTo'])) { + $parameters['inReplyTo'] = $this->data['inReplyTo']; + } + if (isset($this->data['references'])) { + $parameters['references'] = $this->data['references']; + } + if (isset($this->data['subject'])) { + $parameters['subject'] = $this->data['subject']; + } + if (isset($this->data['snippet'])) { + $parameters['preview'] = $this->data['snippet']; + } + if (isset($this->data['sender'])) { + $parameters['sender'] = $this->data['sender']; + } + if (isset($this->data['from'])) { + $parameters['from'] = [[ + 'email' => $this->data['from']['address'] ?? '', + 'name' => $this->data['from']['label'] ?? null + ]]; + } + if (isset($this->data['to'])) { + $parameters['to'] = []; + foreach ($this->data['to'] as $addr) { + $parameters['to'][] = [ + 'email' => $addr['address'] ?? '', + 'name' => $addr['label'] ?? null + ]; + } + } + if (isset($this->data['cc'])) { + $parameters['cc'] = []; + foreach ($this->data['cc'] as $addr) { + $parameters['cc'][] = [ + 'email' => $addr['address'] ?? '', + 'name' => $addr['label'] ?? null + ]; + } + } + if (isset($this->data['bcc'])) { + $parameters['bcc'] = []; + foreach ($this->data['bcc'] as $addr) { + $parameters['bcc'][] = [ + 'email' => $addr['address'] ?? '', + 'name' => $addr['label'] ?? null + ]; + } + } + if (isset($this->data['replyTo'])) { + $parameters['replyTo'] = []; + foreach ($this->data['replyTo'] as $addr) { + $parameters['replyTo'][] = [ + 'email' => $addr['address'] ?? '', + 'name' => $addr['label'] ?? null + ]; + } + } + if (isset($this->data['flags'])) { + $parameters['keywords'] = []; + foreach ($this->data['flags'] as $flag => $value) { + $keyword = match($flag) { + 'read' => '$seen', + 'flagged' => '$flagged', + 'answered' => '$answered', + 'draft' => '$draft', + 'deleted' => '$deleted', + default => $flag + }; + $parameters['keywords'][$keyword] = $value; + } + } + if (isset($this->data['bodyStructure'])) { + $parameters['bodyStructure'] = $this->data['bodyStructure']; + } + if (isset($this->data['body'])) { + $parameters['bodyValues'] = []; + + if (isset($this->data['body']['text']['content'])) { + $parameters['bodyValues']['0'] = [ + 'value' => $this->data['body']['text']['content'], + 'isEncodingProblem' => false, + 'isTruncated' => false + ]; + } + + if (isset($this->data['body']['html']['content'])) { + $parameters['bodyValues']['1'] = [ + 'value' => $this->data['body']['html']['content'], + 'isEncodingProblem' => false, + 'isTruncated' => false + ]; + } + } + if (isset($this->data['headers'])) { + $parameters['headers'] = $this->data['headers']; + } + if (isset($this->data['attachments'])) { + $parameters['attachments'] = $this->data['attachments']; + $parameters['hasAttachment'] = !empty($this->data['attachments']); + } else { + $parameters['hasAttachment'] = false; + } + + return $parameters; + } + +} diff --git a/lib/Providers/Mail/Provider.php b/lib/Providers/Mail/Provider.php new file mode 100644 index 0000000..f838e4a --- /dev/null +++ b/lib/Providers/Mail/Provider.php @@ -0,0 +1,212 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Provider\ProviderBaseInterface; +use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; +use KTXF\Mail\Provider\ProviderServiceMutateInterface; +use KTXF\Mail\Provider\ProviderServiceTestInterface; +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Provider\ResourceServiceMutateInterface; +use KTXM\ProviderJmapc\Service\Discovery; +use KTXM\ProviderJmapc\Service\Remote\RemoteService; +use KTXM\ProviderJmapc\Stores\ServiceStore; + +/** + * JMAP Mail Provider + * + * Provides Mail services via JMAP protocol. + * Filters services by urn:ietf:params:jmap:mail capability. + */ +class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface +{ + + public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; + protected const PROVIDER_IDENTIFIER = 'jmap'; + protected const PROVIDER_LABEL = 'JMAP Mail Provider'; + protected const PROVIDER_DESCRIPTION = 'Provides mail services via JMAP protocol (RFC 8620)'; + protected const PROVIDER_ICON = 'mdi-email-sync'; + + protected array $providerAbilities = [ + self::CAPABILITY_SERVICE_LIST => true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + self::CAPABILITY_SERVICE_CREATE => true, + self::CAPABILITY_SERVICE_MODIFY => true, + self::CAPABILITY_SERVICE_DESTROY => true, + self::CAPABILITY_SERVICE_TEST => true, + ]; + + public function __construct( + private readonly ServiceStore $serviceStore, + ) {} + + public function jsonSerialize(): array + { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, + self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, + ]; + } + + public function jsonDeserialize(array|string $data): static + { + return $this; + } + + public function type(): string + { + return self::TYPE_MAIL; + } + + public function identifier(): string + { + return self::PROVIDER_IDENTIFIER; + } + + public function label(): string + { + return self::PROVIDER_LABEL; + } + + public function description(): string + { + return self::PROVIDER_DESCRIPTION; + } + + public function icon(): string + { + return self::PROVIDER_ICON; + } + + public function capable(string $value): bool + { + return !empty($this->providerAbilities[$value]); + } + + public function capabilities(): array + { + return $this->providerAbilities; + } + + public function serviceList(string $tenantId, string $userId, array $filter = []): array + { + $list = $this->serviceStore->list($tenantId, $userId, $filter); + foreach ($list as $entry) { + $service = new Service(); + $service->fromStore($entry); + $list[$service->identifier()] = $service; + } + return $list; + } + + public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array + { + return $this->serviceStore->extant($tenantId, $userId, $identifiers); + } + + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service + { + return $this->serviceStore->fetch($tenantId, $userId, $identifier); + } + + public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service + { + /** @var Service[] $services */ + $services = $this->serviceList($tenantId, $userId); + foreach ($services as $service) { + if ($service->hasAddress($address)) { + return $service; + } + } + return null; + } + + public function serviceFresh(): ResourceServiceMutateInterface + { + return new Service(); + } + + public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + $created = $this->serviceStore->create($tenantId, $userId, $service); + return (string) $created->identifier(); + } + + public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + $updated = $this->serviceStore->modify($tenantId, $userId, $service); + return (string) $updated->identifier(); + } + + public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool + { + if (!($service instanceof Service)) { + return false; + } + + return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); + } + + public function serviceDiscover( + string $tenantId, + string $userId, + string $identity, + ?string $location = null, + ?string $secret = null + ): ResourceServiceLocationInterface|null { + $discovery = new Discovery(); + + // TODO: Make SSL verification configurable based on tenant/user settings + $verifySSL = true; + + return $discovery->discover($identity, $location, $secret, $verifySSL); + } + + public function serviceTest(ServiceBaseInterface $service, array $options = []): array { + $startTime = microtime(true); + + try { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + $client = RemoteService::freshClient($service); + $session = $client->connect(); + + $latency = round((microtime(true) - $startTime) * 1000); // ms4 + + return [ + 'success' => true, + 'message' => 'JMAP connection successful' + . ' (Account ID: ' . ($session->username() ?? 'N/A') . ')' + . ' (Latency: ' . $latency . ' ms)', + ]; + } catch (\Exception $e) { + $latency = round((microtime(true) - $startTime) * 1000); + return [ + 'success' => false, + 'message' => 'Test failed: ' . $e->getMessage(), + ]; + } + } + +} diff --git a/lib/Providers/Mail/Service.php b/lib/Providers/Mail/Service.php new file mode 100644 index 0000000..a4e6745 --- /dev/null +++ b/lib/Providers/Mail/Service.php @@ -0,0 +1,535 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers\Mail; + +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionMutableInterface; +use KTXF\Mail\Object\Address; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Mail\Service\ServiceCollectionMutableInterface; +use KTXF\Mail\Service\ServiceConfigurableInterface; +use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Resource\Provider\ResourceServiceIdentityInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\ProviderJmapc\Providers\ServiceIdentityBasic; +use KTXM\ProviderJmapc\Providers\ServiceLocation; +use KTXM\ProviderJmapc\Service\Remote\RemoteMailService; +use KTXM\ProviderJmapc\Service\Remote\RemoteService; + +/** + * JMAP Service + * + * Represents a configured JMAP account + */ +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface +{ + public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; + + private const PROVIDER_IDENTIFIER = 'jmap'; + + private ?string $serviceTenantId = null; + private ?string $serviceUserId = null; + private ?string $serviceIdentifier = null; + private ?string $serviceLabel = null; + private bool $serviceEnabled = false; + private bool $serviceDebug = false; + private string $primaryAddress = ''; + private array $secondaryAddresses = []; + private ?ServiceLocation $location = null; + private ?ServiceIdentityBasic $identity = null; + private array $auxiliary = []; + + private array $serviceAbilities = [ + self::CAPABILITY_COLLECTION_LIST => true, + self::CAPABILITY_COLLECTION_LIST_FILTER => [ + self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:100:256:256', + self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:100:256:256', + ], + self::CAPABILITY_COLLECTION_LIST_SORT => [ + self::CAPABILITY_COLLECTION_SORT_LABEL, + self::CAPABILITY_COLLECTION_SORT_RANK, + ], + self::CAPABILITY_COLLECTION_EXTANT => true, + self::CAPABILITY_COLLECTION_FETCH => true, + self::CAPABILITY_COLLECTION_CREATE => true, + self::CAPABILITY_COLLECTION_MODIFY => true, + self::CAPABILITY_COLLECTION_DESTROY => true, + self::CAPABILITY_ENTITY_LIST => true, + self::CAPABILITY_ENTITY_LIST_FILTER => [ + self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_CC => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_BCC => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 'd:0:32:32', + self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 'd:0:16:16', + self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', + self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', + ], + self::CAPABILITY_ENTITY_LIST_SORT => [ + self::CAPABILITY_ENTITY_SORT_FROM, + self::CAPABILITY_ENTITY_SORT_TO, + self::CAPABILITY_ENTITY_SORT_SUBJECT, + self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED, + self::CAPABILITY_ENTITY_SORT_DATE_SENT, + self::CAPABILITY_ENTITY_SORT_SIZE, + ], + self::CAPABILITY_ENTITY_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], + self::CAPABILITY_ENTITY_DELTA => true, + self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_FETCH => true, + ]; + + private readonly RemoteMailService $mailService; + + public function __construct( + ) {} + + private function initialize(): void + { + if (!isset($this->mailService)) { + $client = RemoteService::freshClient($this); + $this->mailService = RemoteService::mailService($client); + } + } + + public function toStore(): array + { + return array_filter([ + 'tid' => $this->serviceTenantId, + 'uid' => $this->serviceUserId, + 'sid' => $this->serviceIdentifier, + 'label' => $this->serviceLabel, + 'enabled' => $this->serviceEnabled, + 'debug' => $this->serviceDebug, + 'primaryAddress' => $this->primaryAddress, + 'secondaryAddresses' => $this->secondaryAddresses, + 'location' => $this->location?->toStore(), + 'identity' => $this->identity?->toStore(), + 'auxiliary' => $this->auxiliary, + ], fn($v) => $v !== null); + } + + public function fromStore(array $data): static + { + $this->serviceTenantId = $data['tid'] ?? null; + $this->serviceUserId = $data['uid'] ?? null; + $this->serviceIdentifier = $data['sid']; + $this->serviceLabel = $data['label'] ?? ''; + $this->serviceEnabled = $data['enabled'] ?? false; + $this->serviceDebug = $data['debug'] ?? false; + + if (isset($data['primaryAddress'])) { + $this->primaryAddress = $data['primaryAddress']; + } + if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { + $this->secondaryAddresses = $data['secondaryAddresses']; + } + + if (isset($data['location'])) { + $this->location = (new ServiceLocation())->fromStore($data['location']); + } + + if (isset($data['identity'])) { + $this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']); + } + if (isset($data['auxiliary']) && is_array($data['auxiliary'])) { + $this->auxiliary = $data['auxiliary']; + } + + return $this; + } + + public function jsonSerialize(): array + { + return array_filter([ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, + self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, + self::JSON_PROPERTY_LABEL => $this->serviceLabel, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, + self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, + self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), + self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), + self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, + ], fn($v) => $v !== null); + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } + + if (isset($data[self::JSON_PROPERTY_LABEL])) { + $this->setLabel($data[self::JSON_PROPERTY_LABEL]); + } + if (isset($data[self::JSON_PROPERTY_ENABLED])) { + $this->setEnabled($data[self::JSON_PROPERTY_ENABLED]); + } + if (isset($data[self::JSON_PROPERTY_LOCATION])) { + $this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION])); + } + if (isset($data[self::JSON_PROPERTY_IDENTITY])) { + $this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY])); + } + if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) { + if (is_array($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]['address'])) { + $this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])); + } + } + if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) { + $this->setSecondaryAddresses(array_map( + fn($addr) => new Address($addr['address']), + $data[self::JSON_PROPERTY_SECONDARY_ADDRESSES] + )); + } + if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) { + $this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]); + } + + return $this; + } + + public function capable(string $value): bool + { + return isset($this->serviceAbilities[$value]); + } + + public function capabilities(): array + { + $caps = []; + foreach (array_keys($this->serviceAbilities) as $cap) { + $caps[$cap] = true; + } + return $caps; + } + + public function provider(): string + { + return self::PROVIDER_IDENTIFIER; + } + + public function identifier(): string|int + { + return $this->serviceIdentifier; + } + + public function getLabel(): string|null + { + return $this->serviceLabel; + } + + public function setLabel(string $label): static + { + $this->serviceLabel = $label; + return $this; + } + + public function getEnabled(): bool + { + return $this->serviceEnabled; + } + + public function setEnabled(bool $enabled): static + { + $this->serviceEnabled = $enabled; + return $this; + } + + public function getPrimaryAddress(): AddressInterface + { + return new Address($this->primaryAddress); + } + + public function setPrimaryAddress(AddressInterface $value): static + { + $this->primaryAddress = $value->getAddress(); + return $this; + } + + public function getSecondaryAddresses(): array + { + return $this->secondaryAddresses; + } + + public function setSecondaryAddresses(array $addresses): static + { + $this->secondaryAddresses = $addresses; + return $this; + } + + public function hasAddress(string $address): bool + { + $address = strtolower(trim($address)); + + if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) { + return true; + } + + foreach ($this->secondaryAddresses as $secondaryAddress) { + if (strtolower($secondaryAddress->getAddress()) === $address) { + return true; + } + } + + return false; + } + + public function getLocation(): ServiceLocation + { + return $this->location; + } + + public function setLocation(ResourceServiceLocationInterface $location): static + { + $this->location = $location; + return $this; + } + + public function freshLocation(string|null $type = null, array $data = []): ServiceLocation + { + $location = new ServiceLocation(); + $location->jsonDeserialize($data); + return $location; + } + + public function getIdentity(): ServiceIdentityBasic + { + return $this->identity; + } + + public function setIdentity(ResourceServiceIdentityInterface $identity): static + { + $this->identity = $identity; + return $this; + } + + public function freshIdentity(string|null $type, array $data = []): ServiceIdentityBasic + { + $identity = new ServiceIdentityBasic(); + $identity->jsonDeserialize($data); + return $identity; + } + + public function getDebug(): bool + { + return $this->serviceDebug; + } + + public function setDebug(bool $debug): static + { + $this->serviceDebug = $debug; + return $this; + } + + public function getAuxiliary(): array + { + return $this->auxiliary; + } + + public function setAuxiliary(array $auxiliary): static + { + $this->auxiliary = $auxiliary; + return $this; + } + + // Collection operations + + public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array + { + $this->initialize(); + + $collections = $this->mailService->collectionList($location, $filter, $sort); + + foreach ($collections as &$collection) { + if (is_array($collection) && isset($collection['id'])) { + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($collection); + $collection = $object; + } + } + + return $collections; + } + + public function collectionListFilter(): Filter + { + return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); + } + + public function collectionListSort(): Sort + { + return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); + } + + public function collectionExtant(string|int ...$identifiers): array + { + $this->initialize(); + + return $this->mailService->collectionExtant(...$identifiers); + } + + public function collectionFetch(string|int $identifier): ?CollectionBaseInterface + { + $this->initialize(); + + $collection = $this->mailService->collectionFetch($identifier); + + if (is_array($collection) && isset($collection['id'])) { + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($collection); + $collection = $object; + } + + return $collection; + } + + public function collectionFresh(): CollectionMutableInterface + { + return new CollectionResource(provider: $this->provider(), service: $this->identifier()); + } + + public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface + { + $this->initialize(); + + if ($collection instanceof CollectionResource === false) { + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->jsonDeserialize($collection->jsonSerialize()); + $collection = $object; + } + + $collection = $collection->toJmap(); + $collection = $this->mailService->collectionCreate($location, $collection, $options); + + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($collection); + + return $object; + } + + public function collectionModify(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface + { + $this->initialize(); + + if ($collection instanceof CollectionResource === false) { + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->jsonDeserialize($collection->jsonSerialize()); + $collection = $object; + } + + $collection = $collection->toJmap(); + $collection = $this->mailService->collectionModify($identifier, $collection); + + $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($collection); + + return $object; + } + + public function collectionDestroy(string|int $identifier, bool $force = false, bool $recursive = false): bool + { + $this->initialize(); + + return $this->mailService->collectionDestroy($identifier, $force, $recursive) !== null; + } + + public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface + { + // TODO: Implement collection move + $this->initialize(); + $collection = new CollectionResource(provider: $this->provider(), service: $this->identifier()); + return $collection; + } + + // Entity operations + + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array + { + $this->initialize(); + + $result = $this->mailService->entityList($collection, $filter, $sort, $range, $properties); + + $list = []; + foreach ($result['list'] as $index => $entry) { + if (is_array($entry) && isset($entry['id'])) { + $object = new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($entry); + $list[$object->identifier()] = $object; + } + unset($result['list'][$index]); + } + + return $list; + } + + public function entityListFilter(): Filter + { + return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); + } + + public function entityListSort(): Sort + { + return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); + } + + public function entityListRange(RangeType $type): IRange + { + return new Range(); + } + + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta + { + $this->initialize(); + + return $this->mailService->entityDelta($collection, $signature, $detail); + } + + public function entityExtant(string|int $collection, string|int ...$identifiers): array + { + $this->initialize(); + + return $this->mailService->entityExtant(...$identifiers); + } + + public function entityFetch(string|int $collection, string|int ...$identifiers): array + { + $this->initialize(); + + $entities = $this->mailService->entityFetch(...$identifiers); + + foreach ($entities as &$entity) { + if (is_array($entity) && isset($entity['id'])) { + $object = new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($entity); + $entity = $object; + } + } + + return $entities; + } + +} diff --git a/lib/Providers/People/PeopleProvider.php b/lib/Providers/People/PeopleProvider.php new file mode 100644 index 0000000..d5c3ed1 --- /dev/null +++ b/lib/Providers/People/PeopleProvider.php @@ -0,0 +1,136 @@ + true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + self::CAPABILITY_SERVICE_FRESH => true, + self::CAPABILITY_SERVICE_CREATE => true, + self::CAPABILITY_SERVICE_MODIFY => true, + self::CAPABILITY_SERVICE_DESTROY => true, + ]; + } + + public function id(): string + { + return 'jmap'; + } + + public function label(): string + { + return 'JMAP Contacts Provider'; + } + + public function serviceList(string $tenantId, string $userId, array $filter): array + { + // Filter by Contacts capability + return $this->serviceStore->listServices($tenantId, $userId, [self::CONTACTS_CAPABILITY]); + } + + public function serviceExtant(string $tenantId, string $userId, array $identifiers): array + { + $result = []; + foreach ($identifiers as $id) { + $service = $this->serviceStore->getService($tenantId, $userId, $id); + $result[$id] = $service !== null; + } + return $result; + } + + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase + { + return $this->serviceStore->getService($tenantId, $userId, $identifier); + } + + public function serviceFresh(string $userId = ''): IServiceBase + { + return new Service( + scope: ServiceScope::User, + enabled: true, + ); + } + + public function serviceCreate(string $userId, IServiceBase $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + // Note: This simplified interface doesn't pass tenantId + // Will need to get it from SessionTenant in actual implementation + throw new \RuntimeException('Use Mail Provider interface for service creation'); + } + + public function serviceModify(string $userId, IServiceBase $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be instance of JMAP Service'); + } + + throw new \RuntimeException('Use Mail Provider interface for service modification'); + } + + public function serviceDestroy(string $userId, IServiceBase $service): bool + { + if (!($service instanceof Service)) { + return false; + } + + throw new \RuntimeException('Use Mail Provider interface for service destruction'); + } + + public function jsonSerialize(): array + { + return [ + '@type' => 'people.provider', + 'id' => $this->id(), + 'label' => $this->label(), + 'capabilities' => $this->capabilities(), + ]; + } + + public function jsonDeserialize(array|string $data): static + { + return $this; + } +} diff --git a/lib/Providers/ServiceIdentityBasic.php b/lib/Providers/ServiceIdentityBasic.php new file mode 100644 index 0000000..4fdebdd --- /dev/null +++ b/lib/Providers/ServiceIdentityBasic.php @@ -0,0 +1,87 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers; + +use KTXF\Resource\Provider\ResourceServiceIdentityBasic; + +/** + * JMAP Service Basic Identity + * + * Username/password authentication for JMAP. + */ +class ServiceIdentityBasic implements ResourceServiceIdentityBasic +{ + public function __construct( + private string $identity = '', + private string $secret = '', + ) {} + + public function toStore(): array + { + return [ + 'type' => self::TYPE_BASIC, + 'identity' => $this->identity, + 'secret' => $this->secret, + ]; + } + + public function fromStore(array $data): self + { + return new self( + identity: $data['identity'] ?? '', + secret: $data['secret'] ?? '', + ); + } + + public function jsonSerialize(): array + { + return [ + 'type' => self::TYPE_BASIC, + 'identity' => $this->identity, + // Password intentionally omitted from serialization for security + ]; + } + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->identity = $data['identity'] ?? ''; + $this->secret = $data['secret'] ?? ''; + + return $this; + } + + public function type(): string + { + return self::TYPE_BASIC; + } + + public function getIdentity(): string + { + return $this->identity; + } + + public function setIdentity(string $value): void + { + $this->identity = $value; + } + + public function getSecret(): string + { + return $this->secret; + } + + public function setSecret(string $value): void + { + $this->secret = $value; + } +} diff --git a/lib/Providers/ServiceLocation.php b/lib/Providers/ServiceLocation.php new file mode 100644 index 0000000..c6c1200 --- /dev/null +++ b/lib/Providers/ServiceLocation.php @@ -0,0 +1,153 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Providers; + +use KTXF\Resource\Provider\ResourceServiceLocationUri; + +/** + * JMAP Service Location + * + * Connection details for JMAP server. + */ +class ServiceLocation implements ResourceServiceLocationUri +{ + public function __construct( + private string $host = '', + private int $port = 443, + private string $scheme = 'https', + private string $path = '/.well-known/jmap', + private bool $verifyPeer = true, + private bool $verifyHost = true, + ) { + $testing = 'test'; + } + + public function toStore(): array + { + return $this->jsonSerialize(); + } + + public function fromStore(array $data): static + { + return $this->jsonDeserialize($data); + } + + public function jsonSerialize(): array + { + return array_filter([ + 'type' => self::TYPE_URI, + 'host' => $this->host ?? '', + 'port' => $this->port ?? 443, + 'scheme' => $this->scheme, + 'path' => $this->path, + 'verifyPeer' => $this->verifyPeer, + 'verifyHost' => $this->verifyHost, + ], fn($v) => $v !== null && $v !== ''); + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->host = $data['host'] ?? ''; + $this->port = (int)($data['port'] ?? 443); + $this->scheme = $data['scheme'] ?? 'https'; + $this->path = $data['path'] ?? ''; + $this->verifyPeer = $data['verifyPeer'] ?? true; + $this->verifyHost = $data['verifyHost'] ?? true; + + return $this; + } + + public function type(): string + { + return self::TYPE_URI; + } + + public function location(): string + { + $uri = $this->scheme . '://' . $this->host; + + // Add port if not default for scheme + if (($this->scheme === 'https' && $this->port !== 443) || + ($this->scheme === 'http' && $this->port !== 80)) { + $uri .= ':' . $this->port; + } + + // Add path if present + if ($this->path !== '') { + $uri .= '/' . ltrim($this->path, '/'); + } + + return $uri; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function setScheme(string $value): void + { + $this->scheme = $value; + } + + public function getHost(): string + { + return $this->host; + } + + public function setHost(string $value): void + { + $this->host = $value; + } + + public function getPort(): int + { + return $this->port; + } + + public function setPort(int $value): void + { + $this->port = $value; + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $value): void + { + $this->path = $value; + } + + public function getVerifyPeer(): bool + { + return $this->verifyPeer; + } + + public function setVerifyPeer(bool $value): void + { + $this->verifyPeer = $value; + } + + public function getVerifyHost(): bool + { + return $this->verifyHost; + } + + public function setVerifyHost(bool $value): void + { + $this->verifyHost = $value; + } +} diff --git a/lib/Service/Discovery.php b/lib/Service/Discovery.php new file mode 100644 index 0000000..7416b16 --- /dev/null +++ b/lib/Service/Discovery.php @@ -0,0 +1,289 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Service; + +use KTXM\ProviderJmapc\Providers\ServiceLocation; + +/** + * JMAP Service Discovery + * + * Implements RFC 8620 service discovery via: + * 1. DNS SRV records (_jmap._tcp.) + * 2. Well-known URI (https:///.well-known/jmap) + */ +class Discovery +{ + private const WELL_KNOWN_PATH = '/.well-known/jmap'; + private const DEFAULT_PORT_HTTPS = 443; + private const DEFAULT_PORT_HTTP = 80; + private const CONNECTION_TIMEOUT = 10; + private const MAX_REDIRECTS = 3; + + /** + * Discover JMAP service location from email address or domain + * + * @param string $identity Email address or domain + * @param string|null $location Optional hostname to test directly (bypasses DNS SRV) + * @param string|null $secret Optional password/token to validate the service + * @param bool $verifySSL Whether to verify SSL certificates + * @return ServiceLocation|null Discovered service location or null if not found + */ + public function discover( + string $identity, + ?string $location = null, + ?string $secret = null, + bool $verifySSL = true + ): ?ServiceLocation { + // If location is provided, test it directly + if ($location !== null && $location !== '') { + $host = $this->extractDomain($location); + if ($host !== null) { + $result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret); + if ($result !== null) { + return $result; + } + + // Try HTTP if HTTPS failed + $result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret); + if ($result !== null) { + return $result; + } + } + return null; + } + + // Extract domain from email address if needed + $domain = $this->extractDomain($identity); + if ($domain === null) { + return null; + } + + // Try DNS SRV lookup first (RFC 8620 recommended method) + $srvResult = $this->discoverViaSRV($domain); + if ($srvResult !== null) { + $result = $this->testWellKnownUri( + $srvResult['host'], + $srvResult['port'], + $verifySSL, + 'https', + $identity, + $secret + ); + if ($result !== null) { + return $result; + } + } + + // Fallback: Try well-known URI directly on domain with HTTPS + $result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret); + if ($result !== null) { + return $result; + } + + // Last resort: Try HTTP (not recommended, but some servers may use it) + $result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret); + if ($result !== null) { + return $result; + } + + return null; + } + + /** + * Extract domain from email address or return as-is if already a domain + */ + private function extractDomain(string $identity): ?string + { + $identity = trim($identity); + + // If it contains @, extract domain part + if (str_contains($identity, '@')) { + $parts = explode('@', $identity); + return strtolower(trim($parts[1] ?? '')); + } + + // Otherwise treat as domain + $domain = strtolower($identity); + + // Remove protocol if present + $domain = preg_replace('#^https?://#i', '', $domain); + + // Remove path if present + $domain = explode('/', $domain)[0]; + + return $domain !== '' ? $domain : null; + } + + /** + * Discover JMAP service via DNS SRV record + * + * Queries for _jmap._tcp. SRV record + * + * @return array{host: string, port: int}|null + */ + private function discoverViaSRV(string $domain): ?array + { + $srvRecord = "_jmap._tcp.{$domain}"; + + try { + $records = @dns_get_record($srvRecord, DNS_SRV); + + if ($records === false || empty($records)) { + return null; + } + + // Use first record (they can be prioritized, but we'll keep it simple) + $record = $records[0]; + + if (isset($record['target']) && isset($record['port'])) { + return [ + 'host' => rtrim($record['target'], '.'), + 'port' => (int)$record['port'], + ]; + } + } catch (\Exception $e) { + // DNS lookup failed, silently continue to fallback methods + } + + return null; + } + + /** + * Test well-known JMAP URI and validate response + * + * Optionally validates with credentials if secret is provided + * + * @return ServiceLocation|null + */ + private function testWellKnownUri( + string $host, + int $port, + bool $verifySSL, + string $scheme = 'https', + ?string $identity = null, + ?string $secret = null + ): ?ServiceLocation { + $url = $this->buildWellKnownUrl($host, $port, $scheme); + + try { + $ch = curl_init($url); + if ($ch === false) { + return null; + } + + $curlOptions = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => self::MAX_REDIRECTS, + CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT, + CURLOPT_SSL_VERIFYPEER => $verifySSL, + CURLOPT_SSL_VERIFYHOST => $verifySSL ? 2 : 0, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + ], + ]; + + // Add basic auth if credentials provided + if ($identity !== null && $secret !== null) { + $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC; + $curlOptions[CURLOPT_USERPWD] = "{$identity}:{$secret}"; + } + + curl_setopt_array($ch, $curlOptions); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Must be 200 OK (or 401 if we didn't provide auth - still proves service exists) + if ($httpCode === 401 && ($identity === null || $secret === null)) { + // Service exists but requires auth - that's fine for discovery + return new ServiceLocation( + host: $host, + port: $port, + scheme: $scheme, + path: self::WELL_KNOWN_PATH, + verifyPeer: $verifySSL, + verifyHost: $verifySSL, + ); + } + + if ($httpCode !== 200 || $response === false) { + return null; + } + + // Parse and validate JMAP session response + $data = json_decode($response, true); + if (!$this->isValidJmapSession($data)) { + return null; + } + + // Create ServiceLocation with discovered settings + return new ServiceLocation( + host: $host, + port: $port, + scheme: $scheme, + path: self::WELL_KNOWN_PATH, + verifyPeer: $verifySSL, + verifyHost: $verifySSL, + ); + + } catch (\Exception $e) { + return null; + } + } + + /** + * Build well-known JMAP URL + */ + private function buildWellKnownUrl(string $host, int $port, string $scheme): string + { + $url = "{$scheme}://{$host}"; + + // Add port if non-standard + if (($scheme === 'https' && $port !== self::DEFAULT_PORT_HTTPS) || + ($scheme === 'http' && $port !== self::DEFAULT_PORT_HTTP)) { + $url .= ":{$port}"; + } + + $url .= self::WELL_KNOWN_PATH; + + return $url; + } + + /** + * Validate that response is a proper JMAP session object + * + * According to RFC 8620, session must contain at minimum: + * - apiUrl: The URL to use for JMAP API requests + * - capabilities: Object describing server capabilities + */ + private function isValidJmapSession(mixed $data): bool + { + if (!is_array($data)) { + return false; + } + + // Must have apiUrl + if (!isset($data['apiUrl']) || !is_string($data['apiUrl'])) { + return false; + } + + // Must have capabilities object + if (!isset($data['capabilities']) || !is_array($data['capabilities'])) { + return false; + } + + // Should have mail capability for our use case + // But we'll be lenient and just check the basics above + + return true; + } +} diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php new file mode 100644 index 0000000..28a5366 --- /dev/null +++ b/lib/Service/MailService.php @@ -0,0 +1,187 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Service; + +use JmapClient\Client; +use KTXF\Resource\Range\IRange; +use KTXM\ProviderJmapc\Service\Remote\RemoteMailService; +use KTXM\ProviderJmapc\Service\Remote\RemoteService; + +class MailService { + protected Client $dataStore; + protected RemoteMailService $remoteMailService; + protected $localMetaStore; + protected $localBlobStore; + protected string $servicePrimaryAccount = ''; + protected string $serviceSelectedAccount = ''; + protected array $serviceAvailableAccounts = []; + protected string $servicePrimaryIdentity = ''; + protected string $serviceSelectedIdentity = ''; + protected array $serviceAvailableIdentities = []; + protected array $serviceCollectionRoles = []; + + public function __construct( + ) { } + + public function initialize(Client $dataStore): void { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // initialize remote service + $this->remoteMailService = RemoteService::mailService($dataStore); + // initialize internal settings + $this->initializeSession(); + $this->initializeCollectionRoles(); + + } + + protected function initializeSession() { + + // retrieve default account + $this->servicePrimaryAccount = $this->dataStore->sessionAccountDefault('mail'); + $this->serviceSelectedAccount = $this->servicePrimaryAccount; + // retrieve accounts + $this->serviceAvailableAccounts = $this->dataStore->sessionAccounts(); + // retrieve identities + $collection = $this->remoteMailService->identityFetch($this->servicePrimaryAccount); + foreach ($collection as $entry) { + $this->serviceAvailableIdentities[$entry->address()] = $entry; + } + + } + + protected function initializeCollectionRoles() { + + // retrieve collections + $collectionList = $this->collectionList('', ''); + // find collection with roles + foreach ($collectionList as $entry) { + $this->serviceCollectionRoles[$entry->getRole()] = $entry->id(); + } + + } + + public function collectionList(string $location, string $scope, array $options = []): array { + + return $this->remoteMailService->collectionList($this->serviceSelectedAccount, $location, $scope); + + } + + public function collectionFetch(string $location, string $id, array $options = []): object { + + return $this->remoteMailService->collectionFetch($this->serviceSelectedAccount, $location, $id); + + } + + public function collectionCreate(string $location, string $label, array $options = []): string { + + return $this->remoteMailService->collectionCreate($this->serviceSelectedAccount, $location, $label); + + } + + public function collectionUpdate(string $location, string $id, string $label, array $options = []): string { + + return $this->remoteMailService->collectionUpdate($this->serviceSelectedAccount, $location, $id, $label); + + } + + public function collectionDelete(string $location, string $id, array $options = []): string { + + return $this->remoteMailService->collectionDelete($this->serviceSelectedAccount, $location, $id); + + } + + public function collectionMove(string $sourceLocation, string $id, string $destinationLocation, array $options = []): string { + + return $this->remoteMailService->collectionMove($this->serviceSelectedAccount, $sourceLocation, $id, $destinationLocation); + + } + + public function entityList(string $location, ?IRange $range = null, ?string $sort = null, string $particulars = 'D', array $options = []): array { + + return $this->remoteMailService->entityList($this->serviceSelectedAccount, $location, $range, $sort, $particulars); + + } + + public function entityFetch(string $location, string $id, string $particulars = 'D', array $options = []): object { + + return $this->remoteMailService->entityFetch($this->serviceSelectedAccount, $location, $id, $particulars); + + } + + public function entityCreate(string $location, IMessage $message, array $options = []): string { + + return $this->remoteMailService->entityCreate($this->serviceSelectedAccount, $location, $message); + + } + + public function entityUpdate(string $location, string $id, IMessage $message, array $options = []): string { + + return $this->remoteMailService->entityUpdate($this->serviceSelectedAccount, $location, $id, $message); + + } + + public function entityDelete(string $location, string $id, array $options = []): string { + + return $this->remoteMailService->entityDelete($this->serviceSelectedAccount, $location, $id); + + } + + public function entityCopy(string $sourceLocation, string $id, string $destinationLocation, array $options = []): string { + + // perform action + return $this->remoteMailService->entityCopy($this->serviceSelectedAccount, $sourceLocation, $id, $destinationLocation); + + } + + public function entityMove(string $sourceLocation, string $id, string $destinationLocation, array $options = []): string { + + // perform action + return $this->remoteMailService->entityMove($this->serviceSelectedAccount, $sourceLocation, $id, $destinationLocation); + + } + + public function entityForward(string $location, string $id, IMessage $message, array $options = []): string { + + // perform action + return $this->remoteMailService->entityForward($this->serviceSelectedAccount, $location, $id, $message); + + } + + public function entityReply(string $location, string $id, IMessage $message, array $options = []): string { + + // perform action + return $this->remoteMailService->entityReply($this->serviceSelectedAccount, $location, $id, $message); + + } + + public function entitySend(IMessage $message, array $options = []): string { + + // extract from address + $from = $message->getFrom(); + // determine if identity exists for this from address + if (isset($this->serviceAvailableIdentities[$from->getAddress()])) { + $selectedIdentity = $this->serviceAvailableIdentities[$from->getAddress()]->id(); + } + // perform action + return $this->remoteMailService->entitySend($selectedIdentity, $message, $this->serviceCollectionRoles['drafts'], $this->serviceCollectionRoles['sent']); + + } + + public function blobFetch(string $id): object { + + return $this->remoteMailService->blobFetch($this->serviceSelectedAccount, $id); + + } + +} diff --git a/lib/Service/Remote/FM/RemoteContactsServiceFM.php b/lib/Service/Remote/FM/RemoteContactsServiceFM.php new file mode 100644 index 0000000..3c9fea3 --- /dev/null +++ b/lib/Service/Remote/FM/RemoteContactsServiceFM.php @@ -0,0 +1,343 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote\FM; + +use DateTimeImmutable; +use JmapClient\Client; +use OCA\JMAPC\Jmap\FM\Request\Contacts\ContactParameters as ContactParametersRequest; +use OCA\JMAPC\Objects\Contact\ContactAnniversaryObject; +use OCA\JMAPC\Objects\Contact\ContactAnniversaryTypes; +use OCA\JMAPC\Objects\Contact\ContactEmailObject; +use OCA\JMAPC\Objects\Contact\ContactNoteObject; +use OCA\JMAPC\Objects\Contact\ContactObject as ContactObject; +use OCA\JMAPC\Objects\Contact\ContactOrganizationObject; +use OCA\JMAPC\Objects\Contact\ContactPhoneObject; +use OCA\JMAPC\Objects\Contact\ContactPhysicalLocationObject; +use OCA\JMAPC\Objects\Contact\ContactTitleObject; +use OCA\JMAPC\Objects\Contact\ContactTitleTypes; +use OCA\JMAPC\Objects\Contact\ContactVirtualLocationObject; +use OCA\JMAPC\Objects\OriginTypes; +use OCA\JMAPC\Service\Remote\RemoteContactsService; + +class RemoteContactsServiceFM extends RemoteContactsService { + private const DATE_ANNIVERSARY = 'Y-m-d'; + + public function __construct() { + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + parent::initialize($dataStore, $dataAccount); + + $this->resourceNamespace = 'https://www.fastmail.com/dev/contacts'; + $this->resourceCollectionLabel = null; + $this->resourceEntityLabel = 'Contact'; + + $dataStore->configureRequestTypes('parameters', 'Contact.object', 'OCA\JMAPC\Jmap\FM\Request\Contacts\ContactParameters'); + + $dataStore->configureResponseTypes('command', 'Contact/get', 'JmapClient\Responses\Contacts\ContactGet'); + $dataStore->configureResponseTypes('command', 'Contact/set', 'JmapClient\Responses\Contacts\ContactSet'); + $dataStore->configureResponseTypes('command', 'Contact/changes', 'JmapClient\Responses\Contacts\ContactChanges'); + $dataStore->configureResponseTypes('command', 'Contact/query', 'JmapClient\Responses\Contacts\ContactQuery'); + $dataStore->configureResponseTypes('command', 'Contact/queryChanges', 'JmapClient\Responses\Contacts\ContactQueryChanges'); + $dataStore->configureResponseTypes('parameters', 'Contact', 'OCA\JMAPC\Jmap\FM\Response\ContactParameters'); + $dataStore->configureResponseTypes('parameters', 'Contact', 'OCA\JMAPC\Jmap\FM\Response\Contacts\ContactParameters'); + + } + + /** + * convert jmap object to contact object + * + * @since Release 1.0.0 + * + */ + public function toContactObject($so): ContactObject { + + // create object + $do = new ContactObject(); + // source origin + $do->Origin = OriginTypes::External; + // id + if ($so->id()) { + $do->ID = $so->id(); + } + // universal id + if ($so->uid()) { + $do->UUID = $so->uid(); + } + // name - last + if ($so->nameLast()) { + $do->Name->Last = $so->nameLast(); + } + // name - first + if ($so->nameFirst()) { + $do->Name->First = $so->nameFirst(); + } + // name - prefix + if ($so->namePrefix()) { + $do->Name->Prefix = $so->namePrefix(); + } + // name - suffix + if ($so->nameSuffix()) { + $do->Name->Suffix = $so->nameSuffix(); + } + // anniversary - birth day + if ($so->birthday() && $so->birthday() !== '0000-00-00') { + $when = new DateTimeImmutable($so->birthday()); + if ($when) { + $anniversary = new ContactAnniversaryObject(); + $anniversary->Type = ContactAnniversaryTypes::Birth; + $anniversary->When = $when; + $do->Anniversaries[] = $anniversary; + } + } + // anniversary - nuptial day + if ($so->nuptialDay() && $so->nuptialDay() !== '0000-00-00') { + $when = new DateTimeImmutable($so->nuptialDay()); + if ($when) { + $anniversary = new ContactAnniversaryObject(); + $anniversary->Type = ContactAnniversaryTypes::Nuptial; + $anniversary->When = $when; + $do->Anniversaries[] = $anniversary; + } + } + // physical location(s) + foreach ($so->location() as $id => $entry) { + $location = new ContactPhysicalLocationObject(); + $location->Context = $entry->type(); + $location->Label = $entry->label(); + $location->Street = $entry->street(); + $location->Locality = $entry->locality(); + $location->Region = $entry->region(); + $location->Code = $entry->code(); + $location->Country = $entry->country(); + $location->Id = (string)$id; + $location->Index = $id; + $do->PhysicalLocations[$id] = $location; + } + // phone(s) + foreach ($so->phone() as $id => $entry) { + $phone = new ContactPhoneObject(); + $phone->Context = $entry->type(); + $phone->Number = $entry->value(); + $phone->Label = $entry->label(); + $phone->Id = (string)$id; + $phone->Index = $id; + if ($entry->default()) { + $phone->Priority = 1; + } + $do->Phone[$id] = $phone; + } + // email(s) + foreach ($so->email() as $id => $entry) { + $email = new ContactEmailObject(); + $email->Context = $entry->type(); + $email->Address = $entry->value(); + $email->Id = (string)$id; + $email->Index = $id; + $do->Email[$id] = $email; + } + // organization - name + if ($so->organizationName()) { + $organization = new ContactOrganizationObject(); + $organization->Label = $so->organizationName(); + $organization->Id = '0'; + $organization->Index = 0; + $organization->Priority = 1; + $do->Organizations[] = $organization; + } + // title + if ($so->title()) { + $title = new ContactTitleObject(); + $title->Kind = ContactTitleTypes::Title; + $title->Label = $so->title(); + $title->Id = '0'; + $title->Index = 0; + $title->Priority = 1; + $do->Titles[] = $title; + } + // notes + if ($so->notes()) { + $note = new ContactNoteObject(); + $note->Content = $so->notes(); + $note->Id = '0'; + $note->Index = 0; + $note->Priority = 1; + $do->Notes[] = $note; + } + // virtual locations + if ($so->online()) { + foreach ($so->online() as $id => $entry) { + $entity = new ContactVirtualLocationObject(); + $entity->Location = $entry->value(); + $entity->Context = $entry->type(); + $entity->Label = $entry->label(); + $email->Id = (string)$id; + $email->Index = $id; + $do->VirtualLocations[$id] = $entity; + } + } + + return $do; + + } + + /** + * convert contact object to jmap object + * + * @since Release 1.0.0 + * + */ + public function fromContactObject(ContactObject $so): mixed { + + // create object + $do = new ContactParametersRequest(); + // universal id + if ($so->UUID) { + $do->uid($so->UUID); + } + // name - last + if ($so->Name->Last) { + $do->nameLast($so->Name->Last); + } + // name - first + if ($so->Name->First) { + $do->nameFirst($so->Name->First); + } + // name - prefix + if ($so->Name->Prefix) { + $do->namePrefix($so->Name->Prefix); + } + // name - suffix + if ($so->Name->Suffix) { + $do->nameSuffix($so->Name->Suffix); + } + // aliases + // only one aliases is supported + if ($so->Name->Aliases->count() > 0) { + $priority = $so->Name->Aliases->highestPriority(); + $do->organizationName($so->Name->Aliases[$priority]->Label); + } + // anniversaries + $delta = [ContactAnniversaryTypes::Birth->name => true, ContactAnniversaryTypes::Nuptial->name => true]; + foreach ($so->Anniversaries as $id => $entry) { + if ($entry->When === null) { + continue; + } + if ($entry->Type === ContactAnniversaryTypes::Birth) { + $do->birthday($entry->When->format(self::DATE_ANNIVERSARY)); + unset($delta[ContactAnniversaryTypes::Birth->name]); + } + if ($entry->Type === ContactAnniversaryTypes::Nuptial) { + $do->nuptialDay($entry->When->format(self::DATE_ANNIVERSARY)); + unset($delta[ContactAnniversaryTypes::Nuptial->name]); + } + } + foreach ($delta as $key => $value) { + if ($key === ContactAnniversaryTypes::Birth->name) { + $do->birthday('0000-00-00'); + } + if ($key === ContactAnniversaryTypes::Nuptial->name) { + $do->nuptialDay('0000-00-00'); + } + } + // phone(s) + foreach ($so->Phone as $id => $entry) { + $entity = $do->phone($id); + $entity->value((string)$entry->Number); + $context = strtolower($entry->Context); + if (in_array($context, ['home', 'work', 'mobile', 'fax', 'page', 'other'], true)) { + $entity->type($entry->Context); + } else { + $entity->type('other'); + $entity->label($entry->Context); + } + if ($entry->Priority === 1) { + $entity->default(true); + } + } + // email(s) + foreach ($so->Email as $id => $entry) { + $entity = $do->email($id); + $entity->value((string)$entry->Address); + $context = strtolower($entry->Context); + if (in_array($context, ['personal', 'work', 'other'], true)) { + $entity->type($entry->Context); + } else { + $entity->type('other'); + $entity->label($entry->Context); + } + if ($entry->Priority === 1) { + $entity->default(true); + } + } + // physical location(s) + foreach ($so->PhysicalLocations as $id => $entry) { + $entity = $do->location($id); + $entity->type((string)$entry->Context); + $entity->label((string)$entry->Label); + $entity->street((string)$entry->Street); + $entity->locality((string)$entry->Locality); + $entity->region((string)$entry->Region); + $entity->code((string)$entry->Code); + $entity->country((string)$entry->Country); + if ($entry->Priority === 1) { + $entity->default(true); + } + } + // organization - name + // only one organization is supported + if ($so->Organizations->count() > 0) { + $priority = $so->Organizations->highestPriority(); + $do->organizationName($so->Organizations[$priority]->Label); + } + // titles + // only one title is supported + if ($so->Titles->count() > 0) { + $priority = $so->Titles->highestPriority(ContactTitleTypes::Title); + if ($priority !== null) { + $do->title($so->Titles[$priority]->Label); + } + } + // notes + // only one note is supported + if ($so->Notes->count() > 0) { + $do->notes($so->Notes[0]->Content); + } + // virtual locations + foreach ($so->VirtualLocations as $id => $entry) { + $entity = $do->online($id); + $entity->type((string)$entry->Context); + $entity->value((string)$entry->Location); + $entity->label((string)$entry->Label); + } + + return $do; + + } + +} diff --git a/lib/Service/Remote/FM/RemoteEventsServiceFM.php b/lib/Service/Remote/FM/RemoteEventsServiceFM.php new file mode 100644 index 0000000..79174fe --- /dev/null +++ b/lib/Service/Remote/FM/RemoteEventsServiceFM.php @@ -0,0 +1,109 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote\FM; + +use Exception; +use JmapClient\Client; +use JmapClient\Requests\Calendar\EventChanges; +use JmapClient\Requests\Calendar\EventGet; +use JmapClient\Responses\ResponseException; +use OCA\JMAPC\Exceptions\JmapUnknownMethod; +use OCA\JMAPC\Objects\BaseStringCollection; +use OCA\JMAPC\Objects\DeltaObject; +use OCA\JMAPC\Service\Remote\RemoteEventsService; + +class RemoteEventsServiceFM extends RemoteEventsService { + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + parent::initialize($dataStore, $dataAccount); + + $dataStore->configureRequestTypes('parameters', 'CalendarEvent.filter', 'OCA\JMAPC\Jmap\FM\Request\Events\EventFilter'); + + } + + /** + * delta of changes for specific collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new EventChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // construct get for created + $r1 = new EventGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r1->targetFromRequest($r0, '/created'); + $r1->property('calendarIds', 'id', 'created', 'updated'); + // construct get for updated + $r2 = new EventGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r2->targetFromRequest($r0, '/updated'); + $r2->property('calendarIds', 'id', 'created', 'updated'); + // transceive + $bundle = $this->dataStore->perform([$r0, $r1, $r2]); + // extract response + $response0 = $bundle->response(0); + $response1 = $bundle->response(1); + $response2 = $bundle->response(2); + // determine if command errored + if ($response0 instanceof ResponseException) { + if ($response0->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response0->description(), 1); + } else { + throw new Exception($response0->type() . ': ' . $response0->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response0->stateNew(); + $delta->additions = new BaseStringCollection(); + foreach ($response1->objects() as $entry) { + if (in_array($location, $entry->in())) { + $delta->additions[] = $entry->id(); + } + } + $delta->modifications = new BaseStringCollection(); + foreach ($response2->objects() as $entry) { + if (in_array($location, $entry->in())) { + $delta->modifications[] = $entry->id(); + } + } + $delta->deletions = new BaseStringCollection(); + foreach ($response0->deleted() as $entry) { + $delta->deletions[] = $entry; + } + + return $delta; + } + +} diff --git a/lib/Service/Remote/RemoteContactsService.php b/lib/Service/Remote/RemoteContactsService.php new file mode 100644 index 0000000..cc4d8de --- /dev/null +++ b/lib/Service/Remote/RemoteContactsService.php @@ -0,0 +1,1229 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use DateTimeImmutable; +use DateTimeZone; +use Exception; + +use JmapClient\Client; +use JmapClient\Requests\Contacts\AddressBookGet; +use JmapClient\Requests\Contacts\AddressBookParameters as AddressBookParametersRequest; +use JmapClient\Requests\Contacts\AddressBookSet; +use JmapClient\Requests\Contacts\ContactChanges; +use JmapClient\Requests\Contacts\ContactGet; +use JmapClient\Requests\Contacts\ContactParameters as ContactParametersRequest; +use JmapClient\Requests\Contacts\ContactQuery; +use JmapClient\Requests\Contacts\ContactQueryChanges; +use JmapClient\Requests\Contacts\ContactSet; +use JmapClient\Responses\Contacts\AddressBookParameters as AddressBookParametersResponse; +use JmapClient\Responses\Contacts\ContactParameters as ContactParametersResponse; +use JmapClient\Responses\ResponseException; +use JmapClient\Session\Account; +use OCA\JMAPC\Exceptions\JmapUnknownMethod; +use OCA\JMAPC\Objects\BaseStringCollection; +use OCA\JMAPC\Objects\Contact\ContactAliasObject; +use OCA\JMAPC\Objects\Contact\ContactAnniversaryObject; +use OCA\JMAPC\Objects\Contact\ContactCollectionObject; +use OCA\JMAPC\Objects\Contact\ContactCryptoObject; +use OCA\JMAPC\Objects\Contact\ContactEmailObject; +use OCA\JMAPC\Objects\Contact\ContactNoteObject; +use OCA\JMAPC\Objects\Contact\ContactObject as ContactObject; +use OCA\JMAPC\Objects\Contact\ContactOrganizationObject; +use OCA\JMAPC\Objects\Contact\ContactPhoneObject; +use OCA\JMAPC\Objects\Contact\ContactPhysicalLocationObject; +use OCA\JMAPC\Objects\Contact\ContactTitleObject; +use OCA\JMAPC\Objects\Contact\ContactTitleTypes; +use OCA\JMAPC\Objects\DeltaObject; +use OCA\JMAPC\Objects\OriginTypes; +use OCA\JMAPC\Store\Common\Filters\IFilter; +use OCA\JMAPC\Store\Common\Range\IRangeTally; +use OCA\JMAPC\Store\Common\Range\RangeAnchorType; +use OCA\JMAPC\Store\Common\Sort\ISort; +use OCA\JMAPC\Store\Remote\Filters\ContactFilter; +use OCA\JMAPC\Store\Remote\Sort\ContactSort; + +class RemoteContactsService { + protected Client $dataStore; + protected string $dataAccount; + + protected ?string $resourceNamespace = null; + protected ?string $resourceCollectionLabel = null; + protected ?string $resourceEntityLabel = null; + + protected array $collectionPropertiesDefault = []; + protected array $collectionPropertiesBasic = []; + protected array $entityPropertiesDefault = []; + protected array $entityPropertiesBasic = [ + 'id', 'addressbookId', 'uid' + ]; + + public function __construct() { + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($dataAccount === null) { + if ($this->resourceNamespace !== null) { + $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); + } else { + $account = $dataStore->sessionAccountDefault('contacts'); + } + $this->dataAccount = $account !== null ? $account->id() : ''; + } else { + $this->dataAccount = $dataAccount; + } + + } + + /** + * list of collections in remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param int|null $depth Depth of sub collections to return + * + * @return array + */ + public function collectionList(?string $location = null, ?string $granularity = null, ?int $depth = null): array { + // construct request + $r0 = new AddressBookGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + // set target to query request + if ($location !== null) { + $r0->target($location); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof AddressBookParametersResponse) { + continue; + } + $to = $this->toContactCollection($so); + $to->Signature = $response->state(); + $list[] = $to; + } + // return collection of collections + return $list; + } + + /** + * retrieve properties for specific collection + * + * @since Release 1.0.0 + */ + public function collectionFetch(string $identifier): ?ContactCollectionObject { + // construct request + $r0 = new AddressBookGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->target($identifier); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to collection object + $so = $response->object(0); + $to = null; + if ($so instanceof AddressBookParametersResponse) { + $to = $this->toContactCollection($so); + $to->Signature = $response->state(); + } + return $to; + } + + /** + * create collection in remote storage + * + * @since Release 1.0.0 + */ + public function collectionCreate(ContactCollectionObject $so): ?string { + // convert entity + $to = $this->fromContactCollection($so); + $id = uniqid(); + // construct request + $r0 = new AddressBookSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * modify collection in remote storage + * + * @since Release 1.0.0 + */ + public function collectionModify(string $identifier, ContactCollectionObject $so): ?string { + // convert entity + $to = $this->fromContactCollection($so); + // construct request + $r0 = new AddressBookSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->update($identifier, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new Exception("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * delete collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionDelete(string $identifier): ?string { + // construct request + $r0 = new AddressBookSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->delete($identifier); + $r0->deleteContents(true); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new Exception("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * retrieve entities from remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param IRange|null $range Range of collections to return + * @param IFilter|null $filter Properties to filter by + * @param ISort|null $sort Properties to sort by + */ + public function entityList(?string $location = null, ?string $granularity = null, ?IRangeTally $range = null, ?IFilter $filter = null, ?ISort $sort = null, ?int $depth = null): array { + // construct request + $r0 = new ContactQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + 'createBefore' => $r0->filter()->createdBefore($value), + 'createAfter' => $r0->filter()->createdAfter($value), + 'modifiedBefore' => $r0->filter()->updatedBefore($value), + 'modifiedAfter' => $r0->filter()->updatedAfter($value), + 'uid' => $r0->filter()->uid($value), + 'kind' => $r0->filter()->kind($value), + 'member' => $r0->filter()->member($value), + 'text' => $r0->filter()->text($value), + 'name' => $r0->filter()->name($value), + 'nameGiven' => $r0->filter()->nameGiven($value), + 'nameSurname' => $r0->filter()->nameSurname($value), + 'nameAlias' => $r0->filter()->nameAlias($value), + 'organization' => $r0->filter()->organization($value), + 'email' => $r0->filter()->mail($value), + 'phone' => $r0->filter()->phone($value), + 'address' => $r0->filter()->address($value), + 'note' => $r0->filter()->note($value), + default => null + }; + } + } + // define sort + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'created' => $r0->sort()->created($direction), + 'modified' => $r0->sort()->updated($direction), + 'nameGiven' => $r0->sort()->nameGiven($direction), + 'nameSurname' => $r0->sort()->nameSurname($direction), + default => null + }; + } + } + // define range + if ($range !== null) { + if ($range->anchor() === RangeAnchorType::ABSOLUTE) { + $r0->limitAbsolute($range->getPosition(), $range->getCount()); + } + if ($range->anchor() === RangeAnchorType::RELATIVE) { + $r0->limitRelative($range->getPosition(), $range->getCount()); + } + } + // construct get request + $r1 = new ContactGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set target to query request + $r1->targetFromRequest($r0, '/ids'); + // select properties to return + if ($granularity === 'B') { + $r1->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert json objects to contact objects + $state = $response->state(); + $list = $response->objects(); + foreach ($list as $id => $entry) { + $eo = $this->toContactObject($entry); + $eo->Signature = $this->generateSignature($eo); + $list[$id] = $eo; + } + // return status object + return ['list' => $list, 'state' => $state]; + } + + public function entityListFilter(): ContactFilter { + return new ContactFilter(); + } + + public function entityListSort(): ContactSort { + return new ContactSort(); + } + + /** + * delta for entities in remote storage + * + * @since Release 1.0.0 + * + * @return DeltaObject + */ + public function entityDelta(?string $location, string $state): DeltaObject { + + if (empty($state)) { + $results = $this->entityList($location, 'B'); + $delta = new DeltaObject(); + $delta->signature = $results['state']; + foreach ($results['list'] as $entry) { + $delta->additions[] = $entry->ID; + } + return $delta; + } + if (empty($location)) { + return $this->entityDeltaDefault($state); + } else { + return $this->entityDeltaSpecific($location, $state); + } + } + + /** + * delta of changes for specific collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaSpecific(?string $location, string $state): DeltaObject { + // construct set request + $r0 = new ContactQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set location constraint + if (!empty($location)) { + $r0->filter()->in($location); + } + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->added()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->removed()); + + return $delta; + } + + /** + * delta of changes in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaDefault(string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new ContactChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->created()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->deleted()); + + return $delta; + } + + /** + * retrieve entity from remote storage + * + * @since Release 1.0.0 + * + * @param string $location Id of collection + * @param string $identifier Id of entity + * @param string $granularity Amount of detail to return + * + * @return EventObject|null + */ + public function entityFetch(string $location, string $identifier, string $granularity = 'D'): ?ContactObject { + // construct request + $r0 = new ContactGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target($identifier); + // select properties to return + if ($granularity === 'B') { + $r0->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to event object + $so = $response->object(0); + if ($so instanceof ContactParametersResponse) { + $to = $this->toContactObject($so); + $to->Signature = $this->generateSignature($to); + } + + return $to ?? null; + } + + /** + * retrieve entity(ies) from remote storage + * + * @since Release 1.0.0 + * + * @param string $location Id of collection + * @param array $identifiers Id of entity + * @param string $granularity Amount of detail to return + * + * @return array + */ + public function entityFetchMultiple(string $location, array $identifiers, string $granularity = 'D'): array { + // construct request + $r0 = new ContactGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target(...$identifiers); + // select properties to return + if ($granularity === 'B') { + $r0->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object(s) to event object + $list = $response->objects(); + foreach ($list as $id => $so) { + if (!$so instanceof ContactParametersResponse) { + continue; + } + $to = $this->toContactObject($so); + $to->Signature = $this->generateSignature($to); + $list[$id] = $so; + } + // return object(s) + return $list; + } + + /** + * create entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCreate(string $location, ContactObject $so): ?ContactObject { + // convert entity + $entity = $this->fromContactObject($so); + $id = uniqid(); + // construct set request + $r0 = new ContactSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create($id, $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $result['id']; + $ro->CreatedOn = isset($result['updated']) ? new DateTimeImmutable($result['updated']) : null; + $ro->ModifiedOn = $ro->CreatedOn; + $ro->Signature = $this->generateSignature($ro); + return $ro; + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * update entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityModify(string $location, string $identifier, ContactObject $so): ?ContactObject { + // convert entity + $entity = $this->fromContactObject($so); + // construct set request + $r0 = new ContactSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($identifier, $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $identifier; + $ro->ModifiedOn = isset($result['updated']) ? new DateTimeImmutable($result['updated']) : null; + $ro->Signature = $this->generateSignature($ro); + return $ro; + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new Exception("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * delete entity from remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDelete(string $location, string $identifier): ?string { + // construct set request + $r0 = new ContactSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $r0->delete($identifier); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new Exception("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * copy entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCopy(string $sourceLocation, string $identifier, string $destinationLocation): string { + return ''; + } + + /** + * move entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityMove(string $sourceLocation, string $identifier, string $destinationLocation): string { + // construct set request + $r0 = new ContactSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $m0 = $r0->update($identifier); + $m0->in($destinationLocation); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection information + return array_key_exists($identifier, $response->updated()) ? (string)$identifier : ''; + } + + private function toContactCollection(AddressBookParametersResponse $so): ContactCollectionObject { + $to = new ContactCollectionObject(); + $to->Id = $so->id(); + $to->Label = $so->label(); + $to->Description = $so->description(); + $to->Priority = $so->priority(); + return $to; + } + + public function fromContactCollection(ContactCollectionObject $so): AddressBookParametersRequest { + // create object + $to = new AddressBookParametersRequest(); + + if ($so->Label !== null) { + $to->label($so->Label); + } + if ($so->Description !== null) { + $to->description($so->Description); + } + if ($so->Priority !== null) { + $to->priority($so->Priority); + } + + return $to; + } + + /** + * convert jmap object to contact object + * + * @since Release 1.0.0 + * + */ + public function toContactObject($so): ContactObject { + + // create object + $do = new ContactObject(); + // source origin + $do->Origin = OriginTypes::External; + // collection id + if ($so->in() !== null) { + $do->CID = $so->in()[0]; + } + // entity id + if ($so->id() !== null) { + $do->ID = $so->id(); + } + // universal id + if ($so->uid() !== null) { + $do->UUID = $so->uid(); + } + // creation date time + if ($so->created() !== null) { + $do->CreatedOn = $so->created(); + } + // modification date time + if ($so->updated() !== null) { + $do->ModifiedOn = $so->updated(); + } + // kind + if ($so->kind() !== null) { + $do->Kind = $so->kind(); + } + // name + if ($so->name() !== null) { + $nameParams = $so->name(); + foreach ($nameParams->components() as $component) { + $kind = $component->kind(); + $value = $component->value(); + if ($kind === 'surname') { + $do->Name->Last = $value; + } elseif ($kind === 'given') { + $do->Name->First = $value; + } elseif ($kind === 'additional') { + $do->Name->Other = $value; + } elseif ($kind === 'prefix') { + $do->Name->Prefix = $value; + } elseif ($kind === 'suffix') { + $do->Name->Suffix = $value; + } + } + } + // aliases + if ($so->aliases() !== null) { + foreach ($so->aliases() as $id => $entry) { + $entity = new ContactAliasObject(); + $entity->Id = (string)$id; + $entity->Label = $entry->name(); + $do->Name->Aliases[$id] = $entity; + } + } + // anniversaries + if ($so->anniversaries() !== null) { + foreach ($so->anniversaries() as $id => $entry) { + $entity = new ContactAnniversaryObject(); + if ($entry->date() !== null) { + $dateParams = $entry->date(); + if (method_exists($dateParams, 'value')) { + $entity->When = $dateParams->value(); + } + } + $do->Anniversaries[$id] = $entity; + } + } + // emails + if ($so->emails() !== null) { + foreach ($so->emails() as $id => $entry) { + $entity = new ContactEmailObject(); + $entity->Id = (string)$id; + $entity->Address = $entry->address(); + $entity->Priority = $entry->priority(); + $entity->Context = !empty($entry->context()) ? $entry->context()[0] : null; + $do->Email[$id] = $entity; + } + } + // phones + if ($so->phones() !== null) { + foreach ($so->phones() as $id => $entry) { + $entity = new ContactPhoneObject(); + $entity->Id = (string)$id; + $entity->Number = $entry->number(); + $entity->Label = $entry->label(); + $entity->Priority = $entry->priority(); + $entity->Context = !empty($entry->context()) ? $entry->context()[0] : null; + $do->Phone[$id] = $entity; + } + } + // addresses + if ($so->addresses() !== null) { + foreach ($so->addresses() as $id => $entry) { + $entity = new ContactPhysicalLocationObject(); + $entity->Id = (string)$id; + $entity->Coordinates = $entry->coordinates(); + if ($entry->timeZone() !== null) { + $entity->TimeZone = $entry->timeZone()->getName(); + } + $entity->Country = $entry->country(); + // parse components + foreach ($entry->components() as $component) { + $kind = $component->kind(); + $value = $component->value(); + if ($kind === 'pobox') { + $entity->Box = $value; + } elseif ($kind === 'unit') { + $entity->Unit = $value; + } elseif ($kind === 'street') { + $entity->Street = $value; + } elseif ($kind === 'locality') { + $entity->Locality = $value; + } elseif ($kind === 'region') { + $entity->Region = $value; + } elseif ($kind === 'code') { + $entity->Code = $value; + } elseif ($kind === 'country') { + $entity->Country = $value; + } + } + $do->PhysicalLocations[$id] = $entity; + } + } + // organizations + if ($so->organizations() !== null) { + foreach ($so->organizations() as $id => $entry) { + $entity = new ContactOrganizationObject(); + $entity->Id = (string)$id; + $entity->Label = $entry->name(); + $entity->SortName = $entry->sorting(); + if ($entry->units() !== null) { + foreach ($entry->units() as $unit) { + $entity->Units[] = $unit->name(); + } + } + $do->Organizations[$id] = $entity; + } + } + // titles + if ($so->titles() !== null) { + foreach ($so->titles() as $id => $entry) { + $entity = new ContactTitleObject(); + $entity->Id = (string)$id; + $entity->Label = $entry->name(); + $entity->Relation = $entry->relation(); + $entity->Kind = match ($entry->kind() ?? 'title') { + 'role' => ContactTitleTypes::Role, + default => ContactTitleTypes::Title, + }; + $do->Titles[$id] = $entity; + } + } + // tags + if ($so->tags() !== null) { + foreach ($so->tags() as $tag) { + $do->Tags[] = $tag; + } + } + // notes + if ($so->notes() !== null) { + foreach ($so->notes() as $id => $entry) { + $entity = new ContactNoteObject(); + $entity->Id = (string)$id; + $entity->Content = $entry->value(); + $entity->Date = $entry->created(); + $do->Notes[$id] = $entity; + } + } + // crypto keys + if ($so->crypto() !== null) { + foreach ($so->crypto() as $id => $entry) { + $entity = new ContactCryptoObject(); + $entity->Id = (string)$id; + $entity->Type = $entry->kind(); + $entity->Data = $entry->uri(); + $do->Crypto[$id] = $entity; + } + } + + return $do; + + } + + /** + * convert contact object to jmap object + * + * @since Release 1.0.0 + * + */ + public function fromContactObject(ContactObject $so): mixed { + + // create object + $to = new ContactParametersRequest(); + // universal id + if ($so->UUID !== null) { + $to->uid($so->UUID); + } + // creation date time + if ($so->CreatedOn !== null) { + $to->created($so->CreatedOn); + } + // modification date time + if ($so->ModifiedOn !== null) { + $to->updated($so->ModifiedOn); + } + // kind + if ($so->Kind !== null) { + $to->kind($so->Kind); + } + // name + if ($so->Name !== null) { + $nameParams = $to->name(); + if ($so->Name->First !== null || $so->Name->Last !== null || + $so->Name->Other !== null || $so->Name->Prefix !== null || + $so->Name->Suffix !== null) { + // Build name components + if ($so->Name->Prefix !== null) { + $component = $nameParams->components(); + $component->kind('prefix'); + $component->value($so->Name->Prefix); + } + if ($so->Name->First !== null) { + $component = $nameParams->components(); + $component->kind('given'); + $component->value($so->Name->First); + } + if ($so->Name->Other !== null) { + $component = $nameParams->components(); + $component->kind('additional'); + $component->value($so->Name->Other); + } + if ($so->Name->Last !== null) { + $component = $nameParams->components(); + $component->kind('surname'); + $component->value($so->Name->Last); + } + if ($so->Name->Suffix !== null) { + $component = $nameParams->components(); + $component->kind('suffix'); + $component->value($so->Name->Suffix); + } + } + } + // aliases + foreach ($so->Name->Aliases ?? [] as $id => $entry) { + $aliasParams = $to->aliases((string)$id); + if ($entry->Label !== null) { + $aliasParams->name($entry->Label); + } + } + // anniversaries + foreach ($so->Anniversaries ?? [] as $id => $entry) { + $annivParams = $to->anniversaries((string)$id); + if ($entry->When !== null) { + $annivParams->dateStamp()->value($entry->When); + } + } + // emails + foreach ($so->Email ?? [] as $id => $entry) { + $emailParams = $to->emails((string)$id); + if ($entry->Address !== null) { + $emailParams->address($entry->Address); + } + if ($entry->Priority !== null) { + $emailParams->priority($entry->Priority); + } + if ($entry->Context !== null) { + $emailParams->context($entry->Context); + } + } + // phones + foreach ($so->Phone ?? [] as $id => $entry) { + $phoneParams = $to->phones((string)$id); + if ($entry->Number !== null) { + $phoneParams->number($entry->Number); + } + if ($entry->Label !== null) { + $phoneParams->label($entry->Label); + } + if ($entry->Priority !== null) { + $phoneParams->priority($entry->Priority); + } + if ($entry->Context !== null) { + $phoneParams->context($entry->Context); + } + } + // addresses + foreach ($so->PhysicalLocations ?? [] as $id => $entry) { + $addressParams = $to->addresses((string)$id); + if ($entry->Box !== null || $entry->Unit !== null || $entry->Street !== null || + $entry->Locality !== null || $entry->Region !== null || $entry->Code !== null || + $entry->Country !== null) { + // Build address components + if ($entry->Box !== null) { + $component = $addressParams->components(); + $component->kind('pobox'); + $component->value($entry->Box); + } + if ($entry->Unit !== null) { + $component = $addressParams->components(); + $component->kind('unit'); + $component->value($entry->Unit); + } + if ($entry->Street !== null) { + $component = $addressParams->components(); + $component->kind('street'); + $component->value($entry->Street); + } + if ($entry->Locality !== null) { + $component = $addressParams->components(); + $component->kind('locality'); + $component->value($entry->Locality); + } + if ($entry->Region !== null) { + $component = $addressParams->components(); + $component->kind('region'); + $component->value($entry->Region); + } + if ($entry->Code !== null) { + $component = $addressParams->components(); + $component->kind('code'); + $component->value($entry->Code); + } + if ($entry->Country !== null) { + $component = $addressParams->components(); + $component->kind('country'); + $component->value($entry->Country); + } + } + if ($entry->Country !== null) { + $addressParams->country($entry->Country); + } + if ($entry->Coordinates !== null) { + // Parse coordinates in format "geo:latitude,longitude" + if (preg_match('/geo:([-\d.]+),([-\d.]+)/', $entry->Coordinates, $matches)) { + $addressParams->coordinates((float)$matches[1], (float)$matches[2]); + } + } + if ($entry->TimeZone !== null) { + try { + $addressParams->timeZone(new DateTimeZone($entry->TimeZone)); + } catch (\Exception $e) { + // Invalid timezone, skip + } + } + } + // organizations + foreach ($so->Organizations ?? [] as $id => $entry) { + $orgParams = $to->organizations((string)$id); + if ($entry->Label !== null) { + $orgParams->name($entry->Label); + } + if ($entry->SortName !== null) { + $orgParams->sorting($entry->SortName); + } + foreach ($entry->Units ?? [] as $unit) { + $unitParams = $orgParams->units(); + $unitParams->name($unit); + } + } + // titles + foreach ($so->Titles ?? [] as $id => $entry) { + $titleParams = $to->titles((string)$id); + if ($entry->Label !== null) { + $titleParams->name($entry->Label); + } + if ($entry->Kind !== null) { + $titleParams->kind(match ($entry->Kind ?? ContactTitleTypes::Title) { + ContactTitleTypes::Role => 'role', + default => 'title', + }); + } + if ($entry->Relation !== null) { + $titleParams->relation($entry->Relation); + } + } + // tags + if (!empty($so->Tags)) { + $tags = []; + foreach ($so->Tags as $tag) { + if ($tag->Value !== null) { + $tags[] = $tag->Value; + } + } + if (!empty($tags)) { + $to->tags(...$tags); + } + } + // notes + foreach ($so->Notes ?? [] as $id => $entry) { + $noteParams = $to->notes((string)$id); + if ($entry->Content !== null) { + $noteParams->contents($entry->Content); + } + if ($entry->Date !== null) { + $noteParams->created($entry->Date); + } + } + // crypto keys + foreach ($so->Crypto ?? [] as $id => $entry) { + $cryptoParams = $to->crypto((string)$id); + if ($entry->Type !== null) { + $cryptoParams->kind($entry->Type); + } + if ($entry->Data !== null) { + $cryptoParams->uri($entry->Data); + } + } + + return $to; + + } + + public function generateSignature(ContactObject $eo): string { + + // clone self + $o = clone $eo; + // remove non needed values + unset( + $o->Origin, + $o->ID, + $o->CID, + $o->Signature, + $o->CCID, + $o->CEID, + $o->CESN, + $o->UUID, + $o->CreatedOn, + $o->ModifiedOn + ); + // generate signature + return md5(json_encode($o, JSON_PARTIAL_OUTPUT_ON_ERROR)); + + } + +} diff --git a/lib/Service/Remote/RemoteCoreService.php b/lib/Service/Remote/RemoteCoreService.php new file mode 100644 index 0000000..285a8ce --- /dev/null +++ b/lib/Service/Remote/RemoteCoreService.php @@ -0,0 +1,219 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use Exception; + +use JmapClient\Client; +use JmapClient\Requests\Blob\BlobGet; +use JmapClient\Requests\Core\SubscriptionGet; +use JmapClient\Requests\Core\SubscriptionParameters as SubscriptionParametersRequest; +use JmapClient\Requests\Core\SubscriptionSet; +use JmapClient\Responses\Core\SubscriptionParameters as SubscriptionParametersResponse; +use JmapClient\Responses\ResponseException; +use OCA\JMAPC\Exceptions\JmapUnknownMethod; + +class RemoteCoreService { + protected Client $dataStore; + protected string $dataAccount; + + protected ?string $resourceNamespace = null; + protected ?string $resourceCollectionLabel = null; + protected ?string $resourceEntityLabel = null; + + public function __construct() { + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($dataAccount === null) { + if ($this->resourceNamespace !== null) { + $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); + } else { + $account = $dataStore->sessionAccountDefault('contacts'); + } + $this->dataAccount = $account !== null ? $account->id() : ''; + } else { + $this->dataAccount = $dataAccount; + } + + } + + /** + * list of subscriptions in remote storage + * + * @since Release 1.0.0 + * + * @return array + */ + public function subscriptionList(): array { + // construct request + $r0 = new SubscriptionGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command errored + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof SubscriptionParametersResponse) { + continue; + } + $list[] = $so; + } + // return collection of collections + return $list; + } + + /** + * retrieve subscription for specific collection + * + * @since Release 1.0.0 + */ + public function subscriptionFetch(string $id): ?SubscriptionParametersResponse { + // construct request + $r0 = new SubscriptionGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + if (!empty($id)) { + $r0->target($id); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert jmap object to collection object + $so = $response->object(0); + $to = null; + if ($so instanceof SubscriptionParametersResponse) { + $to = $so; + } + return $to; + } + + /** + * create subscription in remote storage + * + * @since Release 1.0.0 + */ + public function subscriptionCreate(SubscriptionParametersRequest $so): string { + // construct request + $r0 = new SubscriptionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->create('1', $so); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection id + return (string)$response->created()['1']['id']; + } + + /** + * modify collection in remote storage + * + * @since Release 1.0.0 + */ + public function subscriptionModify(string $id, SubscriptionParametersRequest $so): string { + // construct request + $r0 = new SubscriptionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->update($id, $so); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection id + return array_key_exists($id, $response->updated()) ? (string)$id : ''; + } + + /** + * retrieve blob from remote storage + * + * @since Release 1.0.0 + * + */ + public function blobFetch(string $account, string $id): Object { + + // TODO: testing remove later + //$data = ''; + //$this->dataStore->download($account, $id, $data); + //return null; + + // construct get request + $r0 = new BlobGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $r0->target($id); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert json object to message object and return + return $response->object(0); + + } + + /** + * deposit bolb to remote storage + * + * @since Release 1.0.0 + * + */ + public function blobDeposit(string $account, string $type, &$data): array { + + // TODO: testing remove later + $response = $this->dataStore->upload($account, $type, $data); + // convert response to object + $response = json_decode($response, true); + + return $response; + + /* + // construct set request + $r0 = new BlobSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel) + // construct object + $r0->target($id); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert json object to message object and return + return $response->object(0); + */ + + } +} diff --git a/lib/Service/Remote/RemoteEventsService.php b/lib/Service/Remote/RemoteEventsService.php new file mode 100644 index 0000000..525732a --- /dev/null +++ b/lib/Service/Remote/RemoteEventsService.php @@ -0,0 +1,1429 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use Datetime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; + +use JmapClient\Client; +use JmapClient\Requests\Calendar\CalendarGet; +use JmapClient\Requests\Calendar\CalendarParameters as CalendarParametersRequest; +use JmapClient\Requests\Calendar\CalendarSet; +use JmapClient\Requests\Calendar\EventChanges; +use JmapClient\Requests\Calendar\EventGet; +use JmapClient\Requests\Calendar\EventMutationParameters as EventMutationParametersRequest; +use JmapClient\Requests\Calendar\EventParameters as EventParametersRequest; +use JmapClient\Requests\Calendar\EventQuery; +use JmapClient\Requests\Calendar\EventQueryChanges; +use JmapClient\Requests\Calendar\EventSet; +use JmapClient\Responses\Calendar\CalendarParameters as CalendarParametersResponse; +use JmapClient\Responses\Calendar\EventMutationParameters as EventMutationParametersResponse; +use JmapClient\Responses\Calendar\EventParameters as EventParametersResponse; +use JmapClient\Responses\ResponseException; +use OCA\JMAPC\Exceptions\JmapUnknownMethod; +use OCA\JMAPC\Objects\BaseStringCollection; +use OCA\JMAPC\Objects\DeltaObject; +use OCA\JMAPC\Objects\Event\EventAvailabilityTypes; +use OCA\JMAPC\Objects\Event\EventCollectionObject; +use OCA\JMAPC\Objects\Event\EventLocationPhysicalObject; +use OCA\JMAPC\Objects\Event\EventLocationVirtualObject; +use OCA\JMAPC\Objects\Event\EventMutationObject; +use OCA\JMAPC\Objects\Event\EventNotificationAnchorTypes; +use OCA\JMAPC\Objects\Event\EventNotificationObject; +use OCA\JMAPC\Objects\Event\EventNotificationPatterns; +use OCA\JMAPC\Objects\Event\EventNotificationTypes; +use OCA\JMAPC\Objects\Event\EventObject; +use OCA\JMAPC\Objects\Event\EventOccurrenceObject; +use OCA\JMAPC\Objects\Event\EventOccurrencePatternTypes; +use OCA\JMAPC\Objects\Event\EventOccurrencePrecisionTypes; +use OCA\JMAPC\Objects\Event\EventParticipantObject; +use OCA\JMAPC\Objects\Event\EventParticipantRoleTypes; +use OCA\JMAPC\Objects\Event\EventParticipantStatusTypes; +use OCA\JMAPC\Objects\Event\EventParticipantTypes; +use OCA\JMAPC\Objects\Event\EventSensitivityTypes; +use OCA\JMAPC\Objects\OriginTypes; +use OCA\JMAPC\Store\Common\Filters\IFilter; +use OCA\JMAPC\Store\Common\Range\IRangeTally; +use OCA\JMAPC\Store\Common\Range\RangeAnchorType; +use OCA\JMAPC\Store\Common\Sort\ISort; +use OCA\JMAPC\Store\Remote\Filters\EventFilter; +use OCA\JMAPC\Store\Remote\Sort\EventSort; + +class RemoteEventsService { + public ?DateTimeZone $SystemTimeZone = null; + public ?DateTimeZone $UserTimeZone = null; + + protected Client $dataStore; + protected string $dataAccount; + + protected ?string $resourceNamespace = null; + protected ?string $resourceCollectionLabel = null; + protected ?string $resourceEntityLabel = null; + + protected array $collectionPropertiesDefault = []; + protected array $collectionPropertiesBasic = []; + protected array $entityPropertiesDefault = []; + protected array $entityPropertiesBasic = [ + 'id', 'calendarIds', 'uid', 'created', 'updated' + ]; + + public function __construct() { + + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($dataAccount === null) { + if ($this->resourceNamespace !== null) { + $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); + } else { + $account = $dataStore->sessionAccountDefault('contacts'); + } + $this->dataAccount = $account !== null ? $account->id() : ''; + } else { + $this->dataAccount = $dataAccount; + } + + } + + /** + * list of collections in remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param int|null $depth Depth of sub collections to return + * + * @return array + */ + public function collectionList(?string $location = null, ?string $granularity = null, ?int $depth = null): array { + // construct request + $r0 = new CalendarGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + // define location + if ($location !== null) { + $r0->target($location); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + $list = []; + foreach ($response->objects() as $id => $so) { + if (!$so instanceof CalendarParametersResponse) { + continue; + } + $to = $this->toEventCollection($so); + $to->Signature = $response->state(); + $list[$id] = $to; + } + // return collection of collections + return $list; + } + + /** + * retrieve properties for specific collection + * + * @since Release 1.0.0 + */ + public function collectionFetch(string $identifier): ?EventCollectionObject { + // construct request + $r0 = new CalendarGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->target($identifier); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to collection object + $so = $response->object(0); + $to = null; + if ($so instanceof CalendarParametersResponse) { + $to = $this->toEventCollection($so); + $to->Signature = $response->state(); + } + return $to; + } + + /** + * create collection in remote storage + * + * @since Release 1.0.0 + */ + public function collectionCreate(EventCollectionObject $so): ?string { + // convert entity + $to = $this->fromEventCollection($so); + $id = uniqid(); + // construct request + $r0 = new CalendarSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * modify collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionModify(string $identifier, EventCollectionObject $so): ?string { + // convert entity + $to = $this->fromEventCollection($so); + // construct request + $r0 = new CalendarSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->update($identifier, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new Exception("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * delete collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionDelete(string $identifier): ?string { + // construct request + $r0 = new CalendarSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->delete($identifier); + $r0->deleteContents(true); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new Exception("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * retrieve entities from remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param IRange|null $range Range of collections to return + * @param IFilter|null $filter Properties to filter by + * @param ISort|null $sort Properties to sort by + */ + public function entityList(?string $location = null, ?string $granularity = null, ?IRangeTally $range = null, ?IFilter $filter = null, ?ISort $sort = null, ?int $depth = null): array { + // construct request + $r0 = new EventQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + 'before' => $r0->filter()->before($value), + 'after' => $r0->filter()->after($value), + 'uid' => $r0->filter()->uid($value), + 'text' => $r0->filter()->text($value), + 'title' => $r0->filter()->title($value), + 'description' => $r0->filter()->description($value), + 'location' => $r0->filter()->location($value), + 'owner' => $r0->filter()->owner($value), + 'attendee' => $r0->filter()->attendee($value), + default => null + }; + } + } + // define order + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'created' => $r0->sort()->created($direction), + 'modified' => $r0->sort()->updated($direction), + 'start' => $r0->sort()->start($direction), + 'uid' => $r0->sort()->uid($direction), + 'recurrence' => $r0->sort()->recurrence($direction), + default => null + }; + } + } + // define range + if ($range !== null) { + if ($range->anchor() === RangeAnchorType::ABSOLUTE) { + $r0->limitAbsolute($range->getPosition(), $range->getCount()); + } + if ($range->anchor() === RangeAnchorType::RELATIVE) { + $r0->limitRelative($range->getPosition(), $range->getCount()); + } + } + // construct request + $r1 = new EventGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set target to query request + $r1->targetFromRequest($r0, '/ids'); + // select properties to return + if ($granularity === 'B') { + $r1->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert json objects to message objects + $state = $response->state(); + $list = $response->objects(); + foreach ($list as $id => $entry) { + $eo = $this->toEventObject($entry); + $eo->Signature = $this->generateSignature($eo); + $list[$id] = $eo; + } + // return message collection + return ['list' => $list, 'state' => $state]; + + } + + public function entityListFilter(): EventFilter { + return new EventFilter(); + } + + public function entityListSort(): EventSort { + return new EventSort(); + } + + /** + * delta for entities in remote storage + * + * @since Release 1.0.0 + * + * @return DeltaObject + */ + public function entityDelta(?string $location, string $state, string $granularity = 'D'): DeltaObject { + + if (empty($state)) { + $results = $this->entityList($location, 'B'); + $delta = new DeltaObject(); + $delta->signature = $results['state']; + foreach ($results['list'] as $entry) { + $delta->additions[] = $entry->ID; + } + return $delta; + } + if (empty($location)) { + return $this->entityDeltaDefault($state, $granularity); + } else { + return $this->entityDeltaSpecific($location, $state, $granularity); + } + } + + /** + * delta of changes for specific collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new EventQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set location constraint + if (!empty($location)) { + $r0->filter()->in($location); + } + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->added()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->removed()); + + return $delta; + } + + /** + * delta of changes in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaDefault(string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new EventChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->created()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->deleted()); + + return $delta; + } + + /** + * retrieve entity(ies) from remote storage + * + * @since Release 1.0.0 + * + * @param string $identifier Id of entity + * @param string $granularity Amount of detail to return + * + * @return EventObject|null + */ + public function entityFetch(string $location, string $identifier, string $granularity = 'D'): ?EventObject { + // construct request + $r0 = new EventGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target($identifier); + // select properties to return + if ($granularity === 'B') { + $r0->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to event object + $so = $response->object(0); + if ($so instanceof EventParametersResponse) { + $to = $this->toEventObject($so); + $to->Signature = $this->generateSignature($to); + } + return $to ?? null; + } + + /** + * retrieve entity(ies) from remote storage + * + * @since Release 1.0.0 + * + * @param array $identifiers Id of entity + * @param string $granularity Amount of detail to return + * + * @return array + */ + public function entityFetchMultiple(string $location, array $identifiers, string $granularity = 'D'): array { + // construct request + $r0 = new EventGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target(...$identifiers); + // select properties to return + if ($granularity === 'B') { + $r0->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object(s) to event object + $list = $response->objects(); + foreach ($list as $id => $so) { + if ($so instanceof EventParametersResponse) { + continue; + } + $to = $this->toEventObject($so); + $to->Signature = $this->generateSignature($to); + $list[$id] = $so; + } + // return object(s) + return $list; + } + + /** + * create entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCreate(string $location, EventObject $so): ?EventObject { + // convert entity + $entity = $this->fromEventObject($so); + $id = uniqid(); + // construct set request + $r0 = new EventSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create($id, $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $result['id']; + $ro->CreatedOn = isset($result['updated']) ? new DateTimeImmutable($result['updated']) : null; + $ro->ModifiedOn = $ro->CreatedOn; + $ro->Signature = $this->generateSignature($ro); + return $ro; + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * update entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityModify(string $location, string $identifier, EventObject $so): ?EventObject { + // convert entity + $entity = $this->fromEventObject($so); + // construct set request + $r0 = new EventSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($identifier, $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $identifier; + $ro->ModifiedOn = isset($result['updated']) ? new DateTimeImmutable($result['updated']) : null; + $ro->Signature = $this->generateSignature($ro); + return $ro; + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new Exception("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * delete entity from remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDelete(string $location, string $identifier): ?string { + // construct set request + $r0 = new EventSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $r0->delete($identifier); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new Exception("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * copy entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCopy(string $sourceLocation, string $identifier, string $destinationLocation): string { + return ''; + } + + /** + * move entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityMove(string $sourceLocation, string $identifier, string $destinationLocation): string { + // construct set request + $r0 = new EventSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $m0 = $r0->update($identifier); + $m0->in($destinationLocation); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection information + return array_key_exists($identifier, $response->updated()) ? (string)$identifier : ''; + } + + /** + * convert jmap collection to event collection + * + * @since Release 1.0.0 + * + */ + private function toEventCollection(CalendarParametersResponse $so): EventCollectionObject { + $to = new EventCollectionObject(); + $to->Id = $so->id(); + $to->Label = $so->label(); + $to->Description = $so->description(); + $to->Priority = $so->priority(); + $to->Visibility = $so->visible(); + $to->Color = $so->color(); + return $to; + } + + /** + * convert event collection to jmap collection + * + * @since Release 1.0.0 + * + */ + public function fromEventCollection(EventCollectionObject $so): CalendarParametersRequest { + // create object + $to = new CalendarParametersRequest(); + + if ($so->Label !== null) { + $to->label($so->Label); + } + if ($so->Description !== null) { + $to->description($so->Description); + } + if ($so->Priority !== null) { + $to->priority($so->Priority); + } + if ($so->Visibility !== null) { + $to->visible($so->Visibility); + } + if ($so->Color !== null) { + $to->color($so->Color); + } + + return $to; + } + + /** + * convert jmap object to event object + * + * @since Release 1.0.0 + * + */ + public function toEventObject(EventParametersResponse $so): EventObject { + // create object + $do = new EventObject(); + // source origin + $do->Origin = OriginTypes::External; + // collection id + if ($so->in() !== []) { + $do->CID = $so->in()[0]; + } + // entity id + if ($so->id() !== null) { + $do->ID = $so->id(); + } + // universal id + if ($so->uid() !== null) { + $do->UUID = $so->uid(); + } + // creation date time + if ($so->created() !== null) { + $do->CreatedOn = $so->created(); + } + // modification date time + if ($so->updated() !== null) { + $do->ModifiedOn = $so->updated(); + } + // occurrence(s) + if ($so->recurrenceRule() !== null) { + $soRule = $so->recurrenceRule(); + $doRule = new EventOccurrenceObject(); + + // Interval + if ($soRule->interval() !== null) { + $doRule->Interval = $soRule->interval(); + } + // Iterations + if ($soRule->count() !== null) { + $doRule->Iterations = $soRule->count(); + } + // Conclusion + if ($soRule->until() !== null) { + $doRule->Concludes = new DateTime($soRule->until()); + } + // Daily + if ($soRule->frequency() === 'daily') { + $doRule->Pattern = EventOccurrencePatternTypes::Absolute; + $doRule->Precision = EventOccurrencePrecisionTypes::Daily; + } + // Weekly + if ($soRule->frequency() === 'weekly') { + $doRule->Pattern = EventOccurrencePatternTypes::Absolute; + $doRule->Precision = EventOccurrencePrecisionTypes::Weekly; + $doRule->OnDayOfWeek = $this->fromDaysOfWeek($soRule->byDayOfWeek()); + } + // Monthly + if ($soRule->frequency() === 'monthly') { + $doRule->Precision = EventOccurrencePrecisionTypes::Monthly; + // Absolute + if ($soRule->byDayOfMonth() !== []) { + $doRule->Pattern = EventOccurrencePatternTypes::Absolute; + $doRule->OnDayOfMonth = $soRule->byDayOfMonth(); + } + // Relative + else { + $doRule->Pattern = EventOccurrencePatternTypes::Relative; + $doRule->OnDayOfWeek = $this->fromDaysOfWeek($soRule->byDayOfWeek()); + $doRule->OnPosition = $soRule->byPosition(); + } + } + // Yearly + if ($soRule->frequency() === 'yearly') { + $doRule->Precision = EventOccurrencePrecisionTypes::Yearly; + // nth day of year + if ($soRule->byDayOfYear() !== []) { + $doRule->Pattern = EventOccurrencePatternTypes::Absolute; + $doRule->OnDayOfYear = $soRule->byDayOfYear(); + $doRule->OnDayOfWeek = $this->fromDaysOfWeek($soRule->byDayOfWeek()); + } + // nth week of year + elseif ($soRule->byWeekOfYear() !== []) { + $doRule->Pattern = EventOccurrencePatternTypes::Relative; + $doRule->OnWeekOfYear = $soRule->byWeekOfYear(); + $doRule->OnDayOfWeek = $this->fromDaysOfWeek($soRule->byDayOfWeek()); + } + // nth month of year + elseif ($soRule->byMonthOfYear() !== []) { + if ($soRule->byDayOfMonth() !== []) { + $doRule->Pattern = EventOccurrencePatternTypes::Absolute; + $doRule->OnDayOfMonth = $soRule->byDayOfMonth(); + } else { + $doRule->Pattern = EventOccurrencePatternTypes::Relative; + $doRule->OnDayOfWeek = $this->fromDaysOfWeek($soRule->byDayOfWeek()); + $doRule->OnPosition = $soRule->byPosition(); + } + } + } + // add to collection + $do->OccurrencePattern = $doRule; + } + // other + $this->toEventInstanceObject($so, $do); + // mutations + foreach ($so->recurrenceMutations() as $id => $entry) { + /** @var EventMutationObject $mutation */ + $mutation = $this->toEventInstanceObject($entry, new EventMutationObject()); + $mutation->mutationId = $entry->mutationId() ?? new DateTimeImmutable($id); + $mutation->mutationTz = $entry->mutationTimeZone() ?? $do->TimeZone->getName(); + $do->OccurrenceMutations[$id] = $mutation; + } + + return $do; + } + + /** + * convert jmap object to event object + * + * @since Release 1.0.0 + * + */ + public function toEventInstanceObject(EventParametersResponse|EventMutationParametersResponse $so, EventObject|EventMutationObject $do): EventObject|EventMutationObject { + // sequence + if ($so->sequence() !== null) { + $do->Sequence = $so->sequence(); + } + // time zone + if ($so->timezone() !== null) { + $do->TimeZone = new DateTimeZone($so->timezone()); + } + // start date/time + if ($so->starts() !== null) { + $do->StartsOn = $so->starts(); + $do->StartsTZ = $do->TimeZone; + } + // end date/time + if ($so->ends() !== null) { + $do->EndsOn = $so->ends(); + $do->EndsTZ = $do->TimeZone; + } + // duration + if ($so->duration() !== null) { + $do->Duration = $so->duration(); + } + // all bay event + if ($so->timeless()) { + $do->Timeless = true; + } + // label + if ($so->label() !== null) { + $do->Label = $so->label(); + } + // description + if ($so->descriptionContents() !== null) { + $do->Description = $so->descriptionContents(); + } + // physical location(s) + foreach ($so->physicalLocations() as $id => $entry) { + $entity = new EventLocationPhysicalObject(); + $entity->Id = (string)$id; + $entity->Name = $entry->label(); + $entity->Description = $entry->description(); + $do->LocationsPhysical[$id] = $entity; + } + // virtual location(s) + foreach ($so->virtualLocations() as $id => $entry) { + $entity = new EventLocationVirtualObject(); + $entity->Id = (string)$id; + $entity->Name = $entry->label(); + $entity->Description = $entry->description(); + $do->LocationsVirtual[$id] = $entity; + } + // availability + if ($so->availability() !== null) { + $do->Availability = match (strtolower($so->availability() ?? 'busy')) { + 'free' => EventAvailabilityTypes::Free, + default => EventAvailabilityTypes::Busy, + }; + } + // priority + if ($so->priority() !== null) { + $do->Priority = $so->priority(); + } + // sensitivity + if ($so->privacy() !== null) { + $do->Sensitivity = match (strtolower($so->privacy() ?? 'public')) { + 'private' => EventSensitivityTypes::Private, + 'secret' => EventSensitivityTypes::Secret, + default => EventSensitivityTypes::Public, + }; + } + // color + if ($so->color() !== null) { + $do->Color = $so->color(); + } + // categories(s) + foreach ($so->categories() as $id => $entry) { + $do->Categories[] = $entry; + } + // tag(s) + foreach ($so->tags() as $id => $entry) { + $do->Tags[] = $entry; + } + // Organizer - Address and Name + if ($so->sender() !== null) { + $sender = $this->fromSender($so->sender()); + $do->Organizer->Address = $sender['address']; + $do->Organizer->Name = $sender['name']; + } + // participant(s) + foreach ($so->participants() as $id => $entry) { + $entity = new EventParticipantObject(); + $entity->Id = (string)$id; + $entity->Address = $entry->address(); + $entity->Name = $entry->name(); + $entity->Description = $entry->description(); + $entity->Comment = $entry->comment(); + $entity->Type = match (strtolower($entry->kind() ?? 'unknown')) { + 'individual' => EventParticipantTypes::Individual, + 'group' => EventParticipantTypes::Group, + 'resource' => EventParticipantTypes::Resource, + 'location' => EventParticipantTypes::Location, + default => EventParticipantTypes::Unknown, + }; + $entity->Status = match (strtolower($entry->status() ?? 'needs-action')) { + 'accepted' => EventParticipantStatusTypes::Accepted, + 'declined' => EventParticipantStatusTypes::Declined, + 'tentative' => EventParticipantStatusTypes::Tentative, + 'delegated' => EventParticipantStatusTypes::Delegated, + default => EventParticipantStatusTypes::None, + }; + + foreach ($entry->roles() as $role => $value) { + $entity->Roles[$role] = EventParticipantRoleTypes::from($role); + } + $do->Participants[$id] = $entity; + } + // notification(s) + foreach ($so->notifications() as $id => $entry) { + $trigger = $entry->trigger(); + $entity = new EventNotificationObject(); + $entity->Type = match (strtolower($entry->action() ?? 'display')) { + 'email' => EventNotificationTypes::Email, + 'audio' => EventNotificationTypes::Audible, + default => EventNotificationTypes::Visual, + }; + $entity->Pattern = match (strtolower($trigger->type() ?? 'unknown')) { + 'absolute' => EventNotificationPatterns::Absolute, + 'relative' => EventNotificationPatterns::Relative, + default => EventNotificationPatterns::Unknown, + }; + if ($entity->Pattern === EventNotificationPatterns::Absolute) { + $entity->When = $trigger->when(); + } elseif ($entity->Pattern === EventNotificationPatterns::Relative) { + $entity->Anchor = match (strtolower($trigger->anchor() ?? 'start')) { + 'end' => EventNotificationAnchorTypes::End, + default => EventNotificationAnchorTypes::Start, + }; + $entity->Offset = $trigger->offset(); + } + $do->Notifications[$id] = $entity; + } + + return $do; + } + + /** + * convert event object to jmap object + * + * @since Release 1.0.0 + * + */ + public function fromEventObject(EventObject $so): EventParametersRequest { + // create object + $do = new EventParametersRequest(); + // universal id + if ($so->UUID !== null) { + $do->uid($so->UUID); + } + // creation date time + if ($so->CreatedOn !== null) { + $do->created($so->CreatedOn); + } + // modification date time + if ($so->ModifiedOn !== null) { + $do->updated($so->ModifiedOn); + } + // occurrence(s) + if ($so->OccurrencePattern !== null) { + $soRule = $so->OccurrencePattern; + $doRule = $do->recurrenceRule(); + $doRule->frequency(match ($soRule->Precision ?? EventOccurrencePrecisionTypes::Daily) { + EventOccurrencePrecisionTypes::Yearly => 'yearly', + EventOccurrencePrecisionTypes::Monthly => 'monthly', + EventOccurrencePrecisionTypes::Weekly => 'weekly', + EventOccurrencePrecisionTypes::Hourly => 'hourly', + EventOccurrencePrecisionTypes::Minutely => 'minutely', + EventOccurrencePrecisionTypes::Secondly => 'secondly', + default => 'daily', + }); + if ($soRule->Interval !== null) { + $doRule->interval($soRule->Interval); + } + if ($soRule->Iterations !== null) { + $doRule->count($soRule->Iterations); + } + if ($soRule->Concludes !== null) { + $doRule->until($soRule->Concludes); + } + if ($soRule->OnDayOfWeek !== []) { + foreach ($soRule->OnDayOfWeek as $id => $day) { + $nDay = $doRule->byDayOfWeek($id); + $nDay->day($day); + } + } + if ($soRule->OnDayOfMonth !== []) { + $doRule->byDayOfMonth(...$soRule->OnDayOfMonth); + } + if ($soRule->OnDayOfYear !== []) { + $doRule->byDayOfYear(...$soRule->OnDayOfYear); + } + if ($soRule->OnWeekOfMonth !== []) { + $doRule->byWeekOfYear(...$soRule->OnWeekOfMonth); + } + if ($soRule->OnWeekOfYear !== []) { + $doRule->byWeekOfYear(...$soRule->OnWeekOfYear); + } + if ($soRule->OnMonthOfYear !== []) { + $doRule->byMonthOfYear(...$soRule->OnMonthOfYear); + } + if ($soRule->OnHour !== []) { + $doRule->byHour(...$soRule->OnHour); + } + if ($soRule->OnMinute !== []) { + $doRule->byMinute(...$soRule->OnMinute); + } + if ($soRule->OnSecond !== []) { + $doRule->bySecond(...$soRule->OnSecond); + } + if ($soRule->OnPosition !== []) { + $doRule->byPosition(...$soRule->OnPosition); + } + } + // common properties + $this->fromEventInstanceObject($so, $do); + + foreach ($so->OccurrenceMutations as $id => $mutation) { + $entity = new EventMutationParametersRequest(); + if ($mutation->mutationId) { + $mutationId = clone $mutation->mutationId; + } else { + $mutationId = new DateTimeImmutable($id); + } + $entity->mutationId($mutationId); + if ($mutation->mutationTz) { + $entity->mutationTimeZone($mutation->mutationTz); + } + $this->fromEventInstanceObject($mutation, $entity); + $do->recurrenceMutations($mutationId, $entity); + } + + return $do; + } + + /** + * convert event object to jmap object + * + * @since Release 1.0.0 + * + */ + public function fromEventInstanceObject(EventObject|EventMutationObject $so, EventParametersRequest|EventMutationParametersRequest $do): EventParametersRequest|EventMutationParametersRequest { + // sequence + if ($so->Sequence !== null) { + $do->sequence($so->Sequence); + } + // time zone + if ($so->TimeZone !== null) { + $do->timezone($so->TimeZone->getName()); + } + // start date/time + if ($so->StartsOn !== null) { + $do->starts($so->StartsOn); + } + // duration + if ($so->Duration !== null) { + $do->duration($so->Duration); + } elseif ($so->EndsOn instanceof DateTimeInterface) { + $do->duration($so->StartsOn->diff($so->EndsOn)); + } + // all day Event + if ($so->Timeless !== null) { + $do->timeless($so->Timeless); + } + // label + if ($so->Label !== null) { + $do->label($so->Label); + } + // description + if ($so->Description !== null) { + $do->descriptionContents($so->Description); + } + // physical location(s) + foreach ($so->LocationsPhysical as $entry) { + $entity = $do->physicalLocations($entry->Id); + if ($entry->Name !== null) { + $entity->label($entry->Name); + } + if ($entry->Description !== null) { + $entity->description($entry->Description); + } + } + // virtual location(s) + foreach ($so->LocationsVirtual as $entry) { + $entity = $do->virtualLocations($entry->Id); + if ($entry->Name !== null) { + $entity->label($entry->Name); + } + if ($entry->Description !== null) { + $entity->description($entry->Description); + } + } + // availability + if ($so->Availability !== null) { + $do->availability(match ($so->Availability ?? EventAvailabilityTypes::Busy) { + EventAvailabilityTypes::Free => 'free', + default => 'busy', + }); + } + // priority + if ($so->Priority !== null) { + $do->priority($so->Priority); + } + // sensitivity + if ($so->Sensitivity !== null) { + $do->privacy(match ($so->Sensitivity ?? EventSensitivityTypes::Public) { + EventSensitivityTypes::Private => 'private', + EventSensitivityTypes::Secret => 'secret', + default => 'public', + }); + } + // color + if ($so->Color !== null) { + $do->color($so->Color); + } + // tag(s) + if ($so->Tags->count() > 0) { + $do->tags(...$so->Tags); + } + // participant(s) + foreach ($so->Participants as $entry) { + $entity = $do->participants($entry->Id); + if ($entry->Address !== null) { + $entity->address($entry->Address); + $entity->send('imip', 'mailto:' . $entry->Address); + } + if ($entry->Name !== null) { + $entity->name($entry->Name); + } + if ($entry->Description !== null) { + $entity->description($entry->Description); + } + if ($entry->Comment !== null) { + $entity->comment($entry->Comment); + } + if ($entry->Type !== null) { + $entity->kind(match ($entry->Type ?? EventParticipantTypes::Individual) { + EventParticipantTypes::Group => 'group', + EventParticipantTypes::Resource => 'resource', + EventParticipantTypes::Location => 'location', + default => 'individual', + }); + } + if ($entry->Status !== null) { + $entity->kind(match ($entry->Status ?? EventParticipantStatusTypes::None) { + EventParticipantStatusTypes::Accepted => 'accepted', + EventParticipantStatusTypes::Declined => 'declined', + EventParticipantStatusTypes::Tentative => 'tentative', + EventParticipantStatusTypes::Delegated => 'delegated', + default => 'needs-action', + }); + } + if ($entry->Roles->count() > 0) { + $roles = []; + foreach ($entry->Roles as $role) { + $roles[] = $role->value; + } + $entity->roles(...$roles); + } + } + // notification(s) + foreach ($so->Notifications as $entry) { + $entity = $do->notifications($entry->Id); + if ($entry->Type !== null) { + $entity->action(match ($entry->Type ?? EventNotificationTypes::Visual) { + EventNotificationTypes::Email => 'email', + EventNotificationTypes::Audible => 'audio', + default => 'display', + }); + } + if ($entry->Pattern === EventNotificationPatterns::Absolute) { + $entity->trigger('absolute')->when($entry->When); + } elseif ($entry->Pattern === EventNotificationPatterns::Relative) { + if ($entry->Anchor === EventNotificationAnchorTypes::End) { + $entity->trigger('offset')->anchor('end')->offset($entry->Offset); + } else { + $entity->trigger('offset')->anchor('start')->offset($entry->Offset); + } + } else { + $entity->trigger('unknown'); + } + } + + return $do; + } + + /** + * generate entity signature + * + * @since Release 1.0.0 + * + * @param EventObject $to - event object + * + * @return string entity signature + */ + public function generateSignature(EventObject $to): string { + + // clone self + $o = clone $to; + // remove non needed values + unset( + $o->Origin, + $o->ID, + $o->CID, + $o->Signature, + $o->CCID, + $o->CEID, + $o->CESN, + $o->UUID, + $o->CreatedOn, + $o->ModifiedOn + ); + + // generate signature + return md5(json_encode($o, JSON_PARTIAL_OUTPUT_ON_ERROR)); + + } + + /** + * convert remote availability status to event object availability status + * + * @since Release 1.0.0 + * + * @param string $value remote availability status value + * + * @return string event object availability status value + */ + private function fromSender(?string $value): array { + + // Check if there are angle brackets + $bracketStart = strpos($value, '<'); + $bracketEnd = strpos($value, '>'); + + // If brackets are present + if ($bracketStart !== false && $bracketEnd !== false) { + // Extract the name and address + $name = trim(substr($value, 0, $bracketStart)); + $address = trim(substr($value, $bracketStart + 1, $bracketEnd - $bracketStart - 1)); + } else { + $name = null; + $address = $value; + } + + return ['address' => $address, 'name' => $name]; + + } + + /** + * convert remote days of the week to event object days of the week + * + * @since Release 1.0.0 + * + * @param array $days - remote days of the week values(s) + * + * @return array event object days of the week values(s) + */ + private function fromDaysOfWeek(array $days): array { + + $dow = []; + foreach ($days as $entry) { + if (isset($entry['day'])) { + $dow[] = $entry['day']; + } + } + return $dow; + + } + + /** + * convert event object days of the week to remote days of the week + * + * @since Release 1.0.0 + * + * @param array $days - internal days of the week values(s) + * + * @return array event object days of the week values(s) + */ + private function toDaysOfWeek(array $days): array { + + $dow = []; + foreach ($days as $key => $value) { + # code... + } + + return $dow; + + } + +} diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php new file mode 100644 index 0000000..f3d2c1b --- /dev/null +++ b/lib/Service/Remote/RemoteMailService.php @@ -0,0 +1,897 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use Exception; +use JmapClient\Client; +use JmapClient\Requests\Blob\BlobGet; +use JmapClient\Requests\Blob\BlobSet; +use JmapClient\Requests\Mail\MailboxGet; +use JmapClient\Requests\Mail\MailboxParameters as MailboxParametersRequest; +use JmapClient\Requests\Mail\MailboxQuery; +use JmapClient\Requests\Mail\MailboxSet; +use JmapClient\Requests\Mail\MailChanges; +use JmapClient\Requests\Mail\MailGet; +use JmapClient\Requests\Mail\MailIdentityGet; +use JmapClient\Requests\Mail\MailParameters as MailParametersRequest; +use JmapClient\Requests\Mail\MailQuery; +use JmapClient\Requests\Mail\MailQueryChanges; +use JmapClient\Requests\Mail\MailSet; +use JmapClient\Requests\Mail\MailSubmissionSet; +use JmapClient\Responses\Mail\MailboxParameters as MailboxParametersResponse; +use JmapClient\Responses\Mail\MailParameters as MailParametersResponse; +use JmapClient\Responses\ResponseException; +use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Delta\DeltaCollection; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\IRangeTally; +use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeAnchorType; +use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\ProviderJmapc\Exception\JmapUnknownMethod; +use KTXM\ProviderJmapc\Objects\Mail\Collection as MailCollectionObject; + +class RemoteMailService { + protected Client $dataStore; + protected string $dataAccount; + + protected ?string $resourceNamespace = null; + protected ?string $resourceCollectionLabel = null; + protected ?string $resourceEntityLabel = null; + + protected array $defaultMailProperties = [ + 'id', 'blobId', 'threadId', 'mailboxIds', 'keywords', 'size', + 'receivedAt', 'messageId', 'inReplyTo', 'references', 'sender', 'from', + 'to', 'cc', 'bcc', 'replyTo', 'subject', 'sentAt', 'hasAttachment', + 'attachments', 'preview', 'bodyStructure', 'bodyValues' + ]; + + public function __construct() { + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($dataAccount === null) { + if ($this->resourceNamespace !== null) { + $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); + } else { + $account = $dataStore->sessionAccountDefault('mail'); + } + $this->dataAccount = $account !== null ? $account->id() : ''; + } else { + $this->dataAccount = $dataAccount; + } + + } + + /** + * list of collections in remote storage + * + * @since Release 1.0.0 + */ + public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, IRange|null $range = null): array { + // construct request + $r0 = new MailboxQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + 'in' => $r0->filter()->in($value), + 'name' => $r0->filter()->name($value), + 'role' => $r0->filter()->role($value), + 'hasRoles' => $r0->filter()->hasRoles($value), + 'subscribed' => $r0->filter()->isSubscribed($value), + default => null + }; + } + } + // define order + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'name' => $r0->sort()->name($direction), + 'order' => $r0->sort()->order($direction), + default => null + }; + } + } + // construct request + $r1 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define target + $r1->targetFromRequest($r0, '/ids'); + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $id = $so->id(); + $list[$id] = $so->parametersRaw(); + $list[$id]['signature'] = $response->state(); + } + // return collection of collections + return $list; + } + + /** + * fresh instance of collection filter object + * + * @since Release 1.0.0 + */ + public function collectionListFilter(): Filter { + return new Filter(['in', 'name', 'role', 'hasRoles', 'subscribed']); + } + + /** + * fresh instance of collection sort object + * + * @since Release 1.0.0 + */ + public function collectionListSort(): Sort { + return new Sort(['name', 'order']); + } + + /** + * check existence of collections in remote storage + * + * @since Release 1.0.0 + */ + public function collectionExtant(string ...$identifiers): array { + $extant = []; + // construct request + $r0 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target(...$identifiers); + $r0->property('id'); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $extant[$so->id()] = true; + } + return $extant; + } + + /** + * retrieve properties for specific collection + * + * @since Release 1.0.0 + */ + public function collectionFetch(string $identifier): ?array { + // construct request + $r0 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target($identifier); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to collection object + $so = $response->object(0); + $to = null; + if ($so instanceof MailboxParametersResponse) { + $to = $so->parametersRaw(); + $to['signature'] = $response->state(); + } + return $to; + } + + /** + * create collection in remote storage + * + * @since Release 1.0.0 + */ + public function collectionCreate(string|null $location, array $so): ?array { + // convert entity + $to = new MailboxParametersRequest(); + $to->parametersRaw($so); + // define location + if (!empty($location)) { + $to->in($location); + } + $id = uniqid(); + // construct request + $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + return array_merge($so, $result); + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * modify collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionModify(string $identifier, array $so): ?array { + // convert entity + $to = new MailboxParametersRequest(); + $to->parametersRaw($so); + // construct request + $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($identifier, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + return array_merge($so, $result); + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new Exception("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * delete collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionDestroy(string $identifier, bool $force = false, bool $recursive = false): ?string { + // construct request + $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->delete($identifier); + if ($force) { + $r0->destroyContents(true); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new Exception("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * retrieve entities from remote storage + * + * @since Release 1.0.0 + */ + public function entityList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, IRange|null $range = null, string|null $granularity = null): array { + // construct request + $r0 = new MailQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + '*' => $r0->filter()->text($value), + 'in' => $r0->filter()->in($value), + 'inOmit' => $r0->filter()->inOmit($value), + 'from' => $r0->filter()->from($value), + 'to' => $r0->filter()->to($value), + 'cc' => $r0->filter()->cc($value), + 'bcc' => $r0->filter()->bcc($value), + 'subject' => $r0->filter()->subject($value), + 'body' => $r0->filter()->body($value), + 'attachmentPresent' => $r0->filter()->hasAttachment($value), + 'tagPresent' => $r0->filter()->keywordPresent($value), + 'tagAbsent' => $r0->filter()->keywordAbsent($value), + 'before' => $r0->filter()->receivedBefore($value), + 'after' => $r0->filter()->receivedAfter($value), + 'min' => $r0->filter()->sizeMin((int)$value), + 'max' => $r0->filter()->sizeMax((int)$value), + default => null + }; + } + } + // define order + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'from' => $r0->sort()->from($direction), + 'to' => $r0->sort()->to($direction), + 'subject' => $r0->sort()->subject($direction), + 'received' => $r0->sort()->received($direction), + 'sent' => $r0->sort()->sent($direction), + 'size' => $r0->sort()->size($direction), + 'tag' => $r0->sort()->keyword($direction), + default => null + }; + } + } + // define range + if ($range !== null) { + if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::ABSOLUTE) { + $r0->limitAbsolute($range->getPosition(), $range->getTally()); + } + if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::RELATIVE) { + $r0->limitRelative($range->getPosition(), $range->getTally()); + } + } + // construct get request + $r1 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set target to query request + $r1->targetFromRequest($r0, '/ids'); + // select properties to return + $r1->property(...$this->defaultMailProperties); + $r1->bodyAll(true); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // convert json objects to message objects + $state = $response->state(); + $list = $response->objects(); + foreach ($list as $id => $entry) { + if (!$entry instanceof MailParametersResponse) { + continue; + } + $list[$id] = $entry->parametersRaw(); + } + // return message collection + return ['list' => $list, 'state' => $state]; + } + + /** + * fresh instance of object filter + * + * @since Release 1.0.0 + */ + public function entityListFilter(): Filter { + return new Filter([ + 'in', + 'inOmit', + 'text', + 'from', + 'to', + 'cc', + 'bcc', + 'subject', + 'body', + 'attachmentPresent', + 'tagPresent', + 'tagAbsent', + 'receivedBefore', + 'receivedAfter', + 'sizeMin', + 'sizeMax' + ]); + } + + /** + * fresh instance of object sort + * + * @since Release 1.0.0 + */ + public function entityListSort(): Sort { + return new Sort([ + 'received', + 'sent', + 'from', + 'to', + 'subject', + 'size', + 'tag' + ]); + } + + /** + * fresh instance of object range + * + * @since Release 1.0.0 + */ + public function entityListRange(): RangeTally { + return new RangeTally(); + } + + /** + * check existence of entities in remote storage + * + * @since Release 1.0.0 + */ + public function entityExtant(string ...$identifiers): array { + $extant = []; + // construct request + $r0 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target(...$identifiers); + $r0->property('id'); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert json objects to message objects + foreach ($response->objects() as $so) { + if (!$so instanceof MailParametersResponse) { + continue; + } + $extant[$so->id()] = true; + } + return $extant; + } + + /** + * delta for entities in remote storage + * + * @since Release 1.0.0 + * + * @return Delta + */ + public function entityDelta(?string $location, string $state, string $granularity = 'D'): Delta { + + if (empty($state)) { + $results = $this->entityList($location, null, null, null, 'B'); + $delta = new Delta(); + $delta->signature = $results['state']; + foreach ($results['list'] as $entry) { + $delta->additions[] = $entry['id']; + } + return $delta; + } + if (empty($location)) { + return $this->entityDeltaDefault($state, $granularity); + } else { + return $this->entityDeltaSpecific($location, $state, $granularity); + } + } + + /** + * delta of changes for specific collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): Delta { + // construct set request + $r0 = new MailQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set location constraint + if (!empty($location)) { + $r0->filter()->in($location); + } + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new Delta(); + $delta->signature = $response->stateNew(); + $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); + $delta->modifications = new DeltaCollection([]); + $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); + + return $delta; + } + + /** + * delta of changes in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaDefault(string $state, string $granularity = 'D'): Delta { + // construct set request + $r0 = new MailChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new Delta(); + $delta->signature = $response->stateNew(); + $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); + $delta->modifications = new DeltaCollection([]); + $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); + + return $delta; + } + + /** + * retrieve entity from remote storage + * + * @since Release 1.0.0 + */ + public function entityFetch(string ...$identifiers): ?array { + // construct request + $r0 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target(...$identifiers); + // select properties to return + $r0->property(...$this->defaultMailProperties); + $r0->bodyAll(true); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert json objects to message objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof MailParametersResponse) { + continue; + } + $id = $so->id(); + $list[$id] = $so->parametersRaw(); + $list[$id]['signature'] = $response->state(); + } + // return message collection + return $list; + } + + /** + * create entity in remote storage + * + * @since Release 1.0.0 + */ + public function entityCreate(string $location, array $so): ?array { + // convert entity + $to = new MailParametersRequest(); + $to->parametersRaw($so); + $to->in($location); + $id = uniqid(); + // construct request + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + return array_merge($so, $result); + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new Exception("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * update entity in remote storage + * + * @since Release 1.0.0 + */ + public function entityModify(array $so): ?array { + // extract entity id + $id = $so['id']; + // convert entity + $to = new MailParametersRequest(); + $to->parametersRaw($so); + // construct request + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($id, $to); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command succeeded + if (array_key_exists($id, $response->updated())) { + // update entity + $ro = $response->updated()[$id]; + $so = array_merge($so, $ro); + return $so; + } + return null; + } + + /** + * delete entity from remote storage + * + * @since Release 1.0.0 + */ + public function entityDelete(string $id): ?string { + // construct set request + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $r0->delete($id); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command succeeded + if (array_search($id, $response->deleted()) !== false) { + return $response->stateNew(); + } + return null; + } + + /** + * copy entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCopy(string $location, MailMessageObject $so): ?MailMessageObject { + return null; + } + + /** + * move entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityMove(string $location, array $so): ?array { + // extract entity id + $id = $so['id']; + // construct request + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($id)->in($location); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command succeeded + if (array_key_exists($id, $response->updated())) { + $so = array_merge($so, ['mailboxIds' => [$location => true]]); + return $so; + } + return null; + } + + /** + * send entity + * + * @since Release 1.0.0 + * + */ + public function entitySend(string $identity, MailMessageObject $message, ?string $presendLocation = null, ?string $postsendLocation = null): string { + // determine if pre-send location is present + if ($presendLocation === null || empty($presendLocation)) { + throw new Exception('Pre-Send Location is missing', 1); + } + // determine if post-send location is present + if ($postsendLocation === null || empty($postsendLocation)) { + throw new Exception('Post-Send Location is missing', 1); + } + // determine if we have the basic required data and fail otherwise + if (empty($message->getFrom())) { + throw new Exception('Missing Requirements: Message MUST have a From address', 1); + } + if (empty($message->getTo())) { + throw new Exception('Missing Requirements: Message MUST have a To address(es)', 1); + } + // determine if message has attachments + if (count($message->getAttachments()) > 0) { + // process attachments first + $message = $this->depositAttachmentsFromMessage($message); + } + // convert from address object to string + $from = $message->getFrom()->getAddress(); + // convert to, cc and bcc address object arrays to single strings array + $to = array_map( + function ($entry) { return $entry->getAddress(); }, + array_merge($message->getTo(), $message->getCc(), $message->getBcc()) + ); + unset($cc, $bcc); + // construct set request + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create('1', $message)->in($presendLocation); + // construct set request + $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct envelope + $e1 = $r1->create('2'); + $e1->identity($identity); + $e1->message('#1'); + $e1->from($from); + $e1->to($to); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // return collection information + return (string)$response->created()['2']['id']; + } + + /** + * retrieve collection entity attachment from remote storage + * + * @since Release 1.0.0 + * + */ + public function depositAttachmentsFromMessage(MailMessageObject $message): MailMessageObject { + + $parameters = $message->toJmap(); + $attachments = $message->getAttachments(); + $matches = []; + + $this->findAttachmentParts($parameters['bodyStructure'], $matches); + + foreach ($attachments as $attachment) { + $part = $attachment->toJmap(); + if (isset($matches[$part->getId()])) { + // deposit attachment in data store + $response = $this->blobDeposit($account, $part->getType(), $attachment->getContents()); + // transfer blobId and size to mail part + $matches[$part->getId()]->blobId = $response['blobId']; + $matches[$part->getId()]->size = $response['size']; + unset($matches[$part->getId()]->partId); + } + } + + return (new MailMessageObject())->fromJmap($parameters); + + } + + protected function findAttachmentParts(object &$part, array &$matches) { + + if ($part->disposition === 'attachment' || $part->disposition === 'inline') { + $matches[$part->partId] = $part; + } + + foreach ($part->subParts as $entry) { + $this->findAttachmentParts($entry, $matches); + } + + } + + /** + * retrieve identity from remote storage + * + * @since Release 1.0.0 + * + */ + public function identityFetch(?string $account = null): array { + if ($account === null) { + $account = $this->dataAccount; + } + // construct set request + $r0 = new MailIdentityGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert json object to message object and return + return $response->objects(); + } + +} diff --git a/lib/Service/Remote/RemoteService.php b/lib/Service/Remote/RemoteService.php new file mode 100644 index 0000000..d1ecd4d --- /dev/null +++ b/lib/Service/Remote/RemoteService.php @@ -0,0 +1,213 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use JmapClient\Authentication\Basic; +use JmapClient\Authentication\Bearer; +use JmapClient\Authentication\JsonBasic; +use JmapClient\Authentication\JsonBasicCookie; +use JmapClient\Client as JmapClient; +use KTXF\Resource\Provider\ResourceServiceBaseInterface; +use KTXF\Resource\Provider\ResourceServiceIdentityBasic; +use KTXF\Resource\Provider\ResourceServiceIdentityBearer; +use KTXF\Resource\Provider\ResourceServiceIdentityOAuth; +use KTXF\Resource\Provider\ResourceServiceLocationUri; +use KTXM\ProviderJmapc\Providers\Mail\Service; +use KTXM\ProviderJmapc\Service\Remote\FM\RemoteContactsServiceFM; +use KTXM\ProviderJmapc\Service\Remote\FM\RemoteCoreServiceFM; +use KTXM\ProviderJmapc\Service\Remote\FM\RemoteEventsServiceFM; + +class RemoteService { + static string $clientTransportAgent = 'KtrixJMAP/1.0 (1.0; x64)'; + //public static string $clientTransportAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0'; + + /** + * Initialize remote data store client + * + * @since Release 1.0.0 + */ + public static function freshClient(Service $service): JmapClient { + + // defaults + $client = new JmapClient(); + $client->setTransportAgent(self::$clientTransportAgent); + $location = $service->getLocation(); + $identity = $service->getIdentity(); + + // location + if ($location instanceof ResourceServiceLocationUri === false) { + throw new \InvalidArgumentException('Service location is not a valid URI'); + } + $client->configureTransportMode($location->getScheme()); + $client->setHost($location->getHost() . ':' . $location->getPort()); + if (!empty($location->getPath())) { + $client->setDiscoveryPath($location->getPath()); + } + $client->configureTransportVerification((bool)$location->getVerifyPeer()); + // authentication + if (($identity instanceof ResourceServiceIdentityBasic) === false) { + throw new \InvalidArgumentException('Service identity is not a valid Basic or Bearer authentication'); + } + + if ($identity instanceof ResourceServiceIdentityBasic) { + $client->setAuthentication(new Basic( + $identity->getIdentity(), + $identity->getSecret() + )); + } + // debugging + if ($service->getDebug()) { + $client->configureTransportLogState(true); + $client->configureTransportLogLocation( + sys_get_temp_dir() . '/' . $location->getHost() . '-' . $identity->getIdentity() . '.log' + ); + } + // return + return $client; + + } + + /** + * Destroys remote data store client (Jmap Client) + * + * @since Release 1.0.0 + */ + public static function destroyClient(JmapClient $Client): void { + + // destroy remote data store client + $Client = null; + + } + + /** + * Appropriate Mail Service for Connection + * + * @since Release 1.0.0 + */ + public static function coreService(JmapClient $Client, ?string $dataAccount = null): RemoteCoreService { + // determine if client is connected + if (!$Client->sessionStatus()) { + $Client->connect(); + } + // construct service based on capabilities + if ($Client->sessionCapable('https://www.fastmail.com/dev/user', false)) { + $service = new RemoteCoreServiceFM(); + } else { + $service = new RemoteCoreService(); + } + $service->initialize($Client, $dataAccount); + return $service; + } + + /** + * Appropriate Mail Service for Connection + * + * @since Release 1.0.0 + */ + public static function mailService(JmapClient $Client, ?string $dataAccount = null): RemoteMailService { + // determine if client is connected + if (!$Client->sessionStatus()) { + $Client->connect(); + } + $service = new RemoteMailService(); + $service->initialize($Client, $dataAccount); + return $service; + } + + /** + * Appropriate Contacts Service for Connection + * + * @since Release 1.0.0 + */ + public static function contactsService(JmapClient $Client, ?string $dataAccount = null): RemoteContactsService { + // determine if client is connected + if (!$Client->sessionStatus()) { + $Client->connect(); + } + // construct service based on capabilities + if ($Client->sessionCapable('https://www.fastmail.com/dev/contacts', false)) { + $service = new RemoteContactsServiceFM(); + } else { + $service = new RemoteContactsService(); + } + $service->initialize($Client, $dataAccount); + return $service; + } + + /** + * Appropriate Events Service for Connection + * + * @since Release 1.0.0 + */ + public static function eventsService(JmapClient $Client, ?string $dataAccount = null): RemoteEventsService { + // determine if client is connected + if (!$Client->sessionStatus()) { + $Client->connect(); + } + // construct service based on capabilities + if ($Client->sessionCapable('https://www.fastmail.com/dev/calendars', false)) { + $service = new RemoteEventsServiceFM(); + } else { + $service = new RemoteEventsService(); + } + $service->initialize($Client, $dataAccount); + return $service; + } + + /** + * Appropriate Tasks Service for Connection + * + * @since Release 1.0.0 + */ + public static function tasksService(JmapClient $Client, ?string $dataAccount = null): RemoteTasksService { + // determine if client is connected + if (!$Client->sessionStatus()) { + $Client->connect(); + } + $service = new RemoteTasksService(); + $service->initialize($Client, $dataAccount); + return $service; + } + + public static function cookieStoreRetrieve(mixed $id): ?array { + + $file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . (string)$id . '.jmapc'; + + if (!file_exists($file)) { + return null; + } + + $data = file_get_contents($file); + $crypto = \OC::$server->get(\OCP\Security\ICrypto::class); + $data = $crypto->decrypt($data); + + if (!empty($data)) { + return json_decode($data, true); + } + + return null; + + } + + public static function cookieStoreDeposit(mixed $id, array $value): void { + + if (empty($value)) { + return; + } + + $crypto = \OC::$server->get(\OCP\Security\ICrypto::class); + $data = $crypto->encrypt(json_encode($value)); + + $file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . (string)$id . '.jmapc'; + file_put_contents($file, $data); + + } + +} diff --git a/lib/Service/Remote/RemoteTasksService.php b/lib/Service/Remote/RemoteTasksService.php new file mode 100644 index 0000000..870e852 --- /dev/null +++ b/lib/Service/Remote/RemoteTasksService.php @@ -0,0 +1,652 @@ + + * + * @author Sebastian Krupinski + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace KTXM\ProviderJmapc\Service\Remote; + +use DateTimeImmutable; +use DateTimeZone; +use Exception; + +use JmapClient\Client; + +use JmapClient\Requests\Tasks\TaskChanges; +use JmapClient\Requests\Tasks\TaskGet; +use JmapClient\Requests\Tasks\TaskListGet; +use JmapClient\Requests\Tasks\TaskListSet; +use JmapClient\Requests\Tasks\TaskParameters as TaskParametersRequest; +use JmapClient\Requests\Tasks\TaskQuery; +use JmapClient\Requests\Tasks\TaskQueryChanges; +use JmapClient\Requests\Tasks\TaskSet; +use JmapClient\Responses\ResponseException; +use JmapClient\Responses\Tasks\TaskListParameters as TaskListParametersResponse; +use JmapClient\Responses\Tasks\TaskParameters as TaskParametersResponse; +use OCA\JMAPC\Exceptions\JmapUnknownMethod; +use OCA\JMAPC\Objects\BaseStringCollection; +use OCA\JMAPC\Objects\DeltaObject; +use OCA\JMAPC\Objects\OriginTypes; +use OCA\JMAPC\Objects\Task\TaskCollectionObject; +use OCA\JMAPC\Objects\Task\TaskObject; +use OCA\JMAPC\Store\Common\Filters\IFilter; +use OCA\JMAPC\Store\Common\Range\IRangeTally; +use OCA\JMAPC\Store\Common\Sort\ISort; + +class RemoteTasksService { + public ?DateTimeZone $SystemTimeZone = null; + public ?DateTimeZone $UserTimeZone = null; + + protected Client $dataStore; + protected string $dataAccount; + + protected ?string $resourceNamespace = null; + protected ?string $resourceCollectionLabel = null; + protected ?string $resourceEntityLabel = null; + + protected array $collectionPropertiesDefault = []; + protected array $collectionPropertiesBasic = []; + protected array $entityPropertiesDefault = []; + protected array $entityPropertiesBasic = [ + 'id', 'calendarIds', 'uid', 'created', 'updated' + ]; + + public function __construct() { + } + + public function initialize(Client $dataStore, ?string $dataAccount = null) { + + $this->dataStore = $dataStore; + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($dataAccount === null) { + if ($this->resourceNamespace !== null) { + $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); + } else { + $account = $dataStore->sessionAccountDefault('contacts'); + } + $this->dataAccount = $account !== null ? $account->id() : ''; + } else { + $this->dataAccount = $dataAccount; + } + + } + + /** + * retrieve properties for specific collection + * + * @since Release 1.0.0 + * + */ + public function collectionFetch(string $id): ?TaskCollectionObject { + // construct request + $r0 = new TaskListGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + if (!empty($id)) { + $r0->target($id); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert jmap object to collection object + if ($response->object(0) instanceof TaskListParametersResponse) { + $co = $response->object(0); + $collection = new TaskCollectionObject(); + $collection->Id = $co->id(); + $collection->Label = $co->label(); + $collection->Description = $co->description(); + $collection->Priority = $co->priority(); + $collection->Visibility = $co->visible(); + $collection->Color = $co->color(); + return $collection; + } else { + return null; + } + } + + /** + * create collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionCreate(TaskCollectionObject $collection): string { + // construct request + $r0 = new TaskListSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $m0 = $r0->create('1'); + if ($collection->Label) { + $m0->label($collection->Label); + } + if ($collection->Description) { + $m0->description($collection->Description); + } + if ($collection->Priority) { + $m0->priority($collection->Priority); + } + if ($collection->Visibility) { + $m0->visible($collection->Visibility); + } + if ($collection->Color) { + $m0->color($collection->Color); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection id + return (string)$response->created()['1']['id']; + } + + /** + * update collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionUpdate(string $id, TaskCollectionObject $collection): string { + // construct request + $r0 = new TaskListSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $m0 = $r0->update($id); + $m0->label($collection->Label); + $m0->description($collection->Description); + $m0->priority($collection->Priority); + $m0->visible($collection->Visibility); + $m0->color($collection->Color); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection id + return array_key_exists($id, $response->updated()) ? (string)$id : ''; + } + + /** + * delete collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function collectionDelete(string $id): string { + // construct request + $r0 = new TaskListSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + $r0->delete($id); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection id + return (string)$response->deleted()[0]; + } + + /** + * list of collections in remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param int|null $depth Depth of sub collections to return + * + * @return array + */ + public function collectionList(?string $location = null, ?string $granularity = null, ?int $depth = null): array { + // construct request + $r0 = new TaskListGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceCollectionLabel); + // set target to query request + if ($location !== null) { + $r0->target($location); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command errored + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap objects to collection objects + $list = []; + foreach ($response->objects() as $co) { + $collection = new TaskCollectionObject(); + $collection->Id = $co->id(); + $collection->Label = $co->label(); + $collection->Description = $co->description(); + $collection->Priority = $co->priority(); + $collection->Visibility = $co->visible(); + $collection->Color = $co->color(); + $list[] = $collection; + } + // return collection of collections + return $list; + } + + /** + * retrieve entity from remote storage + * + * @since Release 1.0.0 + * + */ + public function entityFetch(string $location, string $id, string $granularity = 'D'): ?TaskObject { + // construct request + $r0 = new TaskGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->target($id); + // select properties to return + if ($granularity === 'B') { + $r0->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert jmap object to Task object + $eo = $this->toTaskObject($response->object(0)); + $eo->Signature = $this->generateSignature($eo); + + return $eo; + } + + /** + * create entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCreate(string $location, TaskObject $so): ?TaskObject { + // convert entity + $entity = $this->fromTaskObject($so); + // construct set request + $r0 = new TaskSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->create('1', $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return entity + if (isset($response->created()['1']['id'])) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $response->created()['1']['id']; + $ro->CreatedOn = isset($response->created()['1']['updated']) ? new DateTimeImmutable($response->created()['1']['updated']) : null; + $ro->ModifiedOn = $ro->CreatedOn; + $ro->Signature = $this->generateSignature($ro); + return $ro; + } else { + return null; + } + } + + /** + * update entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityModify(string $location, string $id, TaskObject $so): ?TaskObject { + // convert entity + $entity = $this->fromTaskObject($so); + // construct set request + $r0 = new TaskSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($id, $entity)->in($location); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // convert jmap object to Task object + if (array_key_exists($id, $response->updated())) { + $ro = clone $so; + $ro->Origin = OriginTypes::External; + $ro->ID = $id; + $ro->ModifiedOn = isset($response->updated()[$id]['updated']) ? new DateTimeImmutable($response->updated()[$id]['updated']) : null; + $ro->Signature = $this->generateSignature($ro); + } else { + $ro = null; + } + // return entity information + return $ro; + } + + /** + * delete entity from remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDelete(string $location, string $id): string { + // construct set request + $r0 = new TaskSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $r0->delete($id); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection information + return (string)$response->deleted()[0]; + } + + /** + * copy entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCopy(string $sourceLocation, string $id, string $destinationLocation): string { + return ''; + } + + /** + * move entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityMove(string $sourceLocation, string $id, string $destinationLocation): string { + // construct set request + $r0 = new TaskSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // construct object + $m0 = $r0->update($id); + $m0->in($destinationLocation); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // return collection information + return array_key_exists($id, $response->updated()) ? (string)$id : ''; + } + + /** + * retrieve entities from remote storage + * + * @since Release 1.0.0 + * + * @param string|null $location Id of parent collection + * @param string|null $granularity Amount of detail to return + * @param IRange|null $range Range of collections to return + * @param IFilter|null $filter Properties to filter by + * @param ISort|null $sort Properties to sort by + */ + public function entityList(?string $location = null, ?string $granularity = null, ?IRangeTally $range = null, ?IFilter $filter = null, ?ISort $sort = null, ?int $depth = null): array { + // construct request + $r0 = new TaskQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + [$operator, $property, $value] = $condition; + match($property) { + 'before' => $r0->filter()->before($value), + 'after' => $r0->filter()->after($value), + 'uid' => $r0->filter()->uid($value), + default => null + }; + } + } + // define sort + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + [$property, $direction] = $condition; + match($property) { + 'created' => $r0->sort()->created($direction), + 'modified' => $r0->sort()->updated($direction), + 'start' => $r0->sort()->start($direction), + 'uid' => $r0->sort()->uid($direction), + default => null + }; + } + } + // define order + if ($sort !== null) { + foreach ($sort->conditions() as $condition) { + match($condition['attribute']) { + 'created' => $r0->sort()->created($condition['direction']), + 'modified' => $r0->sort()->updated($condition['direction']), + 'start' => $r0->sort()->start($condition['direction']), + 'uid' => $r0->sort()->uid($condition['direction']), + 'recurrence' => $r0->sort()->recurrence($condition['direction']), + default => null + }; + } + } + // construct request + $r1 = new TaskGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set target to query request + $r1->targetFromRequest($r0, '/ids'); + // select properties to return + if ($granularity === 'B') { + $r1->property(...$this->entityPropertiesBasic); + } + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // convert json objects to message objects + $state = $response->state(); + $list = $response->objects(); + foreach ($list as $id => $entry) { + $list[$id] = $this->toTaskObject($entry); + } + // return message collection + return ['list' => $list, 'state' => $state]; + + } + + /** + * delta for entities in remote storage + * + * @since Release 1.0.0 + * + * @return DeltaObject + */ + public function entityDelta(?string $location, string $state, string $granularity = 'D'): DeltaObject { + + if (empty($state)) { + $results = $this->entityList($location, 'B'); + $delta = new DeltaObject(); + $delta->signature = $results['state']; + foreach ($results['list'] as $entry) { + $delta->additions[] = $entry->ID; + } + return $delta; + } + if (empty($location)) { + return $this->entityDeltaDefault($state, $granularity); + } else { + return $this->entityDeltaSpecific($location, $state, $granularity); + } + } + + /** + * delta of changes for specific collection in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new TaskQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set location constraint + if (!empty($location)) { + $r0->filter()->in($location); + } + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command errored + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->created()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->deleted()); + + return $delta; + } + + /** + * delta of changes in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaDefault(string $state, string $granularity = 'D'): DeltaObject { + // construct set request + $r0 = new TaskChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // determine if command errored + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // convert jmap object to delta object + $delta = new DeltaObject(); + $delta->signature = $response->stateNew(); + $delta->additions = new BaseStringCollection($response->created()); + $delta->modifications = new BaseStringCollection($response->updated()); + $delta->deletions = new BaseStringCollection($response->deleted()); + + return $delta; + } + + /** + * convert jmap object to Task object + * + * @since Release 1.0.0 + * + */ + public function toTaskObject(TaskParametersResponse $so): TaskObject { + // create object + $eo = new TaskObject(); + // source origin + $eo->Origin = OriginTypes::External; + // id + if ($so->id()) { + $eo->ID = $so->id(); + } + if ($so->in()) { + $eo->CID = $so->in()[0]; + } + // universal id + if ($so->uid()) { + $eo->UUID = $so->uid(); + } + // creation date time + if ($so->created()) { + $eo->CreatedOn = $so->created(); + } + // modification date time + if ($so->updated()) { + $eo->ModifiedOn = $so->updated(); + } + + return $eo; + + } + + /** + * convert Task object to jmap object + * + * @since Release 1.0.0 + * + */ + public function fromTaskObject(TaskObject $eo): TaskParametersRequest { + + // create object + $to = new TaskParametersRequest(); + // universal id + if ($eo->UUID) { + $to->uid($eo->UUID); + } + // creation date time + if ($eo->CreatedOn) { + $to->created($eo->CreatedOn); + } + // modification date time + if ($eo->ModifiedOn) { + $to->updated($eo->ModifiedOn); + } + + return $to; + + } + + + public function generateSignature(TaskObject $eo): string { + + // clone self + $o = clone $eo; + // remove non needed values + unset( + $o->Origin, + $o->ID, + $o->CID, + $o->Signature, + $o->CCID, + $o->CEID, + $o->CESN, + $o->UUID, + $o->CreatedOn, + $o->ModifiedOn + ); + + // generate signature + return md5(json_encode($o, JSON_PARTIAL_OUTPUT_ON_ERROR)); + + } + +} diff --git a/lib/Stores/ServiceStore.php b/lib/Stores/ServiceStore.php new file mode 100644 index 0000000..7be82d3 --- /dev/null +++ b/lib/Stores/ServiceStore.php @@ -0,0 +1,179 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderJmapc\Stores; + +use KTXC\Db\DataStore; +use KTXF\Security\Crypto; +use KTXF\Utile\UUID; +use KTXM\ProviderJmapc\Providers\Mail\Service; + +/** + * JMAP Service Store + * + * Shared by Mail, Calendar, and Contacts providers. + */ +class ServiceStore +{ + protected const COLLECTION_NAME = 'provider_jmapc_services'; + + public function __construct( + protected readonly DataStore $dataStore, + protected readonly Crypto $crypto, + ) {} + + /** + * List services for a tenant and user, optionally filtered by service IDs + */ + public function list(string $tenantId, string $userId, ?array $filter = null): array + { + $filterCondition = [ + 'tid' => $tenantId, + 'uid' => $userId, + ]; + + if ($filter !== null && !empty($filter)) { + $filterCondition['sid'] = ['$in' => $filter]; + } + + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition); + + $list = []; + foreach ($cursor as $entry) { + + if (isset($entry['identity']['secret'])) { + $entry['identity']['secret'] = $this->crypto->decrypt($entry['identity']['secret']); + } + + $list[$entry['sid']] = $entry; + } + return $list; + } + + /** + * Check existence of services by IDs for a tenant and user + */ + public function extant(string $tenantId, string $userId, array $identifiers): array + { + if (empty($identifiers)) { + return []; + } + + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find( + [ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => ['$in' => array_map('strval', $identifiers)] + ], + ['projection' => ['sid' => 1]] + ); + + $existingIds = []; + foreach ($cursor as $document) { + $existingIds[] = $document['sid']; + } + + // Build result map: all identifiers default to false, existing ones set to true + $result = []; + foreach ($identifiers as $id) { + $result[(string) $id] = in_array((string) $id, $existingIds, true); + } + + return $result; + } + + /** + * Retrieve a single service by ID + */ + public function fetch(string $tenantId, string $userId, string|int $serviceId): ?Service + { + $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ]); + + if (!$document) { + return null; + } + + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->decrypt($document['identity']['secret']); + } + + return (new Service())->fromStore($document); + } + + /** + * Create a new service + */ + public function create(string $tenantId, string $userId, Service $service): Service + { + $document = $service->toStore(); + + // prepare document for insertion + $document['tid'] = $tenantId; + $document['uid'] = $userId; + $document['sid'] = UUID::v4(); + $document['createdOn'] = new \MongoDB\BSON\UTCDateTime(); + $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); + } + + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); + + return (new Service())->fromStore($document); + } + + /** + * Modify an existing service + */ + public function modify(string $tenantId, string $userId, Service $service): Service + { + $serviceId = $service->id(); + if (empty($serviceId)) { + throw new \InvalidArgumentException('Service ID is required for update'); + } + + // prepare document for modification + $document = $service->toStore(); + $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); + } + unset($document['sid'], $document['tid'], $document['uid'], $document['createdOn']); + + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( + [ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ], + ['$set' => $document] + ); + + return (new Service())->fromStore($document); + } + + /** + * Delete a service + */ + public function delete(string $tenantId, string $userId, string|int $serviceId): bool + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ]); + + return $result->getDeletedCount() > 0; + } + +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5eeac90 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "provider_jmapc", + "description": "Ktrix JMAP Provider Module", + "version": "1.0.0", + "private": true, + "license": "AGPL-3.0-or-later", + "author": "Ktrix", + "type": "module", + "scripts": { + "build": "vite build --mode production --config vite.config.ts", + "dev": "vite build --mode development --config vite.config.ts", + "watch": "vite build --mode development --watch --config vite.config.ts", + "typecheck": "vue-tsc --noEmit", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" + }, + "dependencies": { + "pinia": "^2.3.1", + "vue": "^3.5.18", + "vue-router": "^4.5.1", + "vuetify": "^3.10.2" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vite": "^7.1.2", + "vue-tsc": "^3.0.5" + } +} diff --git a/src/components/AccountConfigurationPanel.vue b/src/components/AccountConfigurationPanel.vue new file mode 100644 index 0000000..19098f2 --- /dev/null +++ b/src/components/AccountConfigurationPanel.vue @@ -0,0 +1,506 @@ + + + + + diff --git a/src/components/JmapAuthPanel.vue b/src/components/JmapAuthPanel.vue new file mode 100644 index 0000000..9a7d67d --- /dev/null +++ b/src/components/JmapAuthPanel.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/src/components/JmapConfigPanel.vue b/src/components/JmapConfigPanel.vue new file mode 100644 index 0000000..2c5842f --- /dev/null +++ b/src/components/JmapConfigPanel.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/src/integrations.ts b/src/integrations.ts new file mode 100644 index 0000000..4b3b2bc --- /dev/null +++ b/src/integrations.ts @@ -0,0 +1,57 @@ +import type { ModuleIntegrations } from "@KTXC/types/moduleTypes"; +import type { ProviderMetadata } from "@KTXM/MailManager/types/provider"; +import type { ServiceInterface } from "@KTXM/MailManager/types/service"; +import { JmapServiceObject } from './models/JmapServiceObject' + +const integrations: ModuleIntegrations = { + mail_account_config_panels: [ + { + id: 'jmap', + label: 'JMAP', + icon: 'mdi-api', + caption: 'Modern JSON-based mail protocol', + component: () => import('@/components/JmapConfigPanel.vue'), + priority: 10, + } + ], + mail_account_auth_panels: [ + { + id: 'jmap', + component: () => import('@/components/JmapAuthPanel.vue'), + } + ], + mail_service_factory: [ + { + id: 'jmap', + factory: (data: ServiceInterface) => new JmapServiceObject().fromJson(data) + } + ], + mail_provider_metadata: [ + { + id: 'jmap', + label: 'JMAP', + description: 'Modern JSON-based mail API protocol', + icon: 'mdi-api', + auth: { + methods: ['BA', 'OA', 'TA'], + default: 'BA', + allowMethodSelection: true, + oauth: { + // OAuth config will be provider-specific + // Some JMAP providers use OAuth (e.g., Fastmail) + authorizeUrl: '', // Configured per-instance + tokenUrl: '', + scopes: ['mail'], + flowType: 'authorization_code' + } + }, + supportsDiscovery: true, + meta: { + protocol: 'JMAP', + wellKnownPath: '/.well-known/jmap' + } + } as ProviderMetadata + ] +}; + +export default integrations; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c7b8b64 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,14 @@ +import routes from '@/routes' +import integrations from '@/integrations' +import type { App as Vue } from 'vue' + +// CSS filename is injected by the vite plugin at build time +export const css = ['__CSS_FILENAME_PLACEHOLDER__'] + +export { routes, integrations } + +export default { + install(app: Vue) { + // No additional plugins needed for this module + } +} diff --git a/src/models/JmapServiceObject.ts b/src/models/JmapServiceObject.ts new file mode 100644 index 0000000..9956831 --- /dev/null +++ b/src/models/JmapServiceObject.ts @@ -0,0 +1,55 @@ +/** + * JMAP-specific ServiceObject implementation + * Extends base ServiceObject with JMAP-specific functionality + */ + +import { ServiceObject } from '@KTXM/MailManager/models/service' +import type { JmapAuxiliary } from '../types/auxiliary' + +/** + * JMAP Service Object + * Provides typed access to JMAP-specific auxiliary data + */ +export class JmapServiceObject extends ServiceObject { + /** + * Type-safe access to JMAP-specific auxiliary data + */ + get jmapAuxiliary(): JmapAuxiliary { + return (this._data.auxiliary ?? {}) as JmapAuxiliary + } + + get hasCore(): boolean { + return this.jmapAuxiliary.capable?.core === true; + } + + get hasMail(): boolean { + return this.jmapAuxiliary.capable?.mail === true; + } + + get hasCalendar(): boolean { + return this.jmapAuxiliary.capable?.calendar === true; + } + + get hasContacts(): boolean { + return this.jmapAuxiliary.capable?.contacts === true; + } + + get hasDocuments(): boolean { + return this.jmapAuxiliary.capable?.documents === true; + } + + /** + * Get JMAP session URL + */ + get sessionUrl(): string | undefined { + return this.jmapAuxiliary.sessionUrl + } + + /** + * Get JMAP account ID + */ + get accountId(): string | undefined { + return this.jmapAuxiliary.accountId + } + +} diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..4eb861f --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,7 @@ +// Routes removed - JMAP accounts are now managed through the unified mail_manager +// Users should access accounts via: /m/mail_manager/accounts + +const routes = []; + +export default routes; + diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts new file mode 100644 index 0000000..3a0b290 --- /dev/null +++ b/src/services/serviceService.ts @@ -0,0 +1,81 @@ +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; +import type { + Service, + ConnectionTestRequest, + ConnectionTestResponse, + CollectionsResponse, + DiscoverResponse +} from '@/models/service'; + +const BASE_PATH = '/m/provider_jmapc'; + +export const serviceService = { + /** + * List all JMAP services for current user + */ + async list(capability?: string): Promise<{ services: Service[] }> { + const params = capability ? `?capability=${encodeURIComponent(capability)}` : ''; + return fetchWrapper.get(`${BASE_PATH}/services${params}`); + }, + + /** + * Get a single service by ID + */ + async fetch(id: string): Promise { + return fetchWrapper.get(`${BASE_PATH}/services/${id}`); + }, + + /** + * Create a new service + */ + async create(service: Partial): Promise { + return fetchWrapper.post(`${BASE_PATH}/services`, service); + }, + + /** + * Update an existing service + */ + async update(id: string, service: Partial): Promise { + return fetchWrapper.put(`${BASE_PATH}/services/${id}`, service); + }, + + /** + * Delete a service + */ + async destroy(id: string): Promise<{ success: boolean }> { + return fetchWrapper.delete(`${BASE_PATH}/services/${id}`); + }, + + /** + * Test JMAP connection + */ + async test(request: ConnectionTestRequest): Promise { + return fetchWrapper.post(`${BASE_PATH}/services/test`, request); + }, + + /** + * Auto-discover JMAP endpoint from hostname + */ + async discover(hostname: string, protocol?: string, port?: number, path?: string): Promise { + return fetchWrapper.post(`${BASE_PATH}/services/discover`, { + hostname, + protocol, + port, + path + }); + }, + + /** + * Fetch collections for a service + */ + async fetchCollections(id: string): Promise { + return fetchWrapper.get(`${BASE_PATH}/services/${id}/collections`); + }, + + /** + * Refresh collections for a service (re-query remote server) + */ + async refreshCollections(id: string): Promise { + return fetchWrapper.post(`${BASE_PATH}/services/${id}/collections/refresh`, {}); + }, +}; diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts new file mode 100644 index 0000000..662b191 --- /dev/null +++ b/src/stores/servicesStore.ts @@ -0,0 +1,208 @@ +import { defineStore } from 'pinia'; +import { serviceService } from '@/services/serviceService'; +import { ServiceModel } from '@/models/service'; +import type { Service } from '@/models/service'; + +export const useServicesStore = defineStore('jmapc_services', { + state: () => ({ + services: [] as ServiceModel[], + loading: false, + error: null as string | null, + }), + + getters: { + /** + * Get services filtered by capability + */ + byCapability: (state) => (capability: string) => { + return state.services.filter(s => s.capabilities.includes(capability)); + }, + + /** + * Get mail services + */ + mailServices: (state) => { + return state.services.filter(s => s.hasMail()); + }, + + /** + * Get contact services + */ + contactServices: (state) => { + return state.services.filter(s => s.hasContacts()); + }, + + /** + * Get calendar services + */ + calendarServices: (state) => { + return state.services.filter(s => s.hasCalendars()); + }, + + /** + * Get service by ID + */ + getById: (state) => (id: string) => { + return state.services.find(s => s.id === id); + }, + }, + + actions: { + /** + * Load all services from API + */ + async loadServices(capability?: string) { + this.loading = true; + this.error = null; + + try { + const response = await serviceService.list(capability); + this.services = response.services.map(s => new ServiceModel(s)); + } catch (error: any) { + this.error = error.message || 'Failed to load services'; + throw error; + } finally { + this.loading = false; + } + }, + + /** + * Load a single service + */ + async loadService(id: string) { + this.loading = true; + this.error = null; + + try { + const service = await serviceService.fetch(id); + const model = new ServiceModel(service); + + // Update or add to store + const index = this.services.findIndex(s => s.id === id); + if (index >= 0) { + this.services[index] = model; + } else { + this.services.push(model); + } + + return model; + } catch (error: any) { + this.error = error.message || 'Failed to load service'; + throw error; + } finally { + this.loading = false; + } + }, + + /** + * Create a new service + */ + async createService(service: Partial) { + this.loading = true; + this.error = null; + + try { + const created = await serviceService.create(service); + const model = new ServiceModel(created); + this.services.push(model); + return model; + } catch (error: any) { + this.error = error.message || 'Failed to create service'; + throw error; + } finally { + this.loading = false; + } + }, + + /** + * Update an existing service + */ + async updateService(id: string, service: Partial) { + this.loading = true; + this.error = null; + + try { + const updated = await serviceService.update(id, service); + const model = new ServiceModel(updated); + + const index = this.services.findIndex(s => s.id === id); + if (index >= 0) { + this.services[index] = model; + } + + return model; + } catch (error: any) { + this.error = error.message || 'Failed to update service'; + throw error; + } finally { + this.loading = false; + } + }, + + /** + * Delete a service + */ + async deleteService(id: string) { + this.loading = true; + this.error = null; + + try { + await serviceService.destroy(id); + this.services = this.services.filter(s => s.id !== id); + } catch (error: any) { + this.error = error.message || 'Failed to delete service'; + throw error; + } finally { + this.loading = false; + } + }, + + /** + * Test connection with provided settings + */ + async testConnection(config: any) { + return serviceService.test(config); + }, + + /** + * Auto-discover JMAP endpoint + */ + async discover(hostname: string, protocol?: string, port?: number, path?: string) { + return serviceService.discover(hostname, protocol, port, path); + }, + + /** + * Fetch collections for a service + */ + async fetchCollections(id: string) { + return serviceService.fetchCollections(id); + }, + + /** + * Refresh collections for a service + */ + async refreshCollections(id: string) { + const result = await serviceService.refreshCollections(id); + + // Update local service with fresh collection data + const service = this.getById(id); + if (service && result.success) { + service.collections = { + contacts: result.contacts || [], + calendars: result.calendars || [] + }; + } + + return result; + }, + + /** + * Clear all services + */ + reset() { + this.services = []; + this.error = null; + this.loading = false; + }, + }, +}); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..eef2abd --- /dev/null +++ b/src/style.css @@ -0,0 +1 @@ +/* jmap client provider module styles */ diff --git a/src/types/auxiliary.ts b/src/types/auxiliary.ts new file mode 100644 index 0000000..94c06fd --- /dev/null +++ b/src/types/auxiliary.ts @@ -0,0 +1,28 @@ +/** + * JMAP-specific auxiliary data types + * Stored in ServiceInterface.auxiliary field + */ + +/** + * JMAP-specific auxiliary data + * Contains provider-specific metadata and capabilities + */ +export interface JmapAuxiliary { + /** JMAP capability flags */ + capable?: { + core?: boolean; + mail?: boolean; + calendar?: boolean; + contacts?: boolean; + documents?: boolean; + }; + + /** JMAP session URL */ + sessionUrl?: string; + + /** JMAP account ID */ + accountId?: string; + + /** Allow additional custom fields */ + [key: string]: any; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..57d99a9 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@/*": ["./src/*"], + "@KTXC/*": ["../../core/src/*"], + "@KTXM/MailManager/*": ["../mail_manager/src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7119d69 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,58 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + { + name: 'inject-css-filename', + enforce: 'post', + generateBundle(_options, bundle) { + const cssFile = Object.keys(bundle).find(name => name.endsWith('.css')) + if (!cssFile) return + + for (const fileName of Object.keys(bundle)) { + const chunk = bundle[fileName] + if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) { + chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`) + console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`) + } + } + } + } + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@KTXC': path.resolve(__dirname, '../../core/src'), + '@KTXM/MailManager': path.resolve(__dirname, '../mail_manager/src'), + }, + }, + build: { + outDir: 'static', + emptyOutDir: true, + sourcemap: true, + lib: { + entry: path.resolve(__dirname, 'src/main.ts'), + formats: ['es'], + fileName: () => 'module.mjs', + }, + rollupOptions: { + external: [ + 'vue', + 'vue-router', + 'pinia', + ], + output: { + assetFileNames: (assetInfo) => { + if (assetInfo.name?.endsWith('.css')) { + return 'provider_jmapc-[hash].css' + } + return '[name]-[hash][extname]' + } + } + }, + }, +})