diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f2267685f..cc84b165d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,8 @@ permissions: contents: read env: - E2E_NODE_VERSION: "20" # TODO: Extract automatically using another action + E2E_NODE_VERSION: "20" + STALWART_PWD: "secretpassword" jobs: unit-tests: @@ -94,7 +95,7 @@ jobs: cache: 'redis' name: ${{ matrix.nextcloud-versions }} w/ php${{ matrix.php-versions }}-${{ matrix.db }}-${{ matrix.cache }} integration tests ${{ matrix.coverage && '(coverage)' || ''}} services: - mail-service: + mail-dovecot-service: image: ghcr.io/christophwurst/docker-imap-devel:latest env: MAILNAME: mail.domain.tld @@ -105,6 +106,15 @@ jobs: - 143:143 - 993:993 - 4190:4190 + mail-stalwart-service: + image: stalwartlabs/stalwart:v0.15.5 + env: + STALWART_ADMIN_PASSWORD: ${{ env.STALWART_PWD }} + ports: + - 10080:8080 + - 10025:25 + - 10143:143 + - 10993:993 mariadb-service: image: ghcr.io/nextcloud/continuous-integration-mariadb-11.4:latest env: @@ -142,6 +152,16 @@ jobs: ports: - 6379:6379 steps: + - name: Create domain and account in Stalwart + run: | + curl -sf -X POST http://localhost:10080/api/principal \ + -u "admin:${{ env.STALWART_PWD }}" \ + -H 'Content-Type: application/json' \ + -d '{"type":"domain","name":"example.com"}' + curl -sf -X POST http://localhost:10080/api/principal \ + -u "admin:${{ env.STALWART_PWD }}" \ + -H 'Content-Type: application/json' \ + -d '{"type":"individual","name":"user@example.com","secrets":["mypassword"],"emails":["user@example.com"],"roles":["user"]}' - name: Set up Nextcloud env uses: nextcloud/setup-server-action@34b73d5b0e3633f83a52227d00cc2a6c41d01d9a # v1.0.0 with: diff --git a/appinfo/info.xml b/appinfo/info.xml index 9ab13f49cb..d4f666fbd3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 5.8.0-dev.2 + 5.8.0-dev.3 agpl Christoph Wurst GretaD @@ -79,14 +79,15 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\Command\AddMissingTags OCA\Mail\Command\CleanUp - OCA\Mail\Command\CreateAccount + OCA\Mail\Command\CreateImapAccount + OCA\Mail\Command\CreateJmapAccount OCA\Mail\Command\CreateTagMigrationJobEntry OCA\Mail\Command\DebugAccount OCA\Mail\Command\DeleteAccount - OCA\Mail\Command\DiagnoseAccount OCA\Mail\Command\ExportAccount OCA\Mail\Command\ExportAccountThreads OCA\Mail\Command\PredictImportance + OCA\Mail\Command\TestAccount OCA\Mail\Command\SyncAccount OCA\Mail\Command\Thread OCA\Mail\Command\TrainAccount diff --git a/composer.json b/composer.json index 9a0a23dbed..be70a0413d 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,12 @@ "optimize-autoloader": true, "autoloader-suffix": "Mail" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/sebastiankrupinski/jmap-client-php" + } + ], "require": { "php": ">=8.1 <=8.4", "ext-openssl": "*", @@ -43,6 +49,7 @@ "psr/log": "^3.0.2", "rubix/ml": "2.5.3", "sabberworm/php-css-parser": "^8.9.0", + "sebastiankrupinski/jmap-client-php": "dev-main", "wamania/php-stemmer": "4.0 as 3.0", "youthweb/urllinker": "^2.1.0" }, diff --git a/composer.lock b/composer.lock index f550f012cc..6a7847d169 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b6a3e5ea86fb4218afe65e7470bc3ac", + "content-hash": "b6303381d8f809be8a093fa8d3dd061c", "packages": [ { "name": "amphp/amp", @@ -1940,6 +1940,332 @@ ], "time": "2021-12-01T16:22:57+00:00" }, + { + "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.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "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", + "jshttp/mime-db": "1.54.0.1", + "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.9.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": "2026-03-10T16:41:02+00:00" + }, { "name": "hamza221/html2text", "version": "v1.0.0", @@ -2479,6 +2805,210 @@ }, "time": "2025-10-09T12:29:49+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": "rubix/ml", "version": "2.5.3", @@ -2809,6 +3339,78 @@ }, "time": "2025-07-11T13:20:48+00:00" }, + { + "name": "sebastiankrupinski/jmap-client-php", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/SebastianKrupinski/jmap-client-php.git", + "reference": "30dce48d854abc7d416f3784bc796b379775bdd3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/30dce48d854abc7d416f3784bc796b379775bdd3", + "reference": "30dce48d854abc7d416f3784bc796b379775bdd3", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^7.0", + "php": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.89", + "phpunit/phpunit": "^11.0" + }, + "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": [ + "./vendor/bin/phpunit --testsuite 'Unit Tests'" + ], + "test:integration": [ + "./vendor/bin/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": "2026-03-05T04:24:01+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.6.0", @@ -3901,7 +4503,9 @@ ], "minimum-stability": "stable", "stability-flags": { + "sebastiankrupinski/jmap-client-php": 20, "gravatarphp/gravatar": 20, + "sebastiankrupinski/jmap-client-php": 20, "roave/security-advisories": 20 }, "prefer-stable": false, @@ -3910,9 +4514,9 @@ "php": ">=8.1 <=8.4", "ext-openssl": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.3.0" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 71b50051c4..1deb51fdec 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -16,7 +16,6 @@ use OCA\Mail\Contracts\IAvatarService; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; @@ -62,7 +61,6 @@ use OCA\Mail\Service\AvatarService; use OCA\Mail\Service\DkimService; use OCA\Mail\Service\DkimValidator; -use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\Search\MailSearch; use OCA\Mail\Service\TrustedSenderService; @@ -120,7 +118,6 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IAvatarService::class, AvatarService::class); $context->registerServiceAlias(IAttachmentService::class, AttachmentService::class); - $context->registerServiceAlias(IMailManager::class, MailManager::class); $context->registerServiceAlias(IMailSearch::class, MailSearch::class); $context->registerServiceAlias(IMailTransmission::class, MailTransmission::class); $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); diff --git a/lib/BackgroundJob/ContextChat/SubmitContentJob.php b/lib/BackgroundJob/ContextChat/SubmitContentJob.php index 0f6ffabfe6..565c3cff74 100644 --- a/lib/BackgroundJob/ContextChat/SubmitContentJob.php +++ b/lib/BackgroundJob/ContextChat/SubmitContentJob.php @@ -109,53 +109,41 @@ protected function run($argument): void { } - $client = $this->clientFactory->getClient($account); $items = []; - try { - $startTime = $this->time->getTime(); - foreach ($messages as $message) { - if ($this->time->getTime() - $startTime > ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL) { - break; - } - try { - $imapMessage = $this->mailManager->getImapMessage($client, $account, $mailbox, $message->getUid(), true); - } catch (ServiceException $e) { - // couldn't load message, let's skip it. Retrying would be too costly - continue; - } catch (SmimeDecryptException $e) { - // encryption problem, skip this message - continue; - } - - - // Skip encrypted messages - if ($imapMessage->isEncrypted()) { - continue; - } - - - $fullMessage = $imapMessage->getFullMessage($imapMessage->getUid(), true); - - - $items[] = new ContentItem( - "{$mailbox->getId()}:{$message->getId()}", - $this->contextChatProvider->getId(), - $imapMessage->getSubject(), - $fullMessage['body'] ?? '', - 'E-Mail', - $imapMessage->getSentDate(), - [$account->getUserId()], - ); + $startTime = $this->time->getTime(); + foreach ($messages as $message) { + if ($this->time->getTime() - $startTime > ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL) { + break; } - } catch (\Throwable $e) { - $this->logger->warning('Exception occurred when trying to fetch messages for context chat', ['exception' => $e]); - } finally { try { - $client->close(); - } catch (\Horde_Imap_Client_Exception $e) { - $this->logger->debug('Failed to close IMAP client', ['exception' => $e]); + $imapMessage = $this->mailManager->getImapMessage($account, $mailbox, $message->getUid(), true); + } catch (ServiceException $e) { + // couldn't load message, let's skip it. Retrying would be too costly + continue; + } catch (SmimeDecryptException $e) { + // encryption problem, skip this message + continue; + } + + + // Skip encrypted messages + if ($imapMessage->isEncrypted()) { + continue; } + + + $fullMessage = $imapMessage->getFullMessage($imapMessage->getUid(), true); + + $items[] = new ContentItem( + "{$mailbox->getId()}:{$message->getId()}", + $this->contextChatProvider->getId(), + $imapMessage->getSubject(), + $fullMessage['body'] ?? '', + 'E-Mail', + $imapMessage->getSentDate(), + [$account->getUserId()], + ); } if (count($items) > 0) { diff --git a/lib/BackgroundJob/FollowUpClassifierJob.php b/lib/BackgroundJob/FollowUpClassifierJob.php index 4856aa7d5d..20c24dc716 100644 --- a/lib/BackgroundJob/FollowUpClassifierJob.php +++ b/lib/BackgroundJob/FollowUpClassifierJob.php @@ -9,12 +9,12 @@ namespace OCA\Mail\BackgroundJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Message; use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; use OCP\DB\Exception; @@ -30,7 +30,7 @@ public function __construct( ITimeFactory $time, private LoggerInterface $logger, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, private AiIntegrationsService $aiService, private ThreadMapper $threadMapper, ) { diff --git a/lib/BackgroundJob/MigrateImportantJob.php b/lib/BackgroundJob/MigrateImportantJob.php index 57a14827f5..2bd2a09b0b 100644 --- a/lib/BackgroundJob/MigrateImportantJob.php +++ b/lib/BackgroundJob/MigrateImportantJob.php @@ -14,8 +14,8 @@ use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Migration\MigrateImportantFromImapAndDb; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -28,7 +28,7 @@ class MigrateImportantJob extends QueuedJob { private MailManager $mailManager; private MigrateImportantFromImapAndDb $migration; private LoggerInterface $logger; - private IMAPClientFactory $imapClientFactory; + private ProtocolFactory $protocolFactory; public function __construct(MailboxMapper $mailboxMapper, MailAccountMapper $mailAccountMapper, @@ -36,7 +36,7 @@ public function __construct(MailboxMapper $mailboxMapper, MigrateImportantFromImapAndDb $migration, LoggerInterface $logger, ITimeFactory $timeFactory, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, ) { parent::__construct($timeFactory); $this->mailboxMapper = $mailboxMapper; @@ -44,7 +44,7 @@ public function __construct(MailboxMapper $mailboxMapper, $this->mailManager = $mailManager; $this->migration = $migration; $this->logger = $logger; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; } /** @@ -71,10 +71,10 @@ public function run($argument) { } $account = new Account($mailAccount); - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { - if ($this->mailManager->isPermflagsEnabled($client, $account, $mailbox->getName()) === false) { + if ($this->mailManager->isPermflagsEnabled($account, $mailbox->getName()) === false) { $this->logger->debug("Permflags not enabled for <{$accountId}>"); return; } diff --git a/lib/BackgroundJob/QuotaJob.php b/lib/BackgroundJob/QuotaJob.php index e747ad06c4..57a8fb7cdd 100644 --- a/lib/BackgroundJob/QuotaJob.php +++ b/lib/BackgroundJob/QuotaJob.php @@ -8,8 +8,8 @@ namespace OCA\Mail\BackgroundJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; @@ -22,7 +22,7 @@ class QuotaJob extends TimedJob { private IUserManager $userManager; private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private LoggerInterface $logger; private IJobList $jobList; private IManager $notificationManager; @@ -30,7 +30,7 @@ class QuotaJob extends TimedJob { public function __construct(ITimeFactory $time, IUserManager $userManager, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, IManager $notificationManager, LoggerInterface $logger, IJobList $jobList) { diff --git a/lib/BackgroundJob/RepairSyncJob.php b/lib/BackgroundJob/RepairSyncJob.php index 4d843af8d3..cac5f16add 100644 --- a/lib/BackgroundJob/RepairSyncJob.php +++ b/lib/BackgroundJob/RepairSyncJob.php @@ -9,8 +9,10 @@ namespace OCA\Mail\BackgroundJob; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Events\SynchronizationEvent; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Db\DoesNotExistException; @@ -25,6 +27,7 @@ class RepairSyncJob extends TimedJob { public function __construct( ITimeFactory $time, private SyncService $syncService, + private ProtocolFactory $protocolFactory, private AccountService $accountService, private IUserManager $userManager, private MailboxMapper $mailboxMapper, @@ -65,6 +68,19 @@ protected function run($argument): void { return; } + $this->protocolFactory + ->mailboxConnector($account) + ->syncMailboxList($account, $this->logger, true); + + if ($account->getMailAccount()->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + $this->logger->debug(sprintf( + 'Account %d uses %s, skipping IMAP repair sync after mailbox refresh', + $account->getId(), + $account->getMailAccount()->getProtocol(), + )); + return; + } + $rebuildThreads = false; $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); $snoozeMailboxId = $account->getMailAccount()->getSnoozeMailboxId(); diff --git a/lib/BackgroundJob/SyncJob.php b/lib/BackgroundJob/SyncJob.php index a747ff2103..aa7772b109 100644 --- a/lib/BackgroundJob/SyncJob.php +++ b/lib/BackgroundJob/SyncJob.php @@ -10,11 +10,11 @@ use Horde_Imap_Client_Exception; use OCA\Mail\AppInfo\Application; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\IncompleteSyncException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; @@ -31,8 +31,6 @@ class SyncJob extends TimedJob { private IUserManager $userManager; private AccountService $accountService; - private ImapToDbSynchronizer $syncService; - private MailboxSync $mailboxSync; private LoggerInterface $logger; private IJobList $jobList; private readonly bool $forcedSyncInterval; @@ -41,8 +39,7 @@ public function __construct( ITimeFactory $time, IUserManager $userManager, AccountService $accountService, - MailboxSync $mailboxSync, - ImapToDbSynchronizer $syncService, + private ProtocolFactory $protocolFactory, LoggerInterface $logger, IJobList $jobList, private readonly IConfig $config, @@ -51,8 +48,6 @@ public function __construct( $this->userManager = $userManager; $this->accountService = $accountService; - $this->syncService = $syncService; - $this->mailboxSync = $mailboxSync; $this->logger = $logger; $this->jobList = $jobList; @@ -83,7 +78,8 @@ protected function run($argument) { return; } - if (!$account->getMailAccount()->canAuthenticateImap()) { + if ($account->getMailAccount()->getProtocol() === MailAccount::PROTOCOL_IMAP + && !$account->getMailAccount()->canAuthenticateImap()) { $this->logger->debug('No authentication on IMAP possible, skipping background sync job'); return; } @@ -124,8 +120,12 @@ protected function run($argument) { } try { - $this->mailboxSync->sync($account, $this->logger, true); - $this->syncService->syncAccount($account, $this->logger); + $this->protocolFactory + ->mailboxConnector($account) + ->syncMailboxList($account, $this->logger, true); + $this->protocolFactory + ->messageConnector($account) + ->syncAccount($account, $this->logger, true); } catch (IncompleteSyncException $e) { $this->logger->warning($e->getMessage(), [ 'exception' => $e, diff --git a/lib/BackgroundJob/TrashRetentionJob.php b/lib/BackgroundJob/TrashRetentionJob.php index e3e1bd3e0f..ca5cc77736 100644 --- a/lib/BackgroundJob/TrashRetentionJob.php +++ b/lib/BackgroundJob/TrashRetentionJob.php @@ -10,7 +10,6 @@ namespace OCA\Mail\BackgroundJob; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper; @@ -18,6 +17,7 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; @@ -32,7 +32,7 @@ public function __construct( private MessageRetentionMapper $messageRetentionMapper, private MailAccountMapper $accountMapper, private MailboxMapper $mailboxMapper, - private IMailManager $mailManager, + private MailManager $mailManager, ) { parent::__construct($time); @@ -96,22 +96,16 @@ private function cleanTrash(Account $account, int $retentionSeconds): void { return; } - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $message) { - $this->mailManager->deleteMessageWithClient( - $account, - $trashMailbox, - $message->getUid(), - $client, - ); - $this->messageRetentionMapper->deleteByMailboxIdAndUid( - $message->getMailboxId(), - $message->getUid(), - ); - } - } finally { - $client->logout(); + foreach ($messages as $message) { + $this->mailManager->deleteMessage( + $account, + $trashMailbox, + $message->getUid(), + ); + $this->messageRetentionMapper->deleteByMailboxIdAndUid( + $message->getMailboxId(), + $message->getUid(), + ); } } } diff --git a/lib/Command/CreateAccount.php b/lib/Command/CreateImapAccount.php similarity index 94% rename from lib/Command/CreateAccount.php rename to lib/Command/CreateImapAccount.php index af85924309..93ed3ab393 100644 --- a/lib/Command/CreateAccount.php +++ b/lib/Command/CreateImapAccount.php @@ -5,7 +5,7 @@ /** * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2014-2016 owncloud, Inc. - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Mail\Command; @@ -20,7 +20,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -final class CreateAccount extends Command { +final class CreateImapAccount extends Command { public const ARGUMENT_USER_ID = 'user-id'; public const ARGUMENT_NAME = 'name'; public const ARGUMENT_EMAIL = 'email'; @@ -57,8 +57,9 @@ public function __construct( * @return void */ protected function configure() { - $this->setName('mail:account:create'); - $this->setDescription('creates IMAP account'); + $this->setName('mail:account:create-imap'); + $this->setAliases(['mail:account:create']); + $this->setDescription('creates an IMAP mail account'); $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED); $this->addArgument(self::ARGUMENT_NAME, InputArgument::REQUIRED); $this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED); @@ -98,7 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->userManager->userExists($userId)) { $output->writeln("User $userId does not exist"); - return 1; + return self::FAILURE; } $account = new MailAccount(); @@ -124,6 +125,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Account ' . $account->getId() . " for $email created"); - return 0; + return self::SUCCESS; } } diff --git a/lib/Command/CreateJmapAccount.php b/lib/Command/CreateJmapAccount.php new file mode 100644 index 0000000000..70b1a65c54 --- /dev/null +++ b/lib/Command/CreateJmapAccount.php @@ -0,0 +1,93 @@ +setName('mail:account:create-jmap'); + $this->setDescription('creates a JMAP mail account'); + $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED, 'user to add the account for'); + $this->addArgument(self::ARGUMENT_NAME, InputArgument::REQUIRED, 'display name of the account'); + $this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED, 'email address'); + $this->addArgument(self::ARGUMENT_HOST, InputArgument::REQUIRED, 'JMAP server hostname (e.g. mail.example.com)'); + $this->addArgument(self::ARGUMENT_PORT, InputArgument::REQUIRED, 'JMAP server port (e.g. 443)'); + $this->addArgument(self::ARGUMENT_SSL_MODE, InputArgument::REQUIRED, 'SSL mode (ssl or none)'); + $this->addArgument(self::ARGUMENT_BAUTH_USER, InputArgument::REQUIRED, 'Basic authentication user'); + $this->addArgument(self::ARGUMENT_BAUTH_PASSWORD, InputArgument::REQUIRED, 'Basic authentication password'); + $this->addArgument(self::ARGUMENT_PATH, InputArgument::OPTIONAL, 'JMAP session endpoint path (e.g. /jmap/session)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $name = $input->getArgument(self::ARGUMENT_NAME); + $email = $input->getArgument(self::ARGUMENT_EMAIL); + $host = $input->getArgument(self::ARGUMENT_HOST); + $port = (int)$input->getArgument(self::ARGUMENT_PORT); + $sslMode = $input->getArgument(self::ARGUMENT_SSL_MODE); + $bauthUser = $input->getArgument(self::ARGUMENT_BAUTH_USER); + $bauthPassword = $input->getArgument(self::ARGUMENT_BAUTH_PASSWORD); + $path = $input->getArgument(self::ARGUMENT_PATH); + + if (!$this->userManager->userExists($userId)) { + $output->writeln("User $userId does not exist"); + return self::FAILURE; + } + + $account = new MailAccount(); + $account->setUserId($userId); + $account->setName($name); + $account->setEmail($email); + $account->setProtocol(MailAccount::PROTOCOL_JMAP); + $account->setInboundHost($host); + $account->setInboundPort($port); + $account->setInboundSslMode($sslMode); + $account->setInboundUser($bauthUser); + $account->setInboundPassword($this->crypto->encrypt($bauthPassword)); + if ($path !== null) { + $account->setPath($path); + } + $account->setClassificationEnabled($this->classificationSettingsService->isClassificationEnabledByDefault()); + + $account = $this->accountService->save($account); + + $output->writeln('JMAP account ' . $account->getId() . " for $email created"); + + return self::SUCCESS; + } +} diff --git a/lib/Command/DiagnoseAccount.php b/lib/Command/DiagnoseAccount.php deleted file mode 100644 index 60a3377b10..0000000000 --- a/lib/Command/DiagnoseAccount.php +++ /dev/null @@ -1,125 +0,0 @@ -accountService = $service; - $this->clientFactory = $clientFactory; - $this->logger = $logger; - } - - /** - * @return void - */ - protected function configure() { - $this->setName('mail:account:diagnose'); - $this->setDescription('Diagnose a user\'s IMAP connection'); - $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); - } - - protected function execute(InputInterface $input, OutputInterface $output): int { - $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID); - - try { - $account = $this->accountService->findById($accountId); - } catch (DoesNotExistException $e) { - $output->writeln("Account $accountId does not exist"); - return 1; - } - - if ($account->getMailAccount()->getInboundPassword() === null) { - $output->writeln('No IMAP passwort set. The user might have to log into their account to set it.'); - } - $imapClient = $this->clientFactory->getClient($account); - try { - $this->printCapabilitiesStats($output, $imapClient); - $this->printMailboxesMessagesStats($output, $imapClient); - } catch (Horde_Imap_Client_Exception $e) { - $this->logger->error('Could not get account statistics: ' . $e, [ - 'exception' => $e, - ]); - $output->writeln('Horde error occurred: ' . $e->getMessage() . '. See nextcloud.log for more details.'); - return 2; - } finally { - $imapClient->logout(); - } - - return 0; - } - - /** - * @param OutputInterface $output - * @param Horde_Imap_Client_Socket $imapClient - * - * @throws Horde_Imap_Client_Exception - */ - private function printCapabilitiesStats(OutputInterface $output, - Horde_Imap_Client_Socket $imapClient): void { - $output->writeln('IMAP capabilities:'); - // Once logged in more capabilities are advertised - $imapClient->login(); - $capabilities = array_keys( - json_decode( - $imapClient->capability->serialize(), - true - ) - ); - sort($capabilities); - foreach ($capabilities as $capability) { - $output->writeln("- $capability"); - } - $output->writeln(''); - } - - /** - * @param OutputInterface $output - * @param Horde_Imap_Client_Socket $imapClient - * - * @throws Horde_Imap_Client_Exception - */ - protected function printMailboxesMessagesStats(OutputInterface $output, - Horde_Imap_Client_Socket $imapClient): void { - $mailboxes = $imapClient->listMailboxes('*', Horde_Imap_Client::MBOX_ALL, [ - 'flat' => true, - ]); - $messages = array_reduce($mailboxes, static function (int $c, Horde_Imap_Client_Mailbox $mb) use ($imapClient) { - $status = $imapClient->status($mb, Horde_Imap_Client::STATUS_MESSAGES); - return $c + $status['messages']; - }, 0); - $output->writeln('Account has ' . $messages . ' messages in ' . count($mailboxes) . ' mailboxes'); - } -} diff --git a/lib/Command/SyncAccount.php b/lib/Command/SyncAccount.php index 64f81fa27d..7e55b35a95 100644 --- a/lib/Command/SyncAccount.php +++ b/lib/Command/SyncAccount.php @@ -12,9 +12,8 @@ use OCA\Mail\Account; use OCA\Mail\Exception\IncompleteSyncException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Support\ConsoleLoggerDecorator; use OCP\AppFramework\Db\DoesNotExistException; use Psr\Log\LoggerInterface; @@ -31,19 +30,16 @@ final class SyncAccount extends Command { public const OPTION_FORCE = 'force'; private AccountService $accountService; - private MailboxSync $mailboxSync; - private ImapToDbSynchronizer $syncService; + private ProtocolFactory $protocolFactory; private LoggerInterface $logger; public function __construct(AccountService $service, - MailboxSync $mailboxSync, - ImapToDbSynchronizer $messageSync, + ProtocolFactory $protocolFactory, LoggerInterface $logger) { parent::__construct(); $this->accountService = $service; - $this->mailboxSync = $mailboxSync; - $this->syncService = $messageSync; + $this->protocolFactory = $protocolFactory; $this->logger = $logger; } @@ -52,7 +48,7 @@ public function __construct(AccountService $service, */ protected function configure() { $this->setName('mail:account:sync'); - $this->setDescription('Synchronize an IMAP account'); + $this->setDescription('Synchronize a mail account'); $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); $this->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE); } @@ -84,8 +80,8 @@ private function sync(Account $account, bool $force, OutputInterface $output): v ); try { - $this->mailboxSync->sync($account, $consoleLogger, $force); - $this->syncService->syncAccount($account, $consoleLogger, $force); + $this->protocolFactory->mailboxConnector($account)->syncMailboxList($account, $consoleLogger, $force); + $this->protocolFactory->messageConnector($account)->syncAccount($account, $consoleLogger, $force); } catch (ServiceException $e) { if (!($e instanceof IncompleteSyncException)) { throw $e; diff --git a/lib/Command/TestAccount.php b/lib/Command/TestAccount.php new file mode 100644 index 0000000000..32abbba9dd --- /dev/null +++ b/lib/Command/TestAccount.php @@ -0,0 +1,153 @@ +setName('mail:account:test'); + $this->setAliases(['mail:account:diagnose']); + $this->setDescription('Test the connection for a mail account (IMAP or JMAP)'); + $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED, 'The ID of the mail account'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID); + + try { + $account = $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + $output->writeln("Account $accountId does not exist"); + return 1; + } + + $protocol = $account->getMailAccount()->getProtocol(); + $output->writeln("Account $accountId uses protocol: $protocol"); + + return match ($protocol) { + MailAccount::PROTOCOL_IMAP => $this->testImap($account, $output), + MailAccount::PROTOCOL_JMAP => $this->testJmap($account, $output), + default => $this->unsupportedProtocol($protocol, $output), + }; + } + + private function testImap(\OCA\Mail\Account $account, OutputInterface $output): int { + $output->writeln('Testing IMAP connection...'); + + $mailAccount = $account->getMailAccount(); + $sslMode = $mailAccount->getInboundSslMode(); + $scheme = ($sslMode === 'none') ? 'imap' : 'imaps'; + $host = $mailAccount->getInboundHost() ?? '(not set)'; + $port = $mailAccount->getInboundPort(); + $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . ''); + + if ($account->getMailAccount()->getInboundPassword() === null) { + $output->writeln('No IMAP password set. The user may need to log in to set it.'); + return 1; + } + + try { + $imapClient = $this->protocolFactory->imapClient($account); + } catch (\Exception $e) { + $output->writeln('Could not create IMAP client: ' . $e->getMessage() . ''); + return 2; + } + + try { + $imapClient->login(); + $output->writeln('Login successful'); + + $capabilities = array_keys( + json_decode($imapClient->capability->serialize(), true) + ); + sort($capabilities); + $output->writeln('Capabilities: ' . implode(', ', $capabilities) . ''); + + $output->writeln('IMAP connection test passed'); + return 0; + } catch (Horde_Imap_Client_Exception $e) { + $this->logger->error('IMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [ + 'exception' => $e, + ]); + $output->writeln('IMAP connection test failed: ' . $e->getMessage() . ''); + return 2; + } finally { + $imapClient->logout(); + } + } + + private function testJmap(\OCA\Mail\Account $account, OutputInterface $output): int { + $output->writeln('Testing JMAP connection...'); + + $mailAccount = $account->getMailAccount(); + $sslMode = $mailAccount->getInboundSslMode(); + $scheme = ($sslMode === 'none') ? 'http' : 'https'; + $host = $mailAccount->getInboundHost() ?? '(not set)'; + $port = $mailAccount->getInboundPort(); + $path = $mailAccount->getPath() ?? '/.well-known/jmap'; + $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . $path . ''); + + try { + $client = $this->protocolFactory->jmapClient($account); + $session = $client->connect(); + } catch (\Exception $e) { + $this->logger->error('JMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [ + 'exception' => $e, + ]); + $output->writeln('JMAP connection test failed: ' . $e->getMessage() . ''); + return 2; + } + + if (!$client->sessionStatus()) { + $output->writeln('JMAP session discovery failed. Check the server and credentials.'); + return 2; + } + + $output->writeln('JMAP session established'); + $output->writeln('Username: ' . $session->username() . ''); + $output->writeln('API URL: ' . $session->commandUrl() . ''); + $output->writeln('State: ' . $session->state() . ''); + + $capabilities = []; + foreach ($session->capabilities() as $capability) { + $capabilities[] = $capability->id(); + } + sort($capabilities); + $output->writeln('Capabilities: ' . implode(', ', $capabilities) . ''); + + $output->writeln('JMAP connection test passed'); + return 0; + } + + private function unsupportedProtocol(string $protocol, OutputInterface $output): int { + $output->writeln("Unsupported protocol: $protocol"); + return 1; + } +} diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php deleted file mode 100644 index c18bf8f4a1..0000000000 --- a/lib/Contracts/IMailManager.php +++ /dev/null @@ -1,349 +0,0 @@ -mailManager = $mailManager; $this->accountService = $accountService; - $this->clientFactory = $clientFactory; $this->request = $request; $this->httpClientService = $httpClientService; $this->logger = $logger; @@ -66,10 +62,8 @@ public function unsubscribe(int $id): JsonResponse { return JsonResponse::fail(null, Http::STATUS_NOT_FOUND); } - $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, $message->getUid(), @@ -91,8 +85,6 @@ public function unsubscribe(int $id): JsonResponse { 'exception' => $e, ]); return JsonResponse::error('Unknown error'); - } finally { - $client->logout(); } return JsonResponse::success(); diff --git a/lib/Controller/MailboxesApiController.php b/lib/Controller/MailboxesApiController.php index 106a8892e2..5adc0b91da 100644 --- a/lib/Controller/MailboxesApiController.php +++ b/lib/Controller/MailboxesApiController.php @@ -9,10 +9,10 @@ namespace OCA\Mail\Controller; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\ResponseDefinitions; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -30,7 +30,7 @@ public function __construct( string $appName, IRequest $request, private readonly ?string $userId, - private IMailManager $mailManager, + private MailManager $mailManager, private readonly AccountService $accountService, private IMailSearch $mailSearch, ) { diff --git a/lib/Controller/MailboxesController.php b/lib/Controller/MailboxesController.php index d391f2dee6..61ea7c6f22 100644 --- a/lib/Controller/MailboxesController.php +++ b/lib/Controller/MailboxesController.php @@ -12,7 +12,6 @@ use Horde_Imap_Client; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\IncompleteSyncException; @@ -21,6 +20,7 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -35,7 +35,7 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class MailboxesController extends Controller { private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private SyncService $syncService; private ?string $currentUserId; @@ -44,7 +44,7 @@ public function __construct( IRequest $request, AccountService $accountService, ?string $userId, - IMailManager $mailManager, + MailManager $mailManager, SyncService $syncService, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, diff --git a/lib/Controller/MessageApiController.php b/lib/Controller/MessageApiController.php index 475deca64f..963645271e 100644 --- a/lib/Controller/MessageApiController.php +++ b/lib/Controller/MessageApiController.php @@ -14,7 +14,6 @@ use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; use OCA\Mail\Http\TrapError; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\SmimeData; use OCA\Mail\ResponseDefinitions; use OCA\Mail\Service\AccountService; @@ -59,7 +58,6 @@ public function __construct( private AttachmentService $attachmentService, private OutboxService $outboxService, private MailManager $mailManager, - private IMAPClientFactory $clientFactory, private LoggerInterface $logger, private ITimeFactory $time, private IURLGenerator $urlGenerator, @@ -243,10 +241,8 @@ public function get(int $id): DataResponse { } $loadBody = true; - $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, $message->getUid(), @@ -259,13 +255,10 @@ public function get(int $id): DataResponse { $this->logger->warning('Message could not be decrypted', ['exception' => $e->getMessage()]); $loadBody = false; $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, $message->getUid() ); - } finally { - $client->logout(); } $json = $imapMessage->getFullMessage($id, $loadBody); @@ -330,10 +323,8 @@ public function getRaw(int $id): DataResponse { return new DataResponse('Message, Account or Mailbox not found', Http::STATUS_NOT_FOUND); } - $client = $this->clientFactory->getClient($account); try { $source = $this->mailManager->getSource( - $client, $account, $mailbox->getName(), $message->getUid() @@ -341,8 +332,6 @@ public function getRaw(int $id): DataResponse { } catch (ServiceException $e) { $this->logger->error('Message not found on IMAP, or mail server went away', ['exception' => $e->getMessage()]); return new DataResponse('Message not found', Http::STATUS_NOT_FOUND); - } finally { - $client->logout(); } return new DataResponse($source, Http::STATUS_OK); diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 819cc29ca5..b03cf133c5 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -14,7 +14,6 @@ use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OCA\Mail\Attachment; use OCA\Mail\Contracts\IDkimService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; @@ -25,11 +24,11 @@ use OCA\Mail\Http\AttachmentDownloadResponse; use OCA\Mail\Http\HtmlResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\SmimeData; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\ItineraryService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SmimeService; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; @@ -57,7 +56,7 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class MessagesController extends Controller { private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private IMailSearch $mailSearch; private ItineraryService $itineraryService; private ?string $currentUserId; @@ -70,7 +69,6 @@ class MessagesController extends Controller { private ITrustedSenderService $trustedSenderService; private IMailTransmission $mailTransmission; private SmimeService $smimeService; - private IMAPClientFactory $clientFactory; private IDkimService $dkimService; private IUserPreferences $preferences; private SnoozeService $snoozeService; @@ -80,7 +78,7 @@ public function __construct( string $appName, IRequest $request, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, IMailSearch $mailSearch, ItineraryService $itineraryService, ?string $userId, @@ -93,7 +91,6 @@ public function __construct( ITrustedSenderService $trustedSenderService, IMailTransmission $mailTransmission, SmimeService $smimeService, - IMAPClientFactory $clientFactory, IDkimService $dkimService, IUserPreferences $preferences, SnoozeService $snoozeService, @@ -115,7 +112,6 @@ public function __construct( $this->trustedSenderService = $trustedSenderService; $this->mailTransmission = $mailTransmission; $this->smimeService = $smimeService; - $this->clientFactory = $clientFactory; $this->dkimService = $dkimService; $this->preferences = $preferences; $this->snoozeService = $snoozeService; @@ -237,24 +233,18 @@ public function getBody(int $id): JSONResponse { $cacheInstance = $this->getCacheForAccount($account->getId()); $imapMessageCacheKey = 'message_' . $id; - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - - if ($imapMessage->hasHtmlMessage()) { - $cacheInstance->set($imapMessageCacheKey, $imapMessage->getHtmlBody($id), 600); - } + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), true + ); - $json = $imapMessage->getFullMessage($id); - } finally { - $client->logout(); + if ($imapMessage->hasHtmlMessage()) { + $cacheInstance->set($imapMessageCacheKey, $imapMessage->getHtmlBody($id), 600); } + $json = $imapMessage->getFullMessage($id); + $itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid()); if ($itineraries) { $json['itineraries'] = $itineraries; @@ -532,19 +522,13 @@ public function getSource(int $id): JSONResponse { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $client = $this->clientFactory->getClient($account); - try { - $response = new JSONResponse([ - 'source' => $this->mailManager->getSource( - $client, - $account, - $mailbox->getName(), - $message->getUid() - ) - ]); - } finally { - $client->logout(); - } + $response = new JSONResponse([ + 'source' => $this->mailManager->getSource( + $account, + $mailbox->getName(), + $message->getUid() + ) + ]); // Enable caching $response->cacheFor(60 * 60, false, true); @@ -576,17 +560,11 @@ public function export(int $id): Response { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $client = $this->clientFactory->getClient($account); - try { - $source = $this->mailManager->getSource( - $client, - $account, - $mailbox->getName(), - $message->getUid() - ); - } finally { - $client->logout(); - } + $source = $this->mailManager->getSource( + $account, + $mailbox->getName(), + $message->getUid() + ); return new AttachmentDownloadResponse( $source, @@ -637,20 +615,14 @@ public function getHtmlBody(int $id, bool $plain = false): Response { $html = $cacheInstance->get($imapMessageCacheKey); if ($html === null) { - $client = $this->clientFactory->getClient($account); - try { - $html = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true - )->getHtmlBody( - $id - ); - } finally { - $client->logout(); - } + $html = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), + true + )->getHtmlBody( + $id + ); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 9c1f3ea6f0..916ecdbd2b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,7 +12,6 @@ use OCA\Contacts\Event\LoadContactsOcaApiEvent; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; @@ -22,6 +21,7 @@ use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\ContextChat\ContextChatSettingsService; use OCA\Mail\Service\InternalAddressService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -61,7 +61,7 @@ class PageController extends Controller { private ?string $currentUserId; private IUserSession $userSession; private IUserPreferences $preferences; - private IMailManager $mailManager; + private MailManager $mailManager; private TagMapper $tagMapper; private IInitialState $initialStateService; private LoggerInterface $logger; @@ -86,7 +86,7 @@ public function __construct( ?string $userId, IUserSession $userSession, IUserPreferences $preferences, - IMailManager $mailManager, + MailManager $mailManager, TagMapper $tagMapper, IInitialState $initialStateService, LoggerInterface $logger, diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index 78485d82c1..047eb3fc03 100644 --- a/lib/Controller/TagsController.php +++ b/lib/Controller/TagsController.php @@ -10,10 +10,10 @@ namespace OCA\Mail\Controller; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Exception\ClientException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -24,14 +24,14 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class TagsController extends Controller { private string $currentUserId; - private IMailManager $mailManager; + private MailManager $mailManager; private AccountService $accountService; public function __construct(IRequest $request, string $userId, - IMailManager $mailManager, + MailManager $mailManager, AccountService $accountService, ) { parent::__construct(Application::APP_ID, $request); diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index 96aa68977f..8c0e1b397b 100755 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -9,12 +9,12 @@ namespace OCA\Mail\Controller; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -28,7 +28,7 @@ class ThreadController extends Controller { private string $currentUserId; private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private SnoozeService $snoozeService; private AiIntegrationsService $aiIntergrationsService; private LoggerInterface $logger; @@ -38,7 +38,7 @@ public function __construct(string $appName, IRequest $request, string $userId, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, SnoozeService $snoozeService, AiIntegrationsService $aiIntergrationsService, LoggerInterface $logger) { diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index f3e8b900cb..0c1dba01aa 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -107,8 +107,14 @@ * @method void setClassificationEnabled(bool $classificationEnabled) * @method bool getImipCreate() * @method void setImipCreate(bool $value) + * @method string getProtocol() + * @method void setProtocol(string $protocol) + * @method string|null getPath() + * @method void setPath(?string $path) */ class MailAccount extends Entity { + public const PROTOCOL_IMAP = 'imap'; + public const PROTOCOL_JMAP = 'jmap'; public const SIGNATURE_MODE_PLAIN = 0; public const SIGNATURE_MODE_HTML = 1; @@ -194,6 +200,10 @@ class MailAccount extends Entity { protected bool $imipCreate = false; + protected string $protocol = 'imap'; + + protected ?string $path = null; + /** * @param array $params */ @@ -260,6 +270,12 @@ public function __construct(array $params = []) { if (isset($params['imipCreate'])) { $this->setImipCreate($params['imipCreate']); } + if (isset($params['protocol'])) { + $this->setProtocol($params['protocol']); + } + if (isset($params['path'])) { + $this->setPath($params['path']); + } $this->addType('inboundPort', 'integer'); $this->addType('outboundPort', 'integer'); @@ -286,6 +302,8 @@ public function __construct(array $params = []) { $this->addType('debug', 'boolean'); $this->addType('classificationEnabled', 'boolean'); $this->addType('imipCreate', 'boolean'); + $this->addType('protocol', 'string'); + $this->addType('path', 'string'); } public function getOutOfOfficeFollowsSystem(): bool { @@ -337,6 +355,8 @@ public function toJson() { 'debug' => $this->getDebug(), 'classificationEnabled' => $this->getClassificationEnabled(), 'imipCreate' => $this->getImipCreate(), + 'protocol' => $this->getProtocol(), + 'path' => $this->getPath(), ]; if (!is_null($this->getOutboundHost())) { diff --git a/lib/Db/Mailbox.php b/lib/Db/Mailbox.php index 30be4ea423..acbf4c929d 100644 --- a/lib/Db/Mailbox.php +++ b/lib/Db/Mailbox.php @@ -56,6 +56,12 @@ * @method void setShared(bool $shared) * @method string getNameHash() * @method void setNameHash(string $nameHash) + * @method string|null getRemoteParentId() + * @method void setRemoteParentId(?string $remoteParentId) + * @method string|null getRemoteId() + * @method void setRemoteId(?string $remoteId) + * @method string|null getState() + * @method void setState(?string $state) */ class Mailbox extends Entity implements JsonSerializable { protected $name; @@ -76,6 +82,9 @@ class Mailbox extends Entity implements JsonSerializable { protected $myAcls; protected $shared; protected $nameHash; + protected ?string $remoteParentId = null; + protected ?string $remoteId = null; + protected ?string $state = null; /** * @var int diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 36ebdfbbe3..03cc30a6ec 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -157,6 +157,28 @@ public function findByUid(int $id, string $uid): Mailbox { } } + /** + * @throws DoesNotExistException + * @throws ServiceException + */ + public function findByName(Account $account, string $name): Mailbox { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('account_id', $qb->createNamedParameter($account->getId())), + $qb->expr()->eq('name', $qb->createNamedParameter($name)) + ); + + try { + return $this->findEntity($select); + } catch (MultipleObjectsReturnedException $e) { + // Not possible due to DB constraints + throw new ServiceException('The impossible has happened', 42, $e); + } + } + /** * @throws MailboxLockedException */ diff --git a/lib/Db/Message.php b/lib/Db/Message.php index 2c68e1f083..35d4aa1af3 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -73,6 +73,8 @@ * @method void setEncrypted(bool|null $encrypted) * @method bool getMentionsMe() * @method void setMentionsMe(bool $isMentionned) + * @method string|null getRemoteId() + * @method void setRemoteId(?string $remoteId) */ class Message extends Entity implements JsonSerializable { private const MUTABLE_FLAGS = [ @@ -117,6 +119,7 @@ class Message extends Entity implements JsonSerializable { protected $imipProcessed = false; protected $imipError = false; protected $mentionsMe = false; + protected ?string $remoteId = null; /** * @var bool|null @@ -342,6 +345,7 @@ public function jsonSerialize() { return [ 'databaseId' => $this->getId(), 'uid' => $this->getUid(), + 'remoteId' => $this->getRemoteId(), 'subject' => $this->getSubject(), 'dateInt' => $this->getSentAt(), 'flags' => [ diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 63c961bfd3..932b998d70 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -262,6 +262,7 @@ public function insertBulk(Account $account, Message ...$messages): void { $qb1 = $this->db->getQueryBuilder(); $qb1->insert($this->getTableName()); $qb1->setValue('uid', $qb1->createParameter('uid')); + $qb1->setValue('remote_id', $qb1->createParameter('remote_id')); $qb1->setValue('message_id', $qb1->createParameter('message_id')); $qb1->setValue('references', $qb1->createParameter('references')); $qb1->setValue('in_reply_to', $qb1->createParameter('in_reply_to')); @@ -289,6 +290,7 @@ public function insertBulk(Account $account, Message ...$messages): void { foreach ($messages as $message) { $qb1->setParameter('uid', $message->getUid(), IQueryBuilder::PARAM_INT); + $qb1->setParameter('remote_id', $message->getRemoteId(), $message->getRemoteId() === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR); $qb1->setParameter('message_id', $message->getMessageId(), IQueryBuilder::PARAM_STR); $inReplyTo = self::filterMessageIdLength($message->getInReplyTo()); $qb1->setParameter('in_reply_to', $inReplyTo, $inReplyTo === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR); @@ -743,6 +745,74 @@ public function deleteByUid(Mailbox $mailbox, int ...$uids): void { } } + /** + * @param Mailbox $mailbox + * @param string[] $rids + * + * @return Message[] + */ + public function findByRemoteIds(Mailbox $mailbox, array $rids): array { + if ($rids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + + $select = $qb + ->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT), + $qb->expr()->in('remote_id', $qb->createNamedParameter($rids, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->orderBy('sent_at', 'desc'); + + return $this->findRecipients($this->findEntities($select)); + } + + /** + * @param Mailbox $mailbox + * @param string[] $rids + */ + public function deleteByRemoteIds(Mailbox $mailbox, string ...$rids): void { + $selectMessageIdsQuery = $this->db->getQueryBuilder(); + $deleteRecipientsQuery = $this->db->getQueryBuilder(); + $deleteMessagesQuery = $this->db->getQueryBuilder(); + + $selectMessageIdsQuery->select('id') + ->from($this->getTableName()) + ->where( + $selectMessageIdsQuery->expr()->eq('mailbox_id', $selectMessageIdsQuery->createNamedParameter($mailbox->getId())), + $selectMessageIdsQuery->expr()->in('remote_id', $deleteMessagesQuery->createParameter('remote_ids')), + ); + $deleteRecipientsQuery->delete('mail_recipients') + ->where( + $deleteRecipientsQuery->expr()->in('message_id', $deleteRecipientsQuery->createParameter('ids')), + ); + $deleteMessagesQuery->delete('mail_messages') + ->where( + $deleteMessagesQuery->expr()->in('id', $deleteMessagesQuery->createParameter('ids')), + ); + + foreach (array_chunk($rids, 1000) as $chunk) { + $this->atomic(function () use ($selectMessageIdsQuery, $deleteRecipientsQuery, $deleteMessagesQuery, $chunk) { + $selectMessageIdsQuery->setParameter('remote_ids', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $selectResult = $selectMessageIdsQuery->executeQuery(); + $ids = array_map('intval', $selectResult->fetchAll(\PDO::FETCH_COLUMN)); + $selectResult->closeCursor(); + if (empty($ids)) { + return; + } + + $deleteRecipientsQuery->setParameter('ids', $ids, IQueryBuilder::PARAM_INT_ARRAY); + $deleteRecipientsQuery->executeStatement(); + + $deleteMessagesQuery->setParameter('ids', $ids, IQueryBuilder::PARAM_INT_ARRAY); + $deleteMessagesQuery->executeStatement(); + }, $this->db); + } + } + /** * @param Account $account * @param string $threadRootId diff --git a/lib/IMAP/ImapMailboxConnector.php b/lib/IMAP/ImapMailboxConnector.php new file mode 100644 index 0000000000..5ef19258e3 --- /dev/null +++ b/lib/IMAP/ImapMailboxConnector.php @@ -0,0 +1,114 @@ +mailboxSync->sync($account, $logger, $force); + } + + #[\Override] + public function syncMailboxStats(Account $account, Mailbox $mailbox): void { + $client = $this->imapClientFactory->getClient($account); + try { + $this->mailboxSync->syncStats($client, $mailbox); + } finally { + $client->logout(); + } + } + + #[\Override] + public function createMailbox(Account $account, string $name, array $specialUse = []): Mailbox { + $client = $this->imapClientFactory->getClient($account); + try { + $folder = $this->folderMapper->createFolder($client, $name, $specialUse); + $this->folderMapper->fetchFolderAcls([$folder], $client); + $this->folderMapper->detectFolderSpecialUse([$folder]); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get mailbox status: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + return $this->mailboxMapper->find($account, $name); + } + + #[\Override] + public function renameMailbox(Account $account, Mailbox $mailbox, string $newName): Mailbox { + $client = $this->imapClientFactory->getClient($account); + try { + $this->folderMapper->renameFolder($client, $mailbox->getName(), $newName); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } finally { + $client->logout(); + } + + try { + return $this->mailboxMapper->find($account, $newName); + } catch (DoesNotExistException $e) { + throw new ServiceException("The renamed mailbox $newName does not exist", 0, $e); + } + } + + #[\Override] + public function deleteMailbox(Account $account, Mailbox $mailbox): void { + $client = $this->imapClientFactory->getClient($account); + try { + $this->folderMapper->delete($client, $mailbox->getName()); + } finally { + $client->logout(); + } + + $this->mailboxMapper->delete($mailbox); + } + + #[\Override] + public function subscribeMailbox(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + $client = $this->imapClientFactory->getClient($account); + try { + $client->subscribeMailbox($mailbox->getName(), $subscribed); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not set subscription status for mailbox ' . $mailbox->getId() . ' on IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + return $this->mailboxMapper->find($account, $mailbox->getName()); + } +} diff --git a/lib/IMAP/ImapMessageConnector.php b/lib/IMAP/ImapMessageConnector.php new file mode 100644 index 0000000000..9bb9a9fdce --- /dev/null +++ b/lib/IMAP/ImapMessageConnector.php @@ -0,0 +1,367 @@ +synchronizer->syncAccount($account, $logger, $force); + } + + #[\Override] + public function syncMessages(Account $account, Mailbox $mailbox, bool $force = false): SyncResult { + $client = $this->imapClientFactory->getClient($account); + try { + $rebuildThreads = $this->synchronizer->sync( + $account, + $client, + $mailbox, + $this->logger, + force: $force, + ); + } finally { + $client->logout(); + } + + return new SyncResult( + syncToken: $mailbox->getSyncChangedToken(), + stats: [ + 'rebuildThreads' => $rebuildThreads, + ], + ); + } + + #[\Override] + public function syncMailbox(Account $account, Mailbox $mailbox, LoggerInterface $logger, int $criteria, ?array $knownUids = null, bool $force = false): SyncResult { + $client = $this->imapClientFactory->getClient($account); + try { + $rebuildThreads = $this->synchronizer->sync( + $account, + $client, + $mailbox, + $logger, + $criteria, + $knownUids, + $force, + ); + } finally { + $client->logout(); + } + + return new SyncResult( + syncToken: $mailbox->getSyncChangedToken(), + stats: [ + 'rebuildThreads' => $rebuildThreads, + ], + ); + } + + #[\Override] + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->synchronizer->clearCache($account, $mailbox); + } + + #[\Override] + public function repairSync(Account $account, Mailbox $mailbox, LoggerInterface $logger): void { + $this->synchronizer->repairSync($account, $mailbox, $logger); + } + + #[\Override] + public function fetchMessage(Account $account, Mailbox $mailbox, int $uid, bool $loadBody = false): IMAPMessage { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->find( + $client, + $mailbox->getName(), + $uid, + $account->getUserId(), + $loadBody, + ); + } finally { + $client->logout(); + } + } + + #[\Override] + public function fetchMessageBody(Account $account, Mailbox $mailbox, int $uid): ?string { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->getFullText( + $client, + $mailbox->getName(), + $uid, + $account->getUserId(), + false, + ); + } finally { + $client->logout(); + } + } + + /** + * @return Attachment[] + */ + #[\Override] + public function fetchAttachments(Account $account, Mailbox $mailbox, int $uid): array { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->getAttachments( + $client, + $mailbox->getName(), + $uid, + $account->getUserId(), + ); + } finally { + $client->logout(); + } + } + + #[\Override] + public function fetchAttachment(Account $account, Mailbox $mailbox, int $uid, string $attachmentId): Attachment { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->getAttachment( + $client, + $mailbox->getName(), + $uid, + $attachmentId, + $account->getUserId(), + ); + } finally { + $client->logout(); + } + } + + #[\Override] + public function flagMessage(Account $account, Mailbox $mailbox, int $uid, string $flag, bool $value): void { + $client = $this->imapClientFactory->getClient($account); + try { + $imapFlags = $this->filterFlags($client, $flag, $mailbox->getName()); + foreach ($imapFlags as $imapFlag) { + if ($imapFlag === '') { + continue; + } + if ($value) { + $this->messageMapper->addFlag($client, $mailbox, [$uid], $imapFlag); + } else { + $this->messageMapper->removeFlag($client, $mailbox, [$uid], $imapFlag); + } + } + } finally { + $client->logout(); + } + + $this->eventDispatcher->dispatchTyped( + new MessageFlaggedEvent($account, $mailbox, $uid, $flag, $value), + ); + } + + #[\Override] + public function moveMessage(Account $account, string $sourceMailbox, int $uid, string $destMailbox): ?int { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->move($client, $sourceMailbox, $uid, $destMailbox); + } finally { + $client->logout(); + } + } + + #[\Override] + public function deleteMessage(Account $account, Mailbox $mailbox, int $uid): void { + $this->eventDispatcher->dispatchTyped( + new BeforeMessageDeletedEvent($account, $mailbox->getName(), $uid), + ); + + try { + $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); + if ($trashMailboxId === null) { + throw new TrashMailboxNotSetException(); + } + $trashMailbox = $this->mailboxMapper->findById($trashMailboxId); + } catch (DoesNotExistException $e) { + throw new ServiceException('No trash folder', 0, $e); + } + + $client = $this->imapClientFactory->getClient($account); + try { + if ($mailbox->getName() === $trashMailbox->getName()) { + $this->messageMapper->expunge($client, $mailbox->getName(), $uid); + } else { + $this->messageMapper->move($client, $mailbox->getName(), $uid, $trashMailbox->getName()); + } + } finally { + $client->logout(); + } + + $this->eventDispatcher->dispatchTyped( + new MessageDeletedEvent($account, $mailbox, $uid), + ); + } + + #[\Override] + public function markAllRead(Account $account, Mailbox $mailbox): void { + $client = $this->imapClientFactory->getClient($account); + try { + $this->messageMapper->markAllRead($client, $mailbox->getName()); + } finally { + $client->logout(); + } + } + + #[\Override] + public function clearMailbox(Account $account, Mailbox $mailbox): void { + $client = $this->imapClientFactory->getClient($account); + $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); + $currentMailboxId = $mailbox->getId(); + try { + if (($currentMailboxId !== $trashMailboxId) && $trashMailboxId !== null) { + $trashMailbox = $this->mailboxMapper->findById($trashMailboxId); + $client->copy($mailbox->getName(), $trashMailbox->getName(), [ + 'move' => true, + ]); + } else { + $client->expunge($mailbox->getName(), [ + 'delete' => true, + ]); + } + $this->dbMessageMapper->deleteAll($mailbox); + } finally { + $client->logout(); + } + } + + #[\Override] + public function getQuota(Account $account): ?Quota { + $client = $this->imapClientFactory->getClient($account); + try { + $quotas = array_map( + static fn (Folder $mailbox) => $client->getQuotaRoot($mailbox->getMailbox()), + $this->folderMapper->getFolders($account, $client), + ); + } catch (Horde_Imap_Client_Exception_NoSupportExtension) { + return null; + } finally { + $client->logout(); + } + + $storageQuotas = array_map(static fn (array $root) => $root['storage'] ?? [ + 'usage' => 0, + 'limit' => 0, + ], array_merge(...array_values($quotas))); + + if ($storageQuotas === []) { + return null; + } + + $storage = array_merge(...array_values($storageQuotas)); + + return new Quota( + 1024 * (int)($storage['usage'] ?? 0), + 1024 * (int)($storage['limit'] ?? 0), + ); + } + + #[\Override] + public function isPermflagsEnabled(Account $account, string $mailbox): bool { + $client = $this->imapClientFactory->getClient($account); + try { + $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get message flag options from IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + return is_array($capabilities) + && in_array('\\*', $capabilities['permflags'] ?? [], true); + } + + /** + * @return string[] + */ + private function filterFlags($client, string $flag, string $mailbox): array { + $systemFlags = [ + 'seen' => [Horde_Imap_Client::FLAG_SEEN], + 'answered' => [Horde_Imap_Client::FLAG_ANSWERED], + 'flagged' => [Horde_Imap_Client::FLAG_FLAGGED], + 'deleted' => [Horde_Imap_Client::FLAG_DELETED], + 'draft' => [Horde_Imap_Client::FLAG_DRAFT], + 'recent' => [Horde_Imap_Client::FLAG_RECENT], + ]; + + if (isset($systemFlags[$flag])) { + return $systemFlags[$flag]; + } + + try { + $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get message flag options from IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + + if (!isset($capabilities['permflags'])) { + return []; + } + + if (in_array('\\*', $capabilities['permflags'], true) || in_array($flag, $capabilities['permflags'], true)) { + return [$flag]; + } + + return []; + } +} diff --git a/lib/IMAP/ImapTransmissionConnector.php b/lib/IMAP/ImapTransmissionConnector.php new file mode 100644 index 0000000000..512aa09d3d --- /dev/null +++ b/lib/IMAP/ImapTransmissionConnector.php @@ -0,0 +1,45 @@ +mailTransmission->sendMessage($account, $message); + } + + #[\Override] + public function saveDraft(Account $account, LocalMessage $message): void { + $this->mailTransmission->saveLocalDraft($account, $message); + } + + #[\Override] + public function saveRawMessageToMailbox(Account $account, Mailbox $mailbox, string $raw, array $flags = []): ?int { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->messageMapper->save($client, $mailbox, $raw, $flags); + } finally { + $client->logout(); + } + } +} diff --git a/lib/IMAP/MailboxSync.php b/lib/IMAP/MailboxSync.php index 937900f2af..14d476ab9d 100644 --- a/lib/IMAP/MailboxSync.php +++ b/lib/IMAP/MailboxSync.php @@ -81,7 +81,7 @@ public function sync(Account $account, LoggerInterface $logger, bool $force = false, ?Horde_Imap_Client_Socket $client = null): void { - if (!$force && $account->getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - 7200)) { + if (!$force && $account->getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - 900)) { $logger->debug('account is up to date, skipping mailbox sync'); return; } diff --git a/lib/JMAP/JmapClientFactory.php b/lib/JMAP/JmapClientFactory.php new file mode 100644 index 0000000000..fe1624aaeb --- /dev/null +++ b/lib/JMAP/JmapClientFactory.php @@ -0,0 +1,79 @@ +getMailAccount(); + + $host = $mailAccount->getInboundHost(); + if ($host === null || $host === '') { + throw new ServiceException('JMAP host is not configured for account ' . $account->getId()); + } + + $port = $mailAccount->getInboundPort(); + $secure = $mailAccount->getInboundSslMode() === 'yes'; + $path = $mailAccount->getPath() ?? '/.well-known/jmap'; + $user = $mailAccount->getInboundUser(); + $encryptedPassword = $mailAccount->getInboundPassword(); + + if ($encryptedPassword === null) { + throw new ServiceException('No password set for JMAP account ' . $account->getId()); + } + + try { + $password = $this->crypto->decrypt($encryptedPassword); + } catch (\Exception $e) { + throw new ServiceException( + 'Could not decrypt password for JMAP account ' . $account->getId() . ': ' . $e->getMessage(), + 0, + $e, + ); + } + + $client = new JmapClient(); + $client->configureTransportMode($secure ? 'https' : 'http'); + $client->setHost($host . ':' . $port); + if ($path !== '/.well-known/jmap') { + $client->setDiscoveryPath($path); + } + $client->configureTransportVerification( + $this->config->getSystemValueBool('app.mail.verify-tls-peer', true) + ); + $client->setAuthentication(new Basic($user, $password)); + + return $client; + } +} diff --git a/lib/JMAP/JmapMailboxAdapter.php b/lib/JMAP/JmapMailboxAdapter.php new file mode 100644 index 0000000000..c2e6031808 --- /dev/null +++ b/lib/JMAP/JmapMailboxAdapter.php @@ -0,0 +1,182 @@ +setName($response->label() ?? $response->id() ?? ''); + $mailbox->setNameHash(md5($response->id())); + $mailbox->setRemoteParentId($response->in()); + $mailbox->setRemoteId($response->id()); + $mailbox->setState(null); + $mailbox->setAttributes(json_encode($this->convertToAttributes($response), JSON_THROW_ON_ERROR)); + $mailbox->setDelimiter(self::DELIMITER); + $mailbox->setMessages($response->objectsTotal() ?? 0); + $mailbox->setUnseen($response->objectsUnseen() ?? 0); + $mailbox->setSelectable($response->rights()?->readItems() === true); + $mailbox->setSpecialUse(json_encode($this->convertToSpecialUse($response), JSON_THROW_ON_ERROR)); + $mailbox->setMyAcls($this->convertToAcl($response)); + $mailbox->setShared(false); + + return $mailbox; + } + + /** + * @throws JsonException + */ + public function convertFromMailbox(Mailbox $mailbox, array $patch = []): MailboxParametersRequest { + $properties = ['location', 'name', 'subscribed', 'role', 'rights']; + if (!empty($patch)) { + $properties = array_intersect($properties, $patch); + } + + $request = new MailboxParametersRequest(); + + if (in_array('location', $properties, true)) { + $request->in($mailbox->getRemoteParentId()); + } + if (in_array('name', $properties, true)) { + $request->label($mailbox->getName()); + } + if (in_array('subscribed', $properties, true)) { + $request->subscribed(str_contains($mailbox->getAttributes() ?? '', '\\subscribed')); + } + if (in_array('role', $properties, true)) { + $specialUse = json_decode($mailbox->getSpecialUse() ?? '[]', true) ?? []; + $role = $this->convertFromSpecialUse($specialUse); + $request->role($role); + } + if (in_array('rights', $properties, true)) { + $acls = $mailbox->getMyAcls(); + $request->rights(new MailboxRights( + readItems: str_contains($acls ?? '', 'l') || str_contains($acls ?? '', 'r') || str_contains($acls ?? '', 'a'), + addItems: str_contains($acls ?? '', 'i') || str_contains($acls ?? '', 'a'), + removeItems: str_contains($acls ?? '', 't') || str_contains($acls ?? '', 'e') || str_contains($acls ?? '', 'a'), + setSeen: str_contains($acls ?? '', 's') || str_contains($acls ?? '', 'a'), + setKeywords: str_contains($acls ?? '', 'w') || str_contains($acls ?? '', 'a'), + createChild: str_contains($acls ?? '', 'k') || str_contains($acls ?? '', 'a'), + rename: str_contains($acls ?? '', 'x') || str_contains($acls ?? '', 'a'), + delete: str_contains($acls ?? '', 'x') || str_contains($acls ?? '', 'a'), + submit: str_contains($acls ?? '', 'p') || str_contains($acls ?? '', 'a'), + )); + } + + return $request; + } + + /** + * @return string[] + */ + private function convertToAttributes(MailboxParametersResponse $response): array { + $attributes = []; + + if ($response->subscribed() !== false) { + $attributes[] = '\\subscribed'; + } + + $role = $response->role(); + if ($role !== null && $role !== '') { + $attributes[] = '\\' . $this->normalizeSpecialUse($role); + } + + if ($response->rights()?->readItems() !== true) { + $attributes[] = '\\noselect'; + } + + return $attributes; + } + + /** + * @return string[] + */ + private function convertToSpecialUse(MailboxParametersResponse $response): array { + $role = $response->role(); + if ($role === null || $role === '') { + return []; + } + + return [$this->normalizeSpecialUse($role)]; + } + + /** + * @param string[] $specialUse + */ + private function convertFromSpecialUse(array $specialUse): ?string { + $role = $specialUse[0] ?? null; + if ($role === null) { + return null; + } + + $role = strtolower(trim($role, '\\')); + if ($role === 'flagged') { + return 'important'; + } + + $allowed = ['all', 'archive', 'drafts', 'important', 'inbox', 'junk', 'sent', 'trash']; + + return in_array($role, $allowed, true) ? $role : null; + } + + private function normalizeSpecialUse(string $role): string { + $role = strtolower($role); + + return $role === 'important' ? 'flagged' : $role; + } + + private function convertToAcl(MailboxParametersResponse $response): ?string { + $rights = $response->rights(); + if ($rights === null) { + return null; + } + + $acls = ''; + if ($rights->readItems() === true) { + $acls .= 'lr'; + } + if ($rights->addItems() === true) { + $acls .= 'i'; + } + if ($rights->removeItems() === true) { + $acls .= 'te'; + } + if ($rights->setSeen() === true) { + $acls .= 's'; + } + if ($rights->setKeywords() === true) { + $acls .= 'w'; + } + if ($rights->createChild() === true) { + $acls .= 'k'; + } + if ($rights->rename() === true || $rights->delete() === true) { + $acls .= 'x'; + } + if ($rights->submit() === true) { + $acls .= 'p'; + } + if ($rights->createChild() === true && $rights->rename() === true && $rights->delete() === true) { + $acls .= 'a'; + } + + return $acls === '' ? null : $acls; + } + +} diff --git a/lib/JMAP/JmapMailboxConnector.php b/lib/JMAP/JmapMailboxConnector.php new file mode 100644 index 0000000000..0570774311 --- /dev/null +++ b/lib/JMAP/JmapMailboxConnector.php @@ -0,0 +1,311 @@ +getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - self::MAILBOX_SYNC_TTL)) { + $logger->debug('account is up to date, skipping mailbox sync'); + return; + } + + $this->jmapOperationsService->connect($account); + $remoteMailboxes = $this->jmapOperationsService->collectionList(); + $localMailboxes = $this->mailboxMapper->findAll($account); + $remoteMailboxNames = $this->constructMailboxSyncNameLookup($remoteMailboxes, $logger); + + // create or update mailboxes locally that are present remotely + foreach ($remoteMailboxes as $remoteMailbox) { + $remoteMailboxName = $remoteMailboxNames[$remoteMailbox->getRemoteId()] ?? $remoteMailbox->getName(); + $remoteMailbox->setName($remoteMailboxName); + + $localMailboxIdx = null; + $localMailboxData = null; + foreach ($localMailboxes as $key => $candidate) { + if ($candidate->getRemoteId() === $remoteMailbox->getRemoteId()) { + $localMailboxIdx = $key; + $localMailboxData = $candidate; + break; + } + } + + if ($localMailboxData === null) { + $remoteMailbox->setAccountId($account->getId()); + $this->mailboxMapper->insert($remoteMailbox); + } else { + $localMailbox = $this->mergeMailbox($localMailboxData, $remoteMailbox); + $this->mailboxMapper->update($localMailbox); + unset($localMailboxes[$localMailboxIdx]); + } + } + // delete local mailboxes that are not present remotely + if (count($localMailboxes) > 0) { + foreach ($localMailboxes as $mailbox) { + $this->mailboxMapper->delete($mailbox); + } + } + + $this->dispatcher->dispatchTyped(new MailboxesSynchronizedEvent($account)); + } + + #[\Override] + public function syncMailboxStats(Account $account, Mailbox $mailbox): void { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + $remoteMailbox = $this->jmapOperationsService->collectionFetch($mailbox->getRemoteId()); + $this->mailboxMapper->update($this->mergeMailbox($mailbox, $remoteMailbox)); + } + + #[\Override] + public function createMailbox(Account $account, string $name, array $specialUse = []): Mailbox { + $this->jmapOperationsService->connect($account); + + // extract the mailbox name and parent name from the full path for remote operation + $pathParts = explode(self::DELIMITER, $name); + if (count($pathParts) === 1) { + $mailboxName = $name; + $parentName = null; + } else { + $mailboxName = array_pop($pathParts); + $parentName = implode(self::DELIMITER, $pathParts); + } + // find the parent mailbox to retrieve remote mailbox id for remote operation + if ($parentName !== null) { + try { + $location = $this->mailboxMapper->findByName($account, $parentName); + } catch (DoesNotExistException $e) { + throw new ServiceException('JMAP parent mailbox does not exist: ' . $parentName); + } + + if ($location->getRemoteId() === null) { + throw new ServiceException('JMAP parent mailbox is missing a remote id: ' . $parentName); + } + } else { + $location = null; + } + // construct the mailbox for the remote and local creation + $mailbox = new Mailbox(); + $mailbox->setAccountId($account->getId()); + $mailbox->setDelimiter(self::DELIMITER); + $mailbox->setMessages(0); + $mailbox->setUnseen(0); + $mailbox->setSelectable(true); + $mailbox->setAttributes(json_encode(['\\subscribed'], JSON_THROW_ON_ERROR)); + $mailbox->setSpecialUse(json_encode($specialUse, JSON_THROW_ON_ERROR)); + // create in remote store, using only the mailbox + $mailbox->setName($mailboxName); + $mailbox = $this->jmapOperationsService->collectionCreate($location, $mailbox); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox creation failed'); + } + // create in local store, using the full path name + $mailbox->setName($name); + $mailbox = $this->mailboxMapper->insert($mailbox); + + return $mailbox; + } + + #[\Override] + public function renameMailbox(Account $account, Mailbox $mailbox, string $newName): Mailbox { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // extract the mailbox name from the full path for remote operation + $pathParts = explode(self::DELIMITER, $newName); + if (count($pathParts) === 1) { + $mailboxName = $newName; + } else { + $mailboxName = array_pop($pathParts); + } + // update remote store, using only the mailbox name + $mailbox->setName($mailboxName); + $mailbox = $this->jmapOperationsService->collectionModify($mailbox->getRemoteId(), $mailbox, ['name']); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox rename failed'); + } + // update local store, with the full path name + try { + $mailbox->setName($newName); + return $this->mailboxMapper->update($mailbox); + } catch (DoesNotExistException $e) { + throw new ServiceException("The renamed mailbox $newName does not exist", 0, $e); + } + } + + #[\Override] + public function deleteMailbox(Account $account, Mailbox $mailbox): void { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // delete from remote store + $result = $this->jmapOperationsService->collectionDestroy($mailbox->getRemoteId()); + if ($result === null) { + throw new ServiceException('JMAP mailbox deletion failed'); + } + // delete from local store + $this->mailboxMapper->delete($mailbox); + } + + #[\Override] + public function subscribeMailbox(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // update subscription attribute + $attributes = json_decode($mailbox->getAttributes() ?? '[]', true); + if (!is_array($attributes)) { + $attributes = []; + } + if ($subscribed) { + $attributes[] = '\\subscribed'; + } else { + $attributes = array_filter($attributes, static function ($attribute) { + return $attribute !== '\\subscribed'; + }); + } + $mailbox->setAttributes(json_encode(array_values(array_unique($attributes)))); + // update remote store + $mailbox = $this->jmapOperationsService->collectionModify($mailbox->getRemoteId(), $mailbox, ['subscribed']); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox subscription update failed'); + } + // update local store + try { + return $this->mailboxMapper->update($mailbox); + } catch (DoesNotExistException $e) { + throw new ServiceException('The updated mailbox does not exist', 0, $e); + } + } + + + /** + * @param Mailbox[] $remoteMailboxes + * @return array + */ + private function constructMailboxSyncNameLookup(array $remoteMailboxes): array { + $mailboxesByRid = []; + foreach ($remoteMailboxes as $remoteMailbox) { + $rid = $remoteMailbox->getRemoteId(); + if ($rid === null) { + continue; + } + + $mailboxesByRid[$rid] = $remoteMailbox; + } + + $lookup = []; + $visiting = []; + $resolveMailboxPath = function (string $rid) use (&$resolveMailboxPath, $mailboxesByRid, &$lookup, &$visiting): string { + if (isset($lookup[$rid])) { + return $lookup[$rid]; + } + + $mailbox = $mailboxesByRid[$rid]; + if (isset($visiting[$rid])) { + $this->logger->warning('Detected cyclic JMAP mailbox parent relationship', [ + 'rid' => $rid, + ]); + + return $mailbox->getName(); + } + + $visiting[$rid] = true; + $path = $mailbox->getName(); + $parentRid = $mailbox->getRemoteParentId(); + + if ($parentRid !== null) { + if (isset($mailboxesByRid[$parentRid])) { + $path = $resolveMailboxPath($parentRid) . self::DELIMITER . $path; + } else { + $this->logger->warning('JMAP mailbox parent missing from sync payload', [ + 'rid' => $rid, + 'parentRid' => $parentRid, + ]); + } + } + + unset($visiting[$rid]); + $lookup[$rid] = $path; + + return $path; + }; + + foreach (array_keys($mailboxesByRid) as $rid) { + $resolveMailboxPath($rid); + } + + return $lookup; + } + + private function mergeMailbox(Mailbox $target, Mailbox $source): Mailbox { + $target->setName($source->getName()); + $target->setNameHash($source->getNameHash()); + $target->setRemoteId($source->getRemoteId()); + $target->setState($source->getState()); + $target->setAttributes($source->getAttributes()); + $target->setDelimiter($source->getDelimiter()); + $target->setMessages($source->getMessages()); + $target->setUnseen($source->getUnseen()); + $target->setSelectable($source->getSelectable() === true); + $target->setSpecialUse($source->getSpecialUse()); + $target->setMyAcls($source->getMyAcls()); + $target->setShared($source->isShared() === true); + + return $target; + } + +} diff --git a/lib/JMAP/JmapMessageAdapter.php b/lib/JMAP/JmapMessageAdapter.php new file mode 100644 index 0000000000..3c6f09895c --- /dev/null +++ b/lib/JMAP/JmapMessageAdapter.php @@ -0,0 +1,305 @@ +setUid($uid); + $message->setRemoteId($remoteMessage['id'] ?? null); + $message->setMailboxId($mailbox->getId()); + $message->setMessageId($this->firstString($remoteMessage['messageId'] ?? null)); + $message->setInReplyTo($this->firstString($remoteMessage['inReplyTo'] ?? null)); + $message->setReferences($this->normalizeReferenceValue($remoteMessage['references'] ?? null)); + $message->setThreadRootId($this->firstString($remoteMessage['threadId'] ?? ($remoteMessage['messageId'] ?? null))); + $message->setSubject((string)($remoteMessage['subject'] ?? '')); + $message->setSentAt($this->parseTimestamp($remoteMessage['receivedAt'] ?? $remoteMessage['sentAt'] ?? null)); + $message->setFlagAnswered((bool)($remoteMessage['keywords']['$answered'] ?? false)); + $message->setFlagDeleted((bool)($remoteMessage['keywords']['$deleted'] ?? false)); + $message->setFlagDraft((bool)($remoteMessage['keywords']['$draft'] ?? false)); + $message->setFlagFlagged((bool)($remoteMessage['keywords']['$flagged'] ?? false)); + $message->setFlagSeen((bool)($remoteMessage['keywords']['$seen'] ?? false)); + $message->setFlagForwarded((bool)($remoteMessage['keywords']['$forwarded'] ?? false)); + $message->setFlagJunk((bool)($remoteMessage['keywords']['$junk'] ?? false)); + $message->setFlagNotjunk((bool)($remoteMessage['keywords']['$notjunk'] ?? false)); + $message->setFlagImportant((bool)(($remoteMessage['keywords']['$important'] ?? false) || ($remoteMessage['keywords'][Tag::LABEL_IMPORTANT] ?? false))); + $message->setFlagMdnsent((bool)($remoteMessage['keywords']['$mdnsent'] ?? false)); + $message->setPreviewText(is_string($remoteMessage['preview'] ?? null) ? $remoteMessage['preview'] : null); + $message->setFlagAttachments((bool)($remoteMessage['hasAttachment'] ?? false)); + $message->setUpdatedAt($this->parseTimestamp($remoteMessage['receivedAt'] ?? $remoteMessage['sentAt'] ?? null)); + + $message->setFrom($this->convertAddressList($remoteMessage['from'] ?? $remoteMessage['sender'] ?? [])); + $message->setTo($this->convertAddressList($remoteMessage['to'] ?? [])); + $message->setCc($this->convertAddressList($remoteMessage['cc'] ?? [])); + $message->setBcc($this->convertAddressList($remoteMessage['bcc'] ?? [])); + $message->setTags($this->convertTags($remoteMessage['keywords'] ?? [])); + + return $message; + } + + public function convertToImapMessage(int $uid, array $remoteMessage, bool $loadBody): IMAPMessage { + [$plainBody, $htmlBody, $attachments, $inlineAttachments] = $this->extractBodiesAndAttachments($uid, $remoteMessage, $loadBody); + $flags = $this->convertFlags($remoteMessage['keywords'] ?? []); + + return new IMAPMessage( + $uid, + $this->firstString($remoteMessage['messageId'] ?? null) ?? '', + $flags, + $this->convertAddressList($remoteMessage['from'] ?? $remoteMessage['sender'] ?? []), + $this->convertAddressList($remoteMessage['to'] ?? []), + $this->convertAddressList($remoteMessage['cc'] ?? []), + $this->convertAddressList($remoteMessage['bcc'] ?? []), + $this->convertAddressList($remoteMessage['replyTo'] ?? []), + (string)($remoteMessage['subject'] ?? ''), + $plainBody, + $htmlBody, + $htmlBody !== '', + $attachments, + $inlineAttachments, + $attachments !== [] || $inlineAttachments !== [], + [], + new Horde_Imap_Client_DateTime('@' . $this->parseTimestamp($remoteMessage['receivedAt'] ?? $remoteMessage['sentAt'] ?? null)), + $this->normalizeRawMessageIdList($remoteMessage['references'] ?? null), + '', + false, + [], + null, + false, + null, + $this->firstString($remoteMessage['inReplyTo'] ?? null) ?? '', + false, + false, + false, + $this->htmlService, + false, + ); + } + + public function normalizeFlagKeyword(string $flag): string { + $normalized = strtolower($flag); + return match ($normalized) { + Horde_Imap_Client::FLAG_SEEN, 'seen' => '$seen', + Horde_Imap_Client::FLAG_FLAGGED, 'flagged' => '$flagged', + Horde_Imap_Client::FLAG_ANSWERED, 'answered' => '$answered', + Horde_Imap_Client::FLAG_DELETED, 'deleted' => '$deleted', + Horde_Imap_Client::FLAG_DRAFT, 'draft' => '$draft', + Horde_Imap_Client::FLAG_FORWARDED, 'forwarded' => '$forwarded', + Horde_Imap_Client::FLAG_MDNSENT, '$mdnsent' => '$mdnsent', + 'important' => Tag::LABEL_IMPORTANT, + default => $flag, + }; + } + + /** + * @param array> $messages + */ + public function countUnreadMessages(array $messages): int { + $count = 0; + foreach ($messages as $message) { + if (($message['keywords']['$seen'] ?? false) !== true) { + $count++; + } + } + + return $count; + } + + private function convertAddressList(array $entries): AddressList { + $addresses = []; + foreach ($entries as $entry) { + $email = is_array($entry) ? ($entry['email'] ?? null) : null; + if (!is_string($email) || $email === '') { + continue; + } + $addresses[] = Address::fromRaw((string)($entry['name'] ?? $email), $email); + } + + return new AddressList($addresses); + } + + /** + * @param array $keywords + * @return Tag[] + */ + private function convertTags(array $keywords): array { + $tags = []; + foreach ($keywords as $keyword => $value) { + if ($value !== true || !is_string($keyword) || $keyword === '' || $keyword[0] === '$') { + continue; + } + + $tag = new Tag(); + $tag->setImapLabel($keyword); + $tag->setDisplayName($keyword); + $tag->setColor(''); + $tag->setIsDefaultTag(false); + $tags[] = $tag; + } + + return $tags; + } + + /** + * @param array $keywords + * @return string[] + */ + private function convertFlags(array $keywords): array { + $flags = []; + $map = [ + '$seen' => Horde_Imap_Client::FLAG_SEEN, + '$flagged' => Horde_Imap_Client::FLAG_FLAGGED, + '$answered' => Horde_Imap_Client::FLAG_ANSWERED, + '$deleted' => Horde_Imap_Client::FLAG_DELETED, + '$draft' => Horde_Imap_Client::FLAG_DRAFT, + '$forwarded' => Horde_Imap_Client::FLAG_FORWARDED, + '$mdnsent' => Horde_Imap_Client::FLAG_MDNSENT, + ]; + + foreach ($keywords as $keyword => $enabled) { + if ($enabled !== true || !is_string($keyword)) { + continue; + } + $flags[] = $map[$keyword] ?? $keyword; + } + + if (($keywords[Tag::LABEL_IMPORTANT] ?? false) === true && !in_array(Tag::LABEL_IMPORTANT, $flags, true)) { + $flags[] = Tag::LABEL_IMPORTANT; + } + + return $flags; + } + + private function parseTimestamp(mixed $value): int { + if (is_string($value) && $value !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return $timestamp; + } + } + + return time(); + } + + private function firstString(mixed $value): ?string { + if (is_string($value) && $value !== '') { + return $value; + } + if (is_array($value)) { + foreach ($value as $entry) { + if (is_string($entry) && $entry !== '') { + return $entry; + } + } + } + + return null; + } + + private function normalizeReferenceValue(mixed $references): ?string { + if ($references === null) { + return null; + } + if (is_string($references)) { + return $references; + } + if (is_array($references)) { + return json_encode(array_values(array_filter($references, static fn (mixed $value): bool => is_string($value) && $value !== ''))); + } + + return null; + } + + private function normalizeRawMessageIdList(mixed $references): string { + if (!is_array($references)) { + return ''; + } + + return implode(' ', array_filter($references, static fn (mixed $value): bool => is_string($value) && $value !== '')); + } + + /** + * @return array{0:string,1:string,2:array,3:array} + */ + private function extractBodiesAndAttachments(int $uid, array $remoteMessage, bool $loadBody): array { + if (!$loadBody) { + return ['', '', [], []]; + } + + $bodyValues = is_array($remoteMessage['bodyValues'] ?? null) ? $remoteMessage['bodyValues'] : []; + $plainBody = ''; + $htmlBody = ''; + $attachments = []; + $inlineAttachments = []; + + $walk = function (array $part) use (&$walk, &$plainBody, &$htmlBody, &$attachments, &$inlineAttachments, $bodyValues, $uid): void { + $type = strtolower((string)($part['type'] ?? '')); + $partId = $part['partId'] ?? null; + $value = is_string($partId) && isset($bodyValues[$partId]['value']) && is_string($bodyValues[$partId]['value']) + ? $bodyValues[$partId]['value'] + : ''; + + if ($type === 'text/plain' && $plainBody === '') { + $plainBody = $value; + } + if ($type === 'text/html' && $htmlBody === '') { + $htmlBody = $value; + } + + $disposition = strtolower((string)($part['disposition'] ?? '')); + if ($disposition === 'attachment' || $disposition === 'inline') { + $entry = [ + 'id' => $partId, + 'messageId' => $uid, + 'fileName' => $part['name'] ?? null, + 'mime' => $type !== '' ? $type : 'application/octet-stream', + 'size' => (int)($part['size'] ?? 0), + 'cid' => $part['cid'] ?? null, + 'disposition' => $part['disposition'] ?? null, + ]; + if ($disposition === 'inline') { + $inlineAttachments[] = $entry; + } else { + $attachments[] = $entry; + } + } + + foreach ($part['subParts'] ?? [] as $subPart) { + if (is_array($subPart)) { + $walk($subPart); + } + } + }; + + if (is_array($remoteMessage['bodyStructure'] ?? null)) { + $walk($remoteMessage['bodyStructure']); + } + + if ($plainBody === '' && is_string($remoteMessage['preview'] ?? null)) { + $plainBody = $remoteMessage['preview']; + } + + return [$plainBody, $htmlBody, $attachments, $inlineAttachments]; + } +} \ No newline at end of file diff --git a/lib/JMAP/JmapMessageConnector.php b/lib/JMAP/JmapMessageConnector.php new file mode 100644 index 0000000000..7c008cebde --- /dev/null +++ b/lib/JMAP/JmapMessageConnector.php @@ -0,0 +1,402 @@ +mailboxMapper->findAll($account) as $mailbox) { + $syncSent = $account->getMailAccount()->getSentMailboxId() === $mailbox->getId() || $mailbox->isSpecialUse('sent'); + if (!$mailbox->isInbox() && !$mailbox->getSyncInBackground() && !$syncSent) { + $logger->debug('Skipping mailbox sync for ' . $mailbox->getId()); + continue; + } + + $logger->debug('Syncing ' . $mailbox->getId()); + $this->syncMessages($account, $mailbox, $force); + $rebuildThreads = true; + } + + $this->eventDispatcher->dispatchTyped(new SynchronizationEvent($account, $logger, $rebuildThreads)); + } + + #[\Override] + public function syncMessages(Account $account, Mailbox $mailbox, bool $force = false): SyncResult { + if ($mailbox->getRemoteId() === null || $mailbox->getSelectable() === false) { + return new SyncResult(syncToken: $mailbox->getSyncChangedToken()); + } + + $this->jmapOperationsService->connect($account); + + if ($force || !$mailbox->isCached()) { + return $this->fullSync($account, $mailbox); + } + + return $this->deltaSync($account, $mailbox); + } + + #[\Override] + public function syncMailbox(Account $account, Mailbox $mailbox, LoggerInterface $logger, int $criteria, ?array $knownUids = null, bool $force = false): SyncResult { + return $this->syncMessages($account, $mailbox, $force); + } + + #[\Override] + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->dbMessageMapper->deleteAll($mailbox); + $mailbox->setSyncNewToken(null); + $mailbox->setSyncChangedToken(null); + $mailbox->setSyncVanishedToken(null); + $this->mailboxMapper->update($mailbox); + } + + #[\Override] + public function repairSync(Account $account, Mailbox $mailbox, LoggerInterface $logger): void { + $this->clearCache($account, $mailbox); + $logger->debug('Repairing JMAP mailbox cache for ' . $mailbox->getId()); + $this->syncMessages($account, $mailbox, true); + } + + #[\Override] + public function fetchMessage(Account $account, Mailbox $mailbox, int $uid, bool $loadBody = false): IMAPMessage { + $message = $this->findLocalMessageByUid($mailbox, $uid); + $rid = $message->getRemoteId(); + if ($rid === null) { + throw new ServiceException("Message $uid does not have a JMAP remote id"); + } + + $this->jmapOperationsService->connect($account); + $remoteMessages = $this->jmapOperationsService->entityFetch($rid); + $remoteMessage = $remoteMessages[$rid] ?? null; + if (!is_array($remoteMessage)) { + throw new ServiceException("Message $uid could not be fetched from JMAP"); + } + + return $this->jmapMessageAdapter->convertToImapMessage($uid, $remoteMessage, $loadBody); + } + + #[\Override] + public function fetchMessageBody(Account $account, Mailbox $mailbox, int $uid): ?string { + return $this->fetchMessage($account, $mailbox, $uid, true)->plainMessage; + } + + /** + * @return Attachment[] + */ + #[\Override] + public function fetchAttachments(Account $account, Mailbox $mailbox, int $uid): array { + $message = $this->fetchMessage($account, $mailbox, $uid, true); + return array_map( + static fn (array $attachment): Attachment => new Attachment( + $attachment['id'] ?? null, + $attachment['fileName'] ?? null, + $attachment['mime'] ?? 'application/octet-stream', + '', + (int)($attachment['size'] ?? 0), + ), + $message->attachments, + ); + } + + #[\Override] + public function fetchAttachment(Account $account, Mailbox $mailbox, int $uid, string $attachmentId): Attachment { + foreach ($this->fetchAttachments($account, $mailbox, $uid) as $attachment) { + if ($attachment->getId() === $attachmentId) { + return $attachment; + } + } + + throw new ServiceException("Attachment $attachmentId does not exist on message $uid"); + } + + #[\Override] + public function flagMessage(Account $account, Mailbox $mailbox, int $uid, string $flag, bool $value): void { + $message = $this->findLocalMessageByUid($mailbox, $uid); + $rid = $message->getRemoteId(); + if ($rid === null) { + throw new ServiceException("Message $uid does not have a JMAP remote id"); + } + + $this->jmapOperationsService->connect($account); + $remoteMessages = $this->jmapOperationsService->entityFetch($rid); + $remoteMessage = $remoteMessages[$rid] ?? null; + if (!is_array($remoteMessage)) { + throw new ServiceException("Message $uid could not be fetched from JMAP"); + } + + $keywords = is_array($remoteMessage['keywords'] ?? null) ? $remoteMessage['keywords'] : []; + $keyword = $this->jmapMessageAdapter->normalizeFlagKeyword($flag); + $keywords[$keyword] = $value; + $remoteMessage['keywords'] = $keywords; + + $this->jmapOperationsService->entityModify($remoteMessage); + $this->eventDispatcher->dispatchTyped(new MessageFlaggedEvent($account, $mailbox, $uid, $flag, $value)); + } + + #[\Override] + public function moveMessage(Account $account, string $sourceMailbox, int $uid, string $destMailbox): ?int { + $source = $this->mailboxMapper->find($account, $sourceMailbox); + $destination = $this->mailboxMapper->find($account, $destMailbox); + $message = $this->findLocalMessageByUid($source, $uid); + $rid = $message->getRemoteId(); + if ($rid === null || $destination->getRemoteId() === null) { + throw new ServiceException("Message $uid cannot be moved on JMAP"); + } + + $this->jmapOperationsService->connect($account); + $results = $this->jmapOperationsService->entityMove($destination->getRemoteId(), $rid); + if (($results[$rid] ?? false) !== true) { + throw new ServiceException("Message $uid could not be moved on JMAP"); + } + + $this->dbMessageMapper->deleteByUid($source, $uid); + return null; + } + + #[\Override] + public function deleteMessage(Account $account, Mailbox $mailbox, int $uid): void { + $this->eventDispatcher->dispatchTyped(new BeforeMessageDeletedEvent($account, $mailbox->getName(), $uid)); + + $message = $this->findLocalMessageByUid($mailbox, $uid); + $rid = $message->getRemoteId(); + if ($rid === null) { + throw new ServiceException("Message $uid does not have a JMAP remote id"); + } + + try { + $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); + if ($trashMailboxId === null) { + throw new TrashMailboxNotSetException(); + } + $trashMailbox = $this->mailboxMapper->findById($trashMailboxId); + } catch (DoesNotExistException $e) { + throw new ServiceException('No trash folder', 0, $e); + } + + $this->jmapOperationsService->connect($account); + if ($mailbox->getId() === $trashMailbox->getId()) { + $results = $this->jmapOperationsService->entityDelete($rid); + if (($results[$rid] ?? false) !== true) { + throw new ServiceException("Message $uid could not be deleted on JMAP"); + } + } else { + if ($trashMailbox->getRemoteId() === null) { + throw new ServiceException('Trash mailbox does not have a JMAP remote id'); + } + $results = $this->jmapOperationsService->entityMove($trashMailbox->getRemoteId(), $rid); + if (($results[$rid] ?? false) !== true) { + throw new ServiceException("Message $uid could not be moved to trash on JMAP"); + } + } + + $this->dbMessageMapper->deleteByUid($mailbox, $uid); + $this->eventDispatcher->dispatchTyped(new MessageDeletedEvent($account, $mailbox, $uid)); + } + + #[\Override] + public function markAllRead(Account $account, Mailbox $mailbox): void { + $messages = $this->dbMessageMapper->findByUids($mailbox, $this->dbMessageMapper->findAllUids($mailbox)); + foreach ($messages as $message) { + $this->flagMessage($account, $mailbox, $message->getUid(), Horde_Imap_Client::FLAG_SEEN, true); + } + } + + #[\Override] + public function clearMailbox(Account $account, Mailbox $mailbox): void { + $rids = array_map( + static fn (Message $message): ?string => $message->getRemoteId(), + $this->dbMessageMapper->findByUids($mailbox, $this->dbMessageMapper->findAllUids($mailbox)), + ); + $rids = array_values(array_filter($rids, static fn (?string $rid): bool => $rid !== null)); + + $this->jmapOperationsService->connect($account); + if ($rids !== []) { + $this->jmapOperationsService->entityDelete(...$rids); + } + $this->dbMessageMapper->deleteAll($mailbox); + } + + #[\Override] + public function getQuota(Account $account): ?Quota { + return null; + } + + #[\Override] + public function isPermflagsEnabled(Account $account, string $mailbox): bool { + return true; + } + + private function fullSync(Account $account, Mailbox $mailbox): SyncResult { + $results = $this->jmapOperationsService->entityList($mailbox->getRemoteId()); + $remoteMessages = $results['list'] ?? []; + $localMessages = $this->dbMessageMapper->findByUids($mailbox, $this->dbMessageMapper->findAllUids($mailbox)); + + $nextUid = ($this->dbMessageMapper->findHighestUid($mailbox) ?? 0) + 1; + $insert = []; + foreach ($remoteMessages as $remoteMessage) { + if (!is_array($remoteMessage)) { + continue; + } + + $existing = $this->findMessageByRid($localMessages, $remoteMessage['id'] ?? null); + $uid = $existing?->getUid() ?? $nextUid++; + if ($existing !== null) { + $this->dbMessageMapper->deleteByRemoteIds($mailbox, $existing->getRemoteId()); + } + $insert[] = $this->jmapMessageAdapter->convertToDbMessage($mailbox, $remoteMessage, $uid); + } + + $remoteIds = array_values(array_filter(array_map(static fn (array $message): ?string => $message['id'] ?? null, $remoteMessages))); + $localIds = array_values(array_filter(array_map(static fn (Message $message): ?string => $message->getRemoteId(), $localMessages))); + $deletedIds = array_values(array_diff($localIds, $remoteIds)); + if ($deletedIds !== []) { + $this->dbMessageMapper->deleteByRemoteIds($mailbox, ...$deletedIds); + } + + if ($insert !== []) { + $this->dbMessageMapper->insertBulk($account, ...$insert); + } + + $mailbox->setSyncNewToken($results['state'] ?? null); + $mailbox->setSyncChangedToken($results['state'] ?? null); + $mailbox->setSyncVanishedToken($results['state'] ?? null); + $mailbox->setMessages(count($remoteMessages)); + $mailbox->setUnseen($this->jmapMessageAdapter->countUnreadMessages($remoteMessages)); + $this->mailboxMapper->update($mailbox); + + $newMessageIds = array_map(static fn (Message $message): int => $message->getUid(), $insert); + $vanishedMessageUids = array_map( + static fn (Message $message): int => $message->getUid(), + array_filter($localMessages, static fn (Message $message): bool => in_array($message->getRemoteId(), $deletedIds, true)), + ); + + return new SyncResult( + newMessages: $newMessageIds, + changedMessages: [], + vanishedMessageUids: $vanishedMessageUids, + syncToken: $mailbox->getSyncChangedToken(), + stats: ['rebuildThreads' => true], + ); + } + + private function deltaSync(Account $account, Mailbox $mailbox): SyncResult { + $delta = $this->jmapOperationsService->entityDelta($mailbox->getRemoteId(), $mailbox->getSyncChangedToken() ?? ''); + $changedIds = array_values(array_unique(array_merge($delta['additions'] ?? [], $delta['modifications'] ?? []))); + $fetched = $changedIds === [] ? [] : ($this->jmapOperationsService->entityFetch(...$changedIds) ?? []); + + $existing = $this->dbMessageMapper->findByRemoteIds($mailbox, $changedIds); + $existingByRid = []; + foreach ($existing as $message) { + if ($message->getRemoteId() !== null) { + $existingByRid[$message->getRemoteId()] = $message; + } + } + + $nextUid = ($this->dbMessageMapper->findHighestUid($mailbox) ?? 0) + 1; + $insert = []; + $newMessageUids = []; + $changedMessageUids = []; + foreach ($fetched as $rid => $remoteMessage) { + if (!is_array($remoteMessage)) { + continue; + } + $existingMessage = $existingByRid[$rid] ?? null; + $uid = $existingMessage?->getUid() ?? $nextUid++; + if ($existingMessage !== null) { + $this->dbMessageMapper->deleteByRemoteIds($mailbox, $rid); + $changedMessageUids[] = $uid; + } else { + $newMessageUids[] = $uid; + } + $insert[] = $this->jmapMessageAdapter->convertToDbMessage($mailbox, $remoteMessage, $uid); + } + + if ($insert !== []) { + $this->dbMessageMapper->insertBulk($account, ...$insert); + } + + $deletedMessages = $this->dbMessageMapper->findByRemoteIds($mailbox, $delta['deletions'] ?? []); + $vanishedMessageUids = array_map(static fn (Message $message): int => $message->getUid(), $deletedMessages); + if (($delta['deletions'] ?? []) !== []) { + $this->dbMessageMapper->deleteByRemoteIds($mailbox, ...$delta['deletions']); + } + + $mailbox->setSyncChangedToken($delta['signature'] ?? $mailbox->getSyncChangedToken()); + $mailbox->setSyncVanishedToken($delta['signature'] ?? $mailbox->getSyncVanishedToken()); + $this->mailboxMapper->update($mailbox); + + return new SyncResult( + newMessages: $newMessageUids, + changedMessages: $changedMessageUids, + vanishedMessageUids: $vanishedMessageUids, + syncToken: $mailbox->getSyncChangedToken(), + stats: ['rebuildThreads' => true], + ); + } + + private function findLocalMessageByUid(Mailbox $mailbox, int $uid): Message { + $messages = $this->dbMessageMapper->findByUids($mailbox, [$uid]); + if ($messages === []) { + throw new ServiceException("Message $uid does not exist locally"); + } + + return $messages[0]; + } + + /** + * @param Message[] $messages + */ + private function findMessageByRid(array $messages, ?string $rid): ?Message { + if ($rid === null) { + return null; + } + + foreach ($messages as $message) { + if ($message->getRemoteId() === $rid) { + return $message; + } + } + + return null; + } + +} \ No newline at end of file diff --git a/lib/Listener/DeleteDraftListener.php b/lib/Listener/DeleteDraftListener.php index 0c41a21c44..2a40e9695c 100644 --- a/lib/Listener/DeleteDraftListener.php +++ b/lib/Listener/DeleteDraftListener.php @@ -19,8 +19,8 @@ use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\OutboxMessageCreatedEvent; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; @@ -31,8 +31,8 @@ * @template-implements IEventListener */ class DeleteDraftListener implements IEventListener { - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var MailboxMapper */ private $mailboxMapper; @@ -46,12 +46,12 @@ class DeleteDraftListener implements IEventListener { /** @var IEventDispatcher */ private $eventDispatcher; - public function __construct(IMAPClientFactory $imapClientFactory, + public function __construct(ProtocolFactory $protocolFactory, MailboxMapper $mailboxMapper, MessageMapper $messageMapper, LoggerInterface $logger, IEventDispatcher $eventDispatcher) { - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; $this->mailboxMapper = $mailboxMapper; $this->messageMapper = $messageMapper; $this->logger = $logger; @@ -70,7 +70,7 @@ public function handle(Event $event): void { * @param Message $draft */ private function deleteDraft(Account $account, Message $draft): void { - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $draftsMailbox = $this->getDraftsMailbox($account); } catch (DoesNotExistException $e) { diff --git a/lib/Listener/MoveJunkListener.php b/lib/Listener/MoveJunkListener.php index ddcb224219..41c72511c4 100644 --- a/lib/Listener/MoveJunkListener.php +++ b/lib/Listener/MoveJunkListener.php @@ -9,10 +9,10 @@ namespace OCA\Mail\Listener; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\MailManager; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; @@ -22,7 +22,7 @@ */ class MoveJunkListener implements IEventListener { public function __construct( - private IMailManager $mailManager, + private MailManager $mailManager, private LoggerInterface $logger, ) { } diff --git a/lib/Migration/Version5800Date20260401000001.php b/lib/Migration/Version5800Date20260401000001.php new file mode 100644 index 0000000000..eea8738d10 --- /dev/null +++ b/lib/Migration/Version5800Date20260401000001.php @@ -0,0 +1,53 @@ +getTable('mail_accounts'); + if (!$accountsTable->hasColumn('protocol')) { + $accountsTable->addColumn('protocol', Types::STRING, [ + 'length' => 16, + 'default' => 'imap', + 'notnull' => true, + ]); + } + if (!$accountsTable->hasColumn('path')) { + $accountsTable->addColumn('path', Types::STRING, [ + 'length' => 512, + 'notnull' => false, + 'default' => null, + ]); + } + return $schema; + } +} diff --git a/lib/Migration/Version5800Date20260401000002.php b/lib/Migration/Version5800Date20260401000002.php new file mode 100644 index 0000000000..e42f80fa07 --- /dev/null +++ b/lib/Migration/Version5800Date20260401000002.php @@ -0,0 +1,62 @@ +getTable('mail_mailboxes'); + + if (!$mailboxesTable->hasColumn('remote_parent_id')) { + $mailboxesTable->addColumn('remote_parent_id', Types::STRING, [ + 'length' => 255, + 'notnull' => false, + 'default' => null, + ]); + } + if (!$mailboxesTable->hasColumn('remote_id')) { + $mailboxesTable->addColumn('remote_id', Types::STRING, [ + 'length' => 255, + 'notnull' => false, + 'default' => null, + ]); + } + if (!$mailboxesTable->hasColumn('state')) { + $mailboxesTable->addColumn('state', Types::STRING, [ + 'length' => 64, + 'notnull' => false, + 'default' => null, + ]); + } + return $schema; + } +} diff --git a/lib/Migration/Version5800Date20260401000003.php b/lib/Migration/Version5800Date20260401000003.php new file mode 100644 index 0000000000..e01e21dcc7 --- /dev/null +++ b/lib/Migration/Version5800Date20260401000003.php @@ -0,0 +1,47 @@ +getTable('mail_messages'); + + if (!$messagesTable->hasColumn('remote_id')) { + $messagesTable->addColumn('remote_id', Types::STRING, [ + 'length' => 255, + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Protocol/ProtocolFactory.php b/lib/Protocol/ProtocolFactory.php new file mode 100644 index 0000000000..b0128ce2c8 --- /dev/null +++ b/lib/Protocol/ProtocolFactory.php @@ -0,0 +1,139 @@ + connector interface => class name + */ + private const CONNECTOR_MAP = [ + MailAccount::PROTOCOL_IMAP => [ + IMailboxConnector::class => ImapMailboxConnector::class, + IMessageConnector::class => ImapMessageConnector::class, + ITransmissionConnector::class => ImapTransmissionConnector::class, + ], + MailAccount::PROTOCOL_JMAP => [ + IMailboxConnector::class => JmapMailboxConnector::class, + IMessageConnector::class => JmapMessageConnector::class, + ], + ]; + + public function __construct( + private ContainerInterface $container, + private IMAPClientFactory $imapClientFactory, + private JmapClientFactory $jmapClientFactory, + ) { + } + + /** + * @throws ServiceException + */ + public function imapClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket { + $this->verifyProtocol($account, MailAccount::PROTOCOL_IMAP); + return $this->imapClientFactory->getClient($account, $useCache); + } + + /** + * @throws ServiceException + */ + public function jmapClient(Account $account): JmapClient { + $this->verifyProtocol($account, MailAccount::PROTOCOL_JMAP); + return $this->jmapClientFactory->getClient($account); + } + + /** + * @throws ServiceException + */ + public function testConnection(Account $account): void { + $protocol = $account->getMailAccount()->getProtocol(); + + if ($protocol === MailAccount::PROTOCOL_IMAP) { + $this->imapClient($account)->close(); + return; + } + + if ($protocol === MailAccount::PROTOCOL_JMAP) { + $client = $this->jmapClient($account); + if (!$client->sessionStatus()) { + $client->connect(); + } + return; + } + + throw new ServiceException("Unsupported protocol $protocol"); + } + + /** + * @throws ServiceException + */ + private function verifyProtocol(Account $account, string $expected): void { + $actual = $account->getMailAccount()->getProtocol(); + if ($actual !== $expected) { + throw new ServiceException("Expected protocol $expected but account uses $actual"); + } + } + + /** + * @throws ServiceException + */ + public function mailboxConnector(Account $account): IMailboxConnector { + return $this->resolveConnector($account, IMailboxConnector::class); + } + + /** + * @throws ServiceException + */ + public function messageConnector(Account $account): IMessageConnector { + return $this->resolveConnector($account, IMessageConnector::class); + } + + /** + * @throws ServiceException + */ + public function transmissionConnector(Account $account): ITransmissionConnector { + return $this->resolveConnector($account, ITransmissionConnector::class); + } + + /** + * @template T + * @param Account $account + * @param class-string $interface + * @return T + * @throws ServiceException + */ + private function resolveConnector(Account $account, string $interface): mixed { + $protocol = $account->getMailAccount()->getProtocol(); + $class = self::CONNECTOR_MAP[$protocol][$interface] ?? null; + + if ($class === null) { + throw new ServiceException("No $interface implementation for protocol $protocol"); + } + + return $this->container->get($class); + } +} diff --git a/lib/Protocol/SyncResult.php b/lib/Protocol/SyncResult.php new file mode 100644 index 0000000000..6c9b115e9d --- /dev/null +++ b/lib/Protocol/SyncResult.php @@ -0,0 +1,27 @@ + $stats + */ + public function __construct( + public readonly array $newMessages = [], + public readonly array $changedMessages = [], + public readonly array $vanishedMessageUids = [], + public readonly ?string $syncToken = null, + public readonly array $stats = [], + ) { + } +} diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php index a55aeebbee..43add7b222 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -11,7 +11,7 @@ use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\DB\Exception; @@ -24,7 +24,7 @@ public function __construct( private FlagRepliedMessageHandler $flagRepliedMessageHandler, private AttachmentService $attachmentService, private LocalMessageMapper $localMessageMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, ) { } @@ -49,7 +49,7 @@ public function process(Account $account, LocalMessage $localMessage): LocalMess throw new ServiceException('Could not send message because a previous send operation produced an unclear sent state.'); } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $result = $handlers->process($account, $localMessage, $client); } finally { diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index e54b99214f..6632f216d4 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -21,7 +21,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; @@ -46,21 +46,21 @@ class AccountService { /** @var IJobList */ private $jobList; - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; public function __construct( MailAccountMapper $mapper, AliasesService $aliasesService, IJobList $jobList, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, ) { $this->mapper = $mapper; $this->aliasesService = $aliasesService; $this->jobList = $jobList; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; } /** @@ -237,8 +237,7 @@ public function getAllAcounts(): array { public function testAccountConnection(string $currentUserId, int $accountId) :bool { $account = $this->find($currentUserId, $accountId); try { - $client = $this->imapClientFactory->getClient($account); - $client->close(); + $this->protocolFactory->testConnection($account); return true; } catch (\Throwable $e) { return false; diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index c97997c170..4548f84dc5 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -12,13 +12,13 @@ use JsonException; use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\EventData; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Service\MailManager; use OCP\IConfig; use OCP\IL10N; use OCP\IUserManager; @@ -51,7 +51,7 @@ public function __construct( private IConfig $config, private Cache $cache, private IMAPClientFactory $clientFactory, - private IMailManager $mailManager, + private MailManager $mailManager, private TaskProcessingManager $taskProcessingManager, private TextProcessingManager $textProcessingManager, private IL10N $l, @@ -76,51 +76,45 @@ public function summarizeMessages(Account $account, array $messages): void { } $user = $this->userManager->get($account->getUserId()); $language = explode('_', $this->l10nFactory->getUserLanguage($user))[0]; - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $entry) { - if (mb_strlen((string)$entry->getSummary()) !== 0) { - continue; - } - // retrieve full message from server - $userId = $account->getUserId(); - $mailboxId = $entry->getMailboxId(); - $messageLocalId = $entry->getId(); - $messageRemoteId = $entry->getUid(); - $mailbox = $this->mailManager->getMailbox($userId, $mailboxId); - $message = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $messageRemoteId, - true - ); - // skip message if it is encrypted or empty - if ($message->isEncrypted() || empty(trim($message->getPlainBody()))) { - continue; - } - // construct prompt and task - $messageBody = $message->getPlainBody(); - $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" - . 'The summary should be in the language of this language code ' . $language . ". \r\n" - . "The summary should be less than 160 characters. \r\n" - . "Output *ONLY* the summary itself, leave out any introduction. \r\n" - . "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" - . "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; - $task = new TaskProcessingTask( - TextToText::ID, - [ - 'max_tokens' => 1024, - 'input' => $prompt, - ], - Application::APP_ID, - $userId, - 'message:' . (string)$messageLocalId - ); - $this->taskProcessingManager->scheduleTask($task); + foreach ($messages as $entry) { + if (mb_strlen((string)$entry->getSummary()) !== 0) { + continue; + } + // retrieve full message from server + $userId = $account->getUserId(); + $mailboxId = $entry->getMailboxId(); + $messageLocalId = $entry->getId(); + $messageRemoteId = $entry->getUid(); + $mailbox = $this->mailManager->getMailbox($userId, $mailboxId); + $message = $this->mailManager->getImapMessage( + $account, + $mailbox, + $messageRemoteId, + true + ); + // skip message if it is encrypted or empty + if ($message->isEncrypted() || empty(trim($message->getPlainBody()))) { + continue; } - } finally { - $client->logout(); + // construct prompt and task + $messageBody = $message->getPlainBody(); + $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" + . 'The summary should be in the language of this language code ' . $language . ". \r\n" + . "The summary should be less than 160 characters. \r\n" + . "Output *ONLY* the summary itself, leave out any introduction. \r\n" + . "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" + . "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; + $task = new TaskProcessingTask( + TextToText::ID, + [ + 'max_tokens' => 1024, + 'input' => $prompt, + ], + Application::APP_ID, + $userId, + 'message:' . (string)$messageLocalId + ); + $this->taskProcessingManager->scheduleTask($task); } } @@ -141,22 +135,15 @@ public function summarizeThread(Account $account, string $threadId, array $messa if ($cachedSummary) { return $cachedSummary; } - $client = $this->clientFactory->getClient($account); - try { - $messagesBodies = array_map(function ($message) use ($client, $account, $currentUserId) { - $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - return $imapMessage->getPlainBody(); - }, $messages); - - } finally { - $client->logout(); - } + $messagesBodies = array_map(function ($message) use ($account, $currentUserId) { + $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), true + ); + return $imapMessage->getPlainBody(); + }, $messages); $taskPrompt = implode("\n", $messagesBodies); $summaryTask = new TextProcessingTask(SummaryTaskType::class, $taskPrompt, 'mail', $currentUserId, $threadId); @@ -178,21 +165,15 @@ public function generateEventData(Account $account, string $threadId, array $mes if (!in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { return null; } - $client = $this->clientFactory->getClient($account); - try { - $messageBodies = array_map(function ($message) use ($client, $account, $currentUserId) { - $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - return $imapMessage->getPlainBody(); - }, $messages); - } finally { - $client->logout(); - } + $messageBodies = array_map(function ($message) use ($account, $currentUserId) { + $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), true + ); + return $imapMessage->getPlainBody(); + }, $messages); $task = new TextProcessingTask( FreePromptTaskType::class, @@ -225,22 +206,15 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa throw new ServiceException('Failed to decode smart replies JSON output', previous: $e); } } - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - if (!$this->isPersonalEmail($imapMessage)) { - return []; - } - $messageBody = $imapMessage->getPlainBody(); - - } finally { - $client->logout(); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), true + ); + if (!$this->isPersonalEmail($imapMessage)) { + return []; } + $messageBody = $imapMessage->getPlainBody(); $prompt = "You are tasked with formulating helpful replies or reply templates to e-mails provided that have been sent to me. If you don't know some relevant information for answering the e-mails (like my schedule) leave blanks in the text that can later be filled by me. You must write the replies from my point of view as replies to the original sender of the provided e-mail! Formulate two extremely succinct reply suggestions to the provided ***E-MAIL***. Please, do not invent any context for the replies but, rather, leave blanks for me to fill in with relevant information where necessary. Provide the output formatted as valid JSON with the keys 'reply1' and 'reply2' for the reply suggestions. @@ -286,18 +260,12 @@ public function requiresFollowUp( throw new ServiceException('No language model available for smart replies'); } - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true, - ); - } finally { - $client->logout(); - } + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), + true, + ); if (!$this->isPersonalEmail($imapMessage)) { return false; @@ -351,18 +319,12 @@ public function requiresTranslation( return $cachedValue === 'true' ? true : false; } - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true, - ); - } finally { - $client->logout(); - } + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message->getUid(), + true, + ); if (!$this->isPersonalEmail($imapMessage)) { return false; diff --git a/lib/Service/AntiSpamService.php b/lib/Service/AntiSpamService.php index d991f036c9..7afb729c85 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -19,9 +19,9 @@ use OCA\Mail\Db\MessageMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\Message; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\DataUri\DataUriParser; use OCA\Mail\SMTP\SmtpClientFactory; use OCP\AppFramework\Db\DoesNotExistException; @@ -36,7 +36,7 @@ public function __construct( private IConfig $config, private MessageMapper $dbMessageMapper, private MailManager $mailManager, - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private SmtpClientFactory $smtpClientFactory, private ImapMessageMapper $messageMapper, private LoggerInterface $logger, @@ -119,7 +119,7 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $fullText = $this->messageMapper->getFullText( $client, @@ -195,7 +195,7 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st return; } - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $this->messageMapper->save( $client, diff --git a/lib/Service/Attachment/AttachmentService.php b/lib/Service/Attachment/AttachmentService.php index 2716101d3e..1153feb602 100644 --- a/lib/Service/Attachment/AttachmentService.php +++ b/lib/Service/Attachment/AttachmentService.php @@ -14,7 +14,6 @@ use OCA\Files_Sharing\SharedStorage; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; @@ -25,6 +24,7 @@ use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\File; @@ -43,7 +43,7 @@ class AttachmentService implements IAttachmentService { /** @var AttachmentStorage */ private $storage; /** - * @var IMailManager + * @var MailManager */ private $mailManager; /** @@ -67,7 +67,7 @@ public function __construct( $userFolder, LocalAttachmentMapper $mapper, AttachmentStorage $storage, - IMailManager $mailManager, + MailManager $mailManager, MessageMapper $imapMessageMapper, private ICacheFactory $cacheFactory, private IURLGenerator $urlGenerator, @@ -290,7 +290,6 @@ public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $ $attachments = []; try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, $message->getUid(), diff --git a/lib/Service/Classification/NewMessagesClassifier.php b/lib/Service/Classification/NewMessagesClassifier.php index 449157531f..6e893198ab 100644 --- a/lib/Service/Classification/NewMessagesClassifier.php +++ b/lib/Service/Classification/NewMessagesClassifier.php @@ -11,13 +11,13 @@ use Horde_Imap_Client; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\MailManager; use Psr\Log\LoggerInterface; class NewMessagesClassifier { @@ -33,7 +33,7 @@ public function __construct( private ImportanceClassifier $classifier, private TagMapper $tagMapper, private LoggerInterface $logger, - private IMailManager $mailManager, + private MailManager $mailManager, ) { } diff --git a/lib/Service/DkimService.php b/lib/Service/DkimService.php index f5172035b0..f3e8460557 100644 --- a/lib/Service/DkimService.php +++ b/lib/Service/DkimService.php @@ -14,8 +14,8 @@ use OCA\Mail\Contracts\IDkimValidator; use OCA\Mail\Db\Mailbox; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\ICache; use OCP\ICacheFactory; @@ -23,8 +23,8 @@ class DkimService implements IDkimService { private const CACHE_PREFIX = 'mail_dkim'; private const CACHE_TTL = 7 * 24 * 3600; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var MessageMapper */ private $messageMapper; @@ -35,12 +35,12 @@ class DkimService implements IDkimService { private IDkimValidator $dkimValidator; public function __construct( - IMAPClientFactory $clientFactory, + ProtocolFactory $protocolFactory, MessageMapper $messageMapper, ICacheFactory $cacheFactory, IDkimValidator $dkimValidator, ) { - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->messageMapper = $messageMapper; $this->cache = $cacheFactory->createLocal(self::CACHE_PREFIX); $this->dkimValidator = $dkimValidator; @@ -53,7 +53,7 @@ public function validate(Account $account, Mailbox $mailbox, int $id): bool { return $cached; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $fullText = $this->messageMapper->getFullText( $client, diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php index 284c741160..c2052ee364 100644 --- a/lib/Service/DraftsService.php +++ b/lib/Service/DraftsService.php @@ -10,7 +10,6 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; @@ -18,7 +17,7 @@ use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -31,8 +30,8 @@ class DraftsService { private LocalMessageMapper $mapper; private AttachmentService $attachmentService; private IEventDispatcher $eventDispatcher; - private IMAPClientFactory $clientFactory; - private IMailManager $mailManager; + private ProtocolFactory $protocolFactory; + private MailManager $mailManager; private LoggerInterface $logger; private AccountService $accountService; private ITimeFactory $time; @@ -41,8 +40,8 @@ public function __construct(IMailTransmission $transmission, LocalMessageMapper $mapper, AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - IMAPClientFactory $clientFactory, - IMailManager $mailManager, + ProtocolFactory $protocolFactory, + MailManager $mailManager, LoggerInterface $logger, AccountService $accountService, ITimeFactory $time) { @@ -50,7 +49,7 @@ public function __construct(IMailTransmission $transmission, $this->mapper = $mapper; $this->attachmentService = $attachmentService; $this->eventDispatcher = $eventDispatcher; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->mailManager = $mailManager; $this->logger = $logger; $this->accountService = $accountService; @@ -113,7 +112,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { @@ -146,7 +145,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { diff --git a/lib/Service/ItineraryService.php b/lib/Service/ItineraryService.php index 37c296bab8..3775c8c11d 100644 --- a/lib/Service/ItineraryService.php +++ b/lib/Service/ItineraryService.php @@ -12,9 +12,9 @@ use Nextcloud\KItinerary\Itinerary; use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Integration\KItinerary\ItineraryExtractor; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\ICache; use OCP\ICacheFactory; use Psr\Log\LoggerInterface; @@ -26,8 +26,8 @@ class ItineraryService { private const CACHE_PREFIX = 'mail_itinerary'; private const CACHE_TTL = 7 * 24 * 3600; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var MessageMapper */ private $messageMapper; @@ -41,12 +41,12 @@ class ItineraryService { /** @var LoggerInterface */ private $logger; - public function __construct(IMAPClientFactory $clientFactory, + public function __construct(ProtocolFactory $protocolFactory, MessageMapper $messageMapper, ItineraryExtractor $extractor, ICacheFactory $cacheFactory, LoggerInterface $logger) { - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->messageMapper = $messageMapper; $this->extractor = $extractor; $this->cache = $cacheFactory->createLocal(self::CACHE_PREFIX); @@ -70,7 +70,7 @@ public function extract(Account $account, Mailbox $mailbox, int $id): Itinerary return $cached; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $itinerary = new Itinerary(); $htmlBody = $this->messageMapper->getHtmlBody($client, $mailbox->getName(), $id, $account->getUserId()); diff --git a/lib/Service/JMAP/JmapOperationsService.php b/lib/Service/JMAP/JmapOperationsService.php new file mode 100644 index 0000000000..44a2e11895 --- /dev/null +++ b/lib/Service/JMAP/JmapOperationsService.php @@ -0,0 +1,844 @@ +dataStore = $this->jmapClientFactory->getClient($account); + // evaluate if client is connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($this->dataAccount === null) { + $this->dataAccount = $this->dataStore->sessionAccountDefault('mail')->id(); + } + + return true; + } + + /** + * list of collections in remote storage + * + * @param string|null $location optional location constraint for collection retrieval + * @param array|null $filter optional filter conditions for collection retrieval + * @param array|null $sort optional sort conditions for collection retrieval + * + * @return Mailbox[] + */ + public function collectionList(?string $location = null, ?array $filter = null, ?array $sort = null): array { + // construct request + $r0 = new MailboxQuery($this->dataAccount); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter 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 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); + // 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 collection objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $list[] = $this->jmapMailboxAdapter->convertToMailbox($so); + } + // return collection of collections + return $list; + } + + /** + * 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); + $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 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): ?Mailbox { + // construct request + $r0 = new MailboxGet($this->dataAccount); + $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 collection objects + $collection = $response->object(0); + if ($collection instanceof MailboxParametersResponse) { + return $this->jmapMailboxAdapter->convertToMailbox($collection); + } + return null; + } + + /** + * create collection in remote storage + * + * @since Release 1.0.0 + */ + public function collectionCreate(?Mailbox $location, Mailbox $mailbox): ?Mailbox { + // convert entity + $to = $this->jmapMailboxAdapter->convertFromMailbox($mailbox); + // define location + if (!empty($location)) { + $to->in($location->getRemoteId()); + } + $id = uniqid(); + // construct request + $r0 = new MailboxSet($this->dataAccount); + $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) { + $mailbox->setRemoteId($result['id']); + $mailbox->setNameHash(md5($result['id'])); + return $mailbox; + } + // 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, Mailbox $collection, array $patch = []): ?Mailbox { + // convert entity + $to = $this->jmapMailboxAdapter->convertFromMailbox($collection, $patch); + // construct request + $r0 = new MailboxSet($this->dataAccount); + $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 $collection; + } + // 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); + $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, ?array $filter = null, ?array $sort = null, ?array $range = null, ?string $granularity = null): array { + // construct request + $r0 = new MailQuery($this->dataAccount); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + foreach ($filter 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 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) { + $anchor = $range['anchor'] ?? null; + $position = $range['position'] ?? null; + $tally = $range['tally'] ?? null; + if ($anchor === 'absolute' && $position !== null && $tally !== null) { + $r0->limitAbsolute((int)$position, (int)$tally); + } + if ($anchor === 'relative' && $position !== null && $tally !== null) { + $r0->limitRelative((int)$position, (int)$tally); + } + } + // construct get request + $r1 = new MailGet($this->dataAccount); + // 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]; + } + + /** + * 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); + $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 array + */ + public function entityDelta(?string $location, string $state, string $granularity = 'D'): array { + + if (empty($state)) { + $results = $this->entityList($location, null, null, null, 'B'); + $delta = [ + 'signature' => $results['state'], + 'additions' => [], + 'modifications' => [], + 'deletions' => [], + ]; + 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'): array { + // construct set request + $r0 = new MailQueryChanges($this->dataAccount); + // 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 array + return [ + 'signature' => $response->stateNew(), + 'additions' => array_column($response->added(), 'id'), + 'modifications' => [], + 'deletions' => array_column($response->removed(), 'id'), + ]; + } + + /** + * delta of changes in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityDeltaDefault(string $state, string $granularity = 'D'): array { + // construct set request + $r0 = new MailChanges($this->dataAccount); + // 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 array + return [ + 'signature' => $response->stateNew(), + 'additions' => array_column($response->added(), 'id'), + 'modifications' => [], + 'deletions' => array_column($response->removed(), 'id'), + ]; + } + + /** + * retrieve entity from remote storage + * + * @since Release 1.0.0 + */ + public function entityFetch(string ...$identifiers): ?array { + // construct request + $r0 = new MailGet($this->dataAccount); + $r0->target(...$identifiers); + // select properties to return + $r0->property(...$this->defaultMailProperties); + $r0->bodyAll(true); + // transceive + $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); + $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); + $r0->update($id, $to); + // transceive + $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 entities from remote storage + * + * @since Release 1.0.0 + */ + public function entityDelete(string ...$identifiers): array { + // construct set request + $r0 = new MailSet($this->dataAccount); + foreach ($identifiers as $id) { + $r0->delete($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); + } + } + + $results = []; + // check for success + foreach ($response->deleteSuccesses() as $id) { + $results[$id] = true; + } + // check for failure + foreach ($response->deleteFailures() as $id => $data) { + $results[$id] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + /** + * copy entity in remote storage + * + * @since Release 1.0.0 + * + */ + public function entityCopy(string $target, string ...$identifiers): array { + return []; + } + + /** + * move entity in remote storage + * + * @since Release 1.0.0 + */ + public function entityMove(string $target, string ...$identifiers): array { + // construct request + $r0 = new MailSet($this->dataAccount); + foreach ($identifiers as $id) { + $r0->update($id)->in($target); + } + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->first(); + // 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); + } + } + + $results = []; + // check for success + foreach ($response->updateSuccesses() as $identifier => $data) { + $results[$identifier] = true; + } + // check for failure + foreach ($response->updateFailures() as $identifier => $data) { + $results[$identifier] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + /** + * 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); + $r0->create('1', $message)->in($presendLocation); + // construct set request + $r1 = new MailSubmissionSet($this->dataAccount); + // construct envelope + $e1 = $r1->create('2'); + $e1->identity($identity); + $e1->message('#1'); + $e1->from($from); + $e1->to($to); + // transceive + $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); + // 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/MailManager.php b/lib/Service/MailManager.php index c39a5ba306..c3ae584bd2 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -16,7 +16,6 @@ use Horde_Mime_Exception; use OCA\Mail\Account; use OCA\Mail\Attachment; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; @@ -25,45 +24,23 @@ use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; use OCA\Mail\Db\ThreadMapper; -use OCA\Mail\Events\BeforeMessageDeletedEvent; use OCA\Mail\Events\MessageDeletedEvent; -use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ImapFlagEncodingException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Exception\TrashMailboxNotSetException; use OCA\Mail\Folder; use OCA\Mail\IMAP\FolderMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\ImapFlag; -use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use function array_map; use function array_values; -class MailManager implements IMailManager { - /** - * https://datatracker.ietf.org/doc/html/rfc9051#name-flags-message-attribute - */ - private const SYSTEM_FLAGS = [ - 'seen' => [Horde_Imap_Client::FLAG_SEEN], - 'answered' => [Horde_Imap_Client::FLAG_ANSWERED], - 'flagged' => [Horde_Imap_Client::FLAG_FLAGGED], - 'deleted' => [Horde_Imap_Client::FLAG_DELETED], - 'draft' => [Horde_Imap_Client::FLAG_DRAFT], - 'recent' => [Horde_Imap_Client::FLAG_RECENT], - ]; - - /** @var IMAPClientFactory */ - private $imapClientFactory; - - /** @var MailboxSync */ - private $mailboxSync; - +class MailManager { /** @var MailboxMapper */ private $mailboxMapper; @@ -92,9 +69,7 @@ class MailManager implements IMailManager { private $threadMapper; public function __construct( - IMAPClientFactory $imapClientFactory, MailboxMapper $mailboxMapper, - MailboxSync $mailboxSync, FolderMapper $folderMapper, ImapMessageMapper $messageMapper, DbMessageMapper $dbMessageMapper, @@ -103,11 +78,10 @@ public function __construct( TagMapper $tagMapper, MessageTagsMapper $messageTagsMapper, ThreadMapper $threadMapper, + private ProtocolFactory $protocolFactory, private ImapFlag $imapFlag, ) { - $this->imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; - $this->mailboxSync = $mailboxSync; $this->folderMapper = $folderMapper; $this->imapMessageMapper = $messageMapper; $this->dbMessageMapper = $dbMessageMapper; @@ -136,53 +110,25 @@ public function getMailbox(string $uid, int $id): Mailbox { */ #[\Override] public function getMailboxes(Account $account, bool $forceSync = false): array { - $this->mailboxSync->sync($account, $this->logger, $forceSync); + $this->protocolFactory + ->mailboxConnector($account) + ->syncMailboxList($account, $this->logger, $forceSync); return $this->mailboxMapper->findAll($account); } #[\Override] public function createMailbox(Account $account, string $name, array $specialUse = []): Mailbox { - $client = $this->imapClientFactory->getClient($account); - try { - $folder = $this->folderMapper->createFolder($client, $name, $specialUse); - $this->folderMapper->fetchFolderAcls([$folder], $client); - $this->folderMapper->detectFolderSpecialUse([$folder]); - $this->mailboxSync->sync($account, $this->logger, true, $client); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not get mailbox status: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); - } - - return $this->mailboxMapper->find($account, $name); + return $this->protocolFactory + ->mailboxConnector($account) + ->createMailbox($account, $name, $specialUse); } #[\Override] - public function getImapMessage(Horde_Imap_Client_Socket $client, - Account $account, - Mailbox $mailbox, - int $uid, - bool $loadBody = false): IMAPMessage { - try { - return $this->imapMessageMapper->find( - $client, - $mailbox->getName(), - $uid, - $account->getUserId(), - $loadBody - ); - } catch (DoesNotExistException|Horde_Mime_Exception|Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not load message', - $e->getCode(), - $e - ); - } + public function getImapMessage(Account $account, Mailbox $mailbox, int $uid, bool $loadBody = false): IMAPMessage { + return $this->protocolFactory + ->messageConnector($account) + ->fetchMessage($account, $mailbox, $uid, $loadBody); } /** @@ -192,10 +138,8 @@ public function getImapMessage(Horde_Imap_Client_Socket $client, * @return IMAPMessage[] * @throws ServiceException */ - public function getImapMessagesForScheduleProcessing(Account $account, - Mailbox $mailbox, - array $uids): array { - $client = $this->imapClientFactory->getClient($account); + public function getImapMessagesForScheduleProcessing(Account $account, Mailbox $mailbox, array $uids): array { + $client = $this->protocolFactory->imapClient($account); try { return $this->imapMessageMapper->findByIds( $client, @@ -231,7 +175,6 @@ public function getMessage(string $uid, int $id): Message { } /** - * @param Horde_Imap_Client_Socket $client * @param Account $account * @param string $mailbox * @param int $uid @@ -241,19 +184,13 @@ public function getMessage(string $uid, int $id): Message { * @throws ServiceException */ #[\Override] - public function getSource(Horde_Imap_Client_Socket $client, - Account $account, - string $mailbox, - int $uid): ?string { + public function getSource(Account $account, string $mailbox, int $uid): ?string { try { - return $this->imapMessageMapper->getFullText( - $client, - $mailbox, - $uid, - $account->getUserId(), - false, - ); - } catch (Horde_Imap_Client_Exception|DoesNotExistException $e) { + $mailboxEntity = $this->mailboxMapper->find($account, $mailbox); + return $this->protocolFactory + ->messageConnector($account) + ->fetchMessageBody($account, $mailboxEntity, $uid); + } catch (DoesNotExistException $e) { throw new ServiceException('Could not load message', 0, $e); } } @@ -273,7 +210,8 @@ public function moveMessage(Account $sourceAccount, string $sourceFolderId, int $uid, Account $destinationAccount, - string $destFolderId): ?int { + string $destFolderId, + ): ?int { if ($sourceAccount->getId() === $destinationAccount->getId()) { try { $sourceMailbox = $this->mailboxMapper->find($sourceAccount, $sourceFolderId); @@ -281,12 +219,9 @@ public function moveMessage(Account $sourceAccount, throw new ServiceException("Source mailbox $sourceFolderId does not exist", 0, $e); } - $newUid = $this->moveMessageOnSameAccount( - $sourceAccount, - $sourceFolderId, - $destFolderId, - $uid - ); + $newUid = $this->protocolFactory + ->messageConnector($sourceAccount) + ->moveMessage($sourceAccount, $sourceFolderId, $uid, $destFolderId); // Delete cached source message (the source imap message is copied and deleted) $this->eventDispatcher->dispatch( @@ -306,131 +241,33 @@ public function moveMessage(Account $sourceAccount, * @todo evaluate if we should sync mailboxes first */ #[\Override] - public function deleteMessage(Account $account, - string $mailboxId, - int $messageUid): void { - try { - $sourceMailbox = $this->mailboxMapper->find($account, $mailboxId); - } catch (DoesNotExistException $e) { - throw new ServiceException("Source mailbox $mailboxId does not exist", 0, $e); - } - - $client = $this->imapClientFactory->getClient($account); - try { - $this->deleteMessageWithClient($account, $sourceMailbox, $messageUid, $client); - } finally { - $client->logout(); - } - } - - /** - * @throws ServiceException - * @throws ClientException - * @throws TrashMailboxNotSetException - * - * @todo evaluate if we should sync mailboxes first - */ - #[\Override] - public function deleteMessageWithClient( - Account $account, - Mailbox $mailbox, - int $messageUid, - Horde_Imap_Client_Socket $client, - ): void { - $this->eventDispatcher->dispatchTyped( - new BeforeMessageDeletedEvent($account, $mailbox->getName(), $messageUid) - ); - + public function deleteMessage(Account $account, string|Mailbox $mailbox, int $messageUid): void { try { - $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); - if ($trashMailboxId === null) { - throw new TrashMailboxNotSetException(); - } - $trashMailbox = $this->mailboxMapper->findById($trashMailboxId); + $sourceMailbox = is_string($mailbox) + ? $this->mailboxMapper->find($account, $mailbox) + : $mailbox; } catch (DoesNotExistException $e) { - throw new ServiceException('No trash folder', 0, $e); - } - - if ($mailbox->getName() === $trashMailbox->getName()) { - // Delete inside trash -> expunge - $this->imapMessageMapper->expunge( - $client, - $mailbox->getName(), - $messageUid - ); - } else { - $this->imapMessageMapper->move( - $client, - $mailbox->getName(), - $messageUid, - $trashMailbox->getName() - ); + $name = is_string($mailbox) ? $mailbox : (string)$mailbox->getId(); + throw new ServiceException("Source mailbox $name does not exist", 0, $e); } - $this->eventDispatcher->dispatchTyped( - new MessageDeletedEvent($account, $mailbox, $messageUid) - ); - } - - /** - * @param Account $account - * @param string $sourceFolderId - * @param string $destFolderId - * @param int $messageId - * - * @return ?int the new UID (or null if it couldn't be determined) - * @throws ServiceException - * - */ - private function moveMessageOnSameAccount(Account $account, - string $sourceFolderId, - string $destFolderId, - int $messageId): ?int { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->move($client, $sourceFolderId, $messageId, $destFolderId); - } finally { - $client->logout(); - } + $this->protocolFactory + ->messageConnector($account) + ->deleteMessage($account, $sourceMailbox, $messageUid); } #[\Override] public function markFolderAsRead(Account $account, Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - try { - $this->imapMessageMapper->markAllRead($client, $mailbox->getName()); - } finally { - $client->logout(); - } + $this->protocolFactory + ->messageConnector($account) + ->markAllRead($account, $mailbox); } #[\Override] public function updateSubscription(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { - /** - * 1. Change subscription on IMAP - */ - $client = $this->imapClientFactory->getClient($account); - try { - $client->subscribeMailbox($mailbox->getName(), $subscribed); - - /** - * 2. Pull changes into the mailbox database cache - */ - $this->mailboxSync->sync($account, $this->logger, true, $client); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not set subscription status for mailbox ' . $mailbox->getId() . ' on IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); - } - - /** - * 3. Return the updated object - */ - return $this->mailboxMapper->find($account, $mailbox->getName()); + return $this->protocolFactory + ->mailboxConnector($account) + ->subscribeMailbox($account, $mailbox, $subscribed); } #[\Override] @@ -449,40 +286,9 @@ public function flagMessage(Account $account, string $mailbox, int $uid, string throw new ClientException("Mailbox $mailbox does not exist", 0, $e); } - $client = $this->imapClientFactory->getClient($account); - try { - // Only send system flags to the IMAP server as other flags might not be supported - $imapFlags = $this->filterFlags($client, $account, $flag, $mailbox); - foreach ($imapFlags as $imapFlag) { - if (empty($imapFlag) === true) { - continue; - } - if ($value) { - $this->imapMessageMapper->addFlag($client, $mb, [$uid], $imapFlag); - } else { - $this->imapMessageMapper->removeFlag($client, $mb, [$uid], $imapFlag); - } - } - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not set message flag on IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); - } - - $this->eventDispatcher->dispatch( - MessageFlaggedEvent::class, - new MessageFlaggedEvent( - $account, - $mb, - $uid, - $flag, - $value - ) - ); + $this->protocolFactory + ->messageConnector($account) + ->flagMessage($account, $mb, $uid, $flag, $value); } /** @@ -494,7 +300,7 @@ public function flagMessage(Account $account, string $mailbox, int $uid, string * @throws ServiceException */ public function tagMessagesWithClient(Horde_Imap_Client_Socket $client, Account $account, Mailbox $mailbox, array $messages, Tag $tag, bool $value):void { - if ($this->isPermflagsEnabled($client, $account, $mailbox->getName()) === true) { + if ($this->isPermflagsEnabledWithClient($client, $mailbox->getName()) === true) { $messageIds = array_map(static fn (Message $message) => $message->getUid(), $messages); try { if ($value) { @@ -546,7 +352,7 @@ public function tagMessage(Account $account, string $mailbox, Message $message, } catch (DoesNotExistException $e) { throw new ClientException("Mailbox $mailbox does not exist", 0, $e); } - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $this->tagMessagesWithClient($client, $account, $mb, [$message], $tag, $value); } finally { @@ -562,75 +368,16 @@ public function tagMessage(Account $account, string $mailbox, Message $message, */ #[\Override] public function getQuota(Account $account): ?Quota { - /** - * Get all the quotas roots of the user's mailboxes - */ - $client = $this->imapClientFactory->getClient($account); - try { - $quotas = array_map(static fn (Folder $mb) => $client->getQuotaRoot($mb->getMailbox()), $this->folderMapper->getFolders($account, $client)); - } catch (Horde_Imap_Client_Exception_NoSupportExtension $ex) { - return null; - } finally { - $client->logout(); - } - - /** - * Extract the 'storage' quota - * - * Falls back to 0/0 if this quota has no storage information - * - * @see https://tools.ietf.org/html/rfc2087#section-3 - */ - $storageQuotas = array_map(static fn (array $root) => $root['storage'] ?? [ - 'usage' => 0, - 'limit' => 0, - ], array_merge(...array_values($quotas))); - - if ($storageQuotas === []) { - // Nothing left to do, and array_merge doesn't like to be called with zero arguments. - return null; - } - - /** - * Deduplicate identical quota roots - */ - $storage = array_merge(...array_values($storageQuotas)); - - return new Quota( - 1024 * (int)($storage['usage'] ?? 0), - 1024 * (int)($storage['limit'] ?? 0) - ); + return $this->protocolFactory + ->messageConnector($account) + ->getQuota($account); } #[\Override] public function renameMailbox(Account $account, Mailbox $mailbox, string $name): Mailbox { - /* - * 1. Rename on IMAP - */ - $client = $this->imapClientFactory->getClient($account); - try { - $this->folderMapper->renameFolder( - $client, - $mailbox->getName(), - $name - ); - - /** - * 2. Get the IMAP changes into our database cache - */ - $this->mailboxSync->sync($account, $this->logger, true, $client); - } finally { - $client->logout(); - } - - /** - * 3. Return the cached object with the new ID - */ - try { - return $this->mailboxMapper->find($account, $name); - } catch (DoesNotExistException $e) { - throw new ServiceException("The renamed mailbox $name does not exist", 0, $e); - } + return $this->protocolFactory + ->mailboxConnector($account) + ->renameMailbox($account, $mailbox, $name); } /** @@ -640,15 +387,10 @@ public function renameMailbox(Account $account, Mailbox $mailbox, string $name): * @throws ServiceException */ #[\Override] - public function deleteMailbox(Account $account, - Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - try { - $this->folderMapper->delete($client, $mailbox->getName()); - } finally { - $client->logout(); - } - $this->mailboxMapper->delete($mailbox); + public function deleteMailbox(Account $account, Mailbox $mailbox): void { + $this->protocolFactory + ->mailboxConnector($account) + ->deleteMailbox($account, $mailbox); } /** @@ -665,24 +407,9 @@ public function deleteMailbox(Account $account, #[\Override] public function clearMailbox(Account $account, Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); - $currentMailboxId = $mailbox->getId(); - try { - if (($currentMailboxId !== $trashMailboxId) && !is_null($trashMailboxId)) { - $trash = $this->mailboxMapper->findById($trashMailboxId); - $client->copy($mailbox->getName(), $trash->getName(), [ - 'move' => true - ]); - } else { - $client->expunge($mailbox->getName(), [ - 'delete' => true - ]); - } - $this->dbMessageMapper->deleteAll($mailbox); - } finally { - $client->logout(); - } + $this->protocolFactory + ->messageConnector($account) + ->clearMailbox($account, $mailbox); } /** @@ -693,17 +420,9 @@ public function clearMailbox(Account $account, */ #[\Override] public function getMailAttachments(Account $account, Mailbox $mailbox, Message $message): array { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->getAttachments( - $client, - $mailbox->getName(), - $message->getUid(), - $account->getUserId(), - ); - } finally { - $client->logout(); - } + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachments($account, $mailbox, $message->getUid()); } /** @@ -724,67 +443,9 @@ public function getMailAttachment(Account $account, Mailbox $mailbox, Message $message, string $attachmentId): Attachment { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->getAttachment( - $client, - $mailbox->getName(), - $message->getUid(), - $attachmentId, - $account->getUserId(), - ); - } finally { - $client->logout(); - } - } - - /** - * @param string $imapLabel - * @param string $userId - * @return Tag - * @throws ClientException - */ - #[\Override] - public function getTagByImapLabel(string $imapLabel, string $userId): Tag { - try { - return $this->tagMapper->getTagByImapLabel($imapLabel, $userId); - } catch (DoesNotExistException $e) { - throw new ClientException('Unknown Tag', 0, $e); - } - } - - /** - * Filter out IMAP flags that aren't supported by the client server - * - * @param string $flag - * @param string $mailbox - * @return array - */ - public function filterFlags(Horde_Imap_Client_Socket $client, Account $account, string $flag, string $mailbox): array { - // check if flag is RFC defined system flag - if (array_key_exists($flag, self::SYSTEM_FLAGS) === true) { - return self::SYSTEM_FLAGS[$flag]; - } - // check if server supports custom keywords / this specific keyword - try { - $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not get message flag options from IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } - // check if server returned supported flags - if (!isset($capabilities['permflags'])) { - return []; - } - // check if server supports custom flags or specific flag - if (in_array("\*", $capabilities['permflags']) || in_array($flag, $capabilities['permflags'])) { - return [$flag]; - } - - return []; + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachment($account, $mailbox, $message->getUid(), $attachmentId); } /** @@ -795,7 +456,13 @@ public function filterFlags(Horde_Imap_Client_Socket $client, Account $account, * @return boolean */ #[\Override] - public function isPermflagsEnabled(Horde_Imap_Client_Socket $client, Account $account, string $mailbox): bool { + public function isPermflagsEnabled(Account $account, string $mailbox): bool { + return $this->protocolFactory + ->messageConnector($account) + ->isPermflagsEnabled($account, $mailbox); + } + + private function isPermflagsEnabledWithClient(Horde_Imap_Client_Socket $client, string $mailbox): bool { try { $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); } catch (Horde_Imap_Client_Exception $e) { @@ -869,7 +536,7 @@ public function deleteTagForAccount(int $id, string $userId, Tag $tag, Account $ throw new ClientException('Messages not found', 0, $e); } - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); foreach ($messageTags as $messageTag) { $this->messageTagsMapper->delete($messageTag); diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index 5e8b549447..d910ae7396 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -30,7 +30,6 @@ use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; @@ -42,9 +41,9 @@ use OCA\Mail\Events\SaveDraftEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\DataUri\DataUriParser; use OCA\Mail\SMTP\SmtpClientFactory; use OCA\Mail\Support\PerformanceLogger; @@ -61,7 +60,7 @@ class MailTransmission implements IMailTransmission { ]; public function __construct( - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private SmtpClientFactory $smtpClientFactory, private IEventDispatcher $eventDispatcher, private MailboxMapper $mailboxMapper, @@ -70,7 +69,7 @@ public function __construct( private PerformanceLogger $performanceLogger, private AliasesService $aliasesService, private TransmissionService $transmissionService, - private IMailManager $mailManager, + private MailManager $mailManager, ) { } @@ -231,7 +230,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $perfLogger->step('build local draft message'); // 'Send' the message - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $transport = new Horde_Mail_Transport_Null(); $mail->send($transport, false, false); @@ -312,7 +311,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $perfLogger->step('build draft message'); // 'Send' the message - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $transport = new Horde_Mail_Transport_Null(); $mail->send($transport, false, false); @@ -385,7 +384,7 @@ public function sendMdn(Account $account, Mailbox $mailbox, Message $message): v 'peek' => true, ]); - $imapClient = $this->imapClientFactory->getClient($account); + $imapClient = $this->protocolFactory->imapClient($account); try { /** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */ $fetchResults = iterator_to_array($imapClient->fetch($mailbox->getName(), $query, [ diff --git a/lib/Service/OutboxService.php b/lib/Service/OutboxService.php index 0e81ae325a..4cd6522dbd 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -10,14 +10,13 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\Recipient; use OCA\Mail\Events\OutboxMessageCreatedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; @@ -39,10 +38,10 @@ class OutboxService { /** @var IEventDispatcher */ private $eventDispatcher; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; - /** @var IMailManager */ + /** @var MailManager */ private $mailManager; /** @var AccountService */ @@ -58,8 +57,8 @@ public function __construct( LocalMessageMapper $mapper, AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - IMAPClientFactory $clientFactory, - IMailManager $mailManager, + ProtocolFactory $protocolFactory, + MailManager $mailManager, AccountService $accountService, ITimeFactory $timeFactory, LoggerInterface $logger, @@ -68,7 +67,7 @@ public function __construct( $this->mapper = $mapper; $this->attachmentService = $attachmentService; $this->eventDispatcher = $eventDispatcher; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->mailManager = $mailManager; $this->timeFactory = $timeFactory; $this->logger = $logger; @@ -143,7 +142,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { @@ -175,7 +174,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index e55daa741a..700da17301 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -18,7 +18,7 @@ use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\CouldNotConnectException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\SMTP\SmtpClientFactory; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; @@ -34,8 +34,8 @@ class SetupService { /** @var SmtpClientFactory */ private $smtpClientFactory; - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var LoggerInterface */ private $logger; @@ -46,13 +46,13 @@ class SetupService { public function __construct(AccountService $accountService, ICrypto $crypto, SmtpClientFactory $smtpClientFactory, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, LoggerInterface $logger, TagMapper $tagMapper) { $this->accountService = $accountService; $this->crypto = $crypto; $this->smtpClientFactory = $smtpClientFactory; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; $this->logger = $logger; $this->tagMapper = $tagMapper; } @@ -127,7 +127,7 @@ public function createNewAccount(string $accountName, protected function testConnectivity(Account $account): void { $mailAccount = $account->getMailAccount(); - $imapClient = $this->imapClientFactory->getClient($account); + $imapClient = $this->protocolFactory->imapClient($account); try { $imapClient->login(); } catch (Horde_Imap_Client_Exception $e) { diff --git a/lib/Service/SnoozeService.php b/lib/Service/SnoozeService.php index ae45e8bcd8..297ca34f56 100644 --- a/lib/Service/SnoozeService.php +++ b/lib/Service/SnoozeService.php @@ -10,7 +10,6 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; @@ -37,7 +36,7 @@ public function __construct( private MessageSnoozeMapper $messageSnoozeMapper, private MailAccountMapper $accountMapper, private MailboxMapper $mailboxMapper, - private IMailManager $mailManager, + private MailManager $mailManager, private AccountService $accountService, ) { } @@ -302,42 +301,37 @@ private function wakeMessagesByAccount(Account $account): void { return; } - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $message) { - $srcMailboxId = $this->messageSnoozeMapper->getSrcMailboxId( - $message->getMailboxId(), - $message->getUid(), - ); - - $srcMailboxName = 'INBOX'; - - if ($srcMailboxId !== null) { - try { - $srcMailbox = $this->mailboxMapper->findById($srcMailboxId); - $srcMailboxName = $srcMailbox->getName(); - } catch (DoesNotExistException $e) { - // Could not find mailbox, moving back to INBOX - } + foreach ($messages as $message) { + $srcMailboxId = $this->messageSnoozeMapper->getSrcMailboxId( + $message->getMailboxId(), + $message->getUid(), + ); + + $srcMailboxName = 'INBOX'; + + if ($srcMailboxId !== null) { + try { + $srcMailbox = $this->mailboxMapper->findById($srcMailboxId); + $srcMailboxName = $srcMailbox->getName(); + } catch (DoesNotExistException $e) { + // Could not find mailbox, moving back to INBOX } + } - $this->mailManager->flagMessage($account, $snoozeMailbox->getName(), $message->getUid(), 'seen', false); + $this->mailManager->flagMessage($account, $snoozeMailbox->getName(), $message->getUid(), 'seen', false); - $this->mailManager->moveMessage( - $account, - $snoozeMailbox->getName(), - $message->getUid(), - $account, - $srcMailboxName - ); + $this->mailManager->moveMessage( + $account, + $snoozeMailbox->getName(), + $message->getUid(), + $account, + $srcMailboxName + ); - $this->messageSnoozeMapper->deleteByMailboxIdAndUid( - $message->getMailboxId(), - $message->getUid(), - ); - } - } finally { - $client->logout(); + $this->messageSnoozeMapper->deleteByMailboxIdAndUid( + $message->getMailboxId(), + $message->getUid(), + ); } } diff --git a/lib/Service/Sync/ImapToDbSynchronizer.php b/lib/Service/Sync/ImapToDbSynchronizer.php index 7874c8b4f4..a30a10b834 100644 --- a/lib/Service/Sync/ImapToDbSynchronizer.php +++ b/lib/Service/Sync/ImapToDbSynchronizer.php @@ -14,7 +14,6 @@ use Horde_Imap_Client_Exception; use Horde_Imap_Client_Ids; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper as DatabaseMessageMapper; @@ -29,12 +28,13 @@ use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\UidValidityChangedException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\IMAP\Sync\Request; use OCA\Mail\IMAP\Sync\Synchronizer; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Classification\NewMessagesClassifier; +use OCA\Mail\Service\MailManager; use OCA\Mail\Support\PerformanceLogger; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -53,8 +53,8 @@ class ImapToDbSynchronizer { /** @var DatabaseMessageMapper */ private $dbMapper; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var ImapMessageMapper */ private $imapMapper; @@ -74,14 +74,14 @@ class ImapToDbSynchronizer { /** @var LoggerInterface */ private $logger; - /** @var IMailManager */ + /** @var MailManager */ private $mailManager; private TagMapper $tagMapper; private NewMessagesClassifier $newMessagesClassifier; public function __construct(DatabaseMessageMapper $dbMapper, - IMAPClientFactory $clientFactory, + ProtocolFactory $protocolFactory, ImapMessageMapper $imapMapper, MailboxMapper $mailboxMapper, DatabaseMessageMapper $messageMapper, @@ -89,11 +89,11 @@ public function __construct(DatabaseMessageMapper $dbMapper, IEventDispatcher $dispatcher, PerformanceLogger $performanceLogger, LoggerInterface $logger, - IMailManager $mailManager, + MailManager $mailManager, TagMapper $tagMapper, NewMessagesClassifier $newMessagesClassifier) { $this->dbMapper = $dbMapper; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->imapMapper = $imapMapper; $this->mailboxMapper = $mailboxMapper; $this->synchronizer = $synchronizer; @@ -119,7 +119,7 @@ public function syncAccount(Account $account, $sentMailboxId = $account->getMailAccount()->getSentMailboxId(); $trashRetentionDays = $account->getMailAccount()->getTrashRetentionDays(); - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); foreach ($this->mailboxMapper->findAll($account) as $mailbox) { $syncTrash = $trashMailboxId === $mailbox->getId() && $trashRetentionDays !== null; @@ -337,7 +337,7 @@ private function runInitialSync( // Need a client without a cache $client->logout(); - $client = $this->clientFactory->getClient($account, false); + $client = $this->protocolFactory->imapClient($account, false); $highestKnownUid = $this->dbMapper->findHighestUid($mailbox); try { @@ -380,7 +380,7 @@ private function runInitialSync( // Horde's "no MODSEQ" fallback that resolves ALL UIDs, causing OOM on // large mailboxes. $client->logout(); - $cacheClient = $this->clientFactory->getClient($account); + $cacheClient = $this->protocolFactory->imapClient($account); try { $syncToken = $cacheClient->getSyncToken($mailbox->getName()); } finally { @@ -502,7 +502,7 @@ private function runPartialSync( ); $perf->step('get changed messages via Horde'); - $permflagsEnabled = $this->mailManager->isPermflagsEnabled($client, $account, $mailbox->getName()); + $permflagsEnabled = $this->mailManager->isPermflagsEnabled($account, $mailbox->getName()); foreach (array_chunk($response->getChangedMessages(), 500) as $chunk) { $this->dbMapper->updateBulk($account, $permflagsEnabled, ...array_map(static fn (IMAPMessage $imapMessage) => $imapMessage->toDbMessage($mailbox->getId(), $account->getMailAccount()), $chunk)); @@ -572,7 +572,7 @@ public function repairSync( ); // Need to use a client without a cache here (to disable QRESYNC entirely) - $client = $this->clientFactory->getClient($account, false); + $client = $this->protocolFactory->imapClient($account, false); try { $knownUids = $this->dbMapper->findAllUids($mailbox); $hordeMailbox = new \Horde_Imap_Client_Mailbox($mailbox->getName()); diff --git a/lib/Service/Sync/SyncService.php b/lib/Service/Sync/SyncService.php index 0cff18572e..0312978aa6 100644 --- a/lib/Service/Sync/SyncService.php +++ b/lib/Service/Sync/SyncService.php @@ -18,10 +18,10 @@ use OCA\Mail\Exception\MailboxLockedException; use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\PreviewEnhancer; use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Search\FilterStringParser; use OCA\Mail\Service\Search\SearchQuery; use Psr\Log\LoggerInterface; @@ -30,7 +30,7 @@ class SyncService { - private IMAPClientFactory $clientFactory; + private ProtocolFactory $protocolFactory; /** @var ImapToDbSynchronizer */ private $synchronizer; @@ -51,14 +51,14 @@ class SyncService { private $mailboxSync; public function __construct( - IMAPClientFactory $clientFactory, + ProtocolFactory $protocolFactory, ImapToDbSynchronizer $synchronizer, FilterStringParser $filterStringParser, MessageMapper $messageMapper, PreviewEnhancer $previewEnhancer, LoggerInterface $logger, MailboxSync $mailboxSync) { - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->synchronizer = $synchronizer; $this->filterStringParser = $filterStringParser; $this->messageMapper = $messageMapper; @@ -74,9 +74,8 @@ public function __construct( * @throws MailboxLockedException * @throws ServiceException */ - public function clearCache(Account $account, - Mailbox $mailbox): void { - $this->synchronizer->clearCache($account, $mailbox); + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->protocolFactory->messageConnector($account)->clearCache($account, $mailbox); } /** @@ -86,7 +85,7 @@ public function clearCache(Account $account, * @throws ServiceException */ public function repairSync(Account $account, Mailbox $mailbox): void { - $this->synchronizer->repairSync($account, $mailbox, $this->logger); + $this->protocolFactory->messageConnector($account)->repairSync($account, $mailbox, $this->logger); } /** @@ -110,26 +109,26 @@ public function syncMailbox(Account $account, ?int $lastMessageTimestamp, ?array $knownIds = null, string $sortOrder = IMailSearch::ORDER_NEWEST_FIRST, - ?string $filter = null): Response { + ?string $filter = null + ): Response { if ($partialOnly && !$mailbox->isCached()) { throw MailboxNotCachedException::from($mailbox); } - $client = $this->clientFactory->getClient($account); - - $this->synchronizer->sync( - $account, - $client, - $mailbox, - $this->logger, - $criteria, - $knownIds === null ? null : $this->messageMapper->findUidsForIds($mailbox, $knownIds), - !$partialOnly - ); - - $this->mailboxSync->syncStats($client, $mailbox); - - $client->logout(); + $this->protocolFactory + ->mailboxConnector($account) + ->syncMailboxStats($account, $mailbox); + + $this->protocolFactory + ->messageConnector($account) + ->syncMailbox( + $account, + $mailbox, + $this->logger, + $criteria, + $knownIds === null ? null : $this->messageMapper->findUidsForIds($mailbox, $knownIds), + !$partialOnly, + ); $query = $filter === null ? null : $this->filterStringParser->parse($filter); return $this->getDatabaseSyncChanges( diff --git a/lib/SetupChecks/MailConnectionPerformance.php b/lib/SetupChecks/MailConnectionPerformance.php index 09591a2704..2e59fc3399 100644 --- a/lib/SetupChecks/MailConnectionPerformance.php +++ b/lib/SetupChecks/MailConnectionPerformance.php @@ -13,7 +13,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\IL10N; use OCP\SetupCheck\ISetupCheck; use OCP\SetupCheck\SetupResult; @@ -26,7 +26,7 @@ public function __construct( private LoggerInterface $logger, private ProvisioningMapper $provisioningMapper, private MailAccountMapper $accountMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, private MicroTime $microtime, ) { } @@ -59,7 +59,7 @@ public function run(): SetupResult { foreach ($collection as $accountId) { $account = new Account($this->accountMapper->findById((int)$accountId)); try { - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); } catch (ServiceException $e) { $this->logger->warning('Error occurred while getting IMAP client for setup check: ' . $e->getMessage(), [ 'exception' => $e, diff --git a/package-lock.json b/package-lock.json index 341a693242..36c0e9f3bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextcloud-mail", - "version": "5.8.0-dev.1", + "version": "5.8.0-dev.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nextcloud-mail", - "version": "5.8.0-dev.1", + "version": "5.8.0-dev.3", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index e161992106..aaf75feceb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextcloud-mail", - "version": "5.8.0-dev.1", + "version": "5.8.0-dev.3", "private": true, "description": "Nextcloud Mail", "license": "AGPL-3.0-only", diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php index 0d2efc5bae..b290978744 100644 --- a/tests/Integration/Db/MailAccountTest.php +++ b/tests/Integration/Db/MailAccountTest.php @@ -74,6 +74,8 @@ public function testToAPI() { 'classificationEnabled' => true, 'authMethod' => 'password', 'imipCreate' => false, + 'protocol' => 'imap', + 'path' => null, ], $a->toJson()); } @@ -115,6 +117,8 @@ public function testMailAccountConstruct() { 'classificationEnabled' => true, 'authMethod' => 'password', 'imipCreate' => false, + 'protocol' => 'imap', + 'path' => null, ]; $a = new MailAccount($expected); // TODO: fix inconsistency diff --git a/tests/Integration/Framework/JmapTestAccount.php b/tests/Integration/Framework/JmapTestAccount.php new file mode 100644 index 0000000000..5613431ae5 --- /dev/null +++ b/tests/Integration/Framework/JmapTestAccount.php @@ -0,0 +1,44 @@ +setUserId($userId ?? $this->getTestAccountUserId()); + $mailAccount->setName('Tester'); + $mailAccount->setEmail('user@example.com'); + $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP); + $mailAccount->setInboundHost('127.0.0.1'); + $mailAccount->setInboundPort(10080); + $mailAccount->setInboundSslMode('none'); + $mailAccount->setInboundUser('user@example.com'); + $mailAccount->setInboundPassword(Server::get(ICrypto::class)->encrypt('mypassword')); + + $saved = $accountService->save($mailAccount); + + return $saved; + } +} diff --git a/tests/Integration/Protocol/ProtocolFactoryImapTest.php b/tests/Integration/Protocol/ProtocolFactoryImapTest.php new file mode 100644 index 0000000000..1223fea706 --- /dev/null +++ b/tests/Integration/Protocol/ProtocolFactoryImapTest.php @@ -0,0 +1,39 @@ +protocolFactory = Server::get(ProtocolFactory::class); + } + + public function testImapClientConnection(): void { + $account = new Account($this->createTestAccount()); + + $client = $this->protocolFactory->imapClient($account); + + $this->assertInstanceOf(Horde_Imap_Client_Socket::class, $client); + $client->login(); + $client->logout(); + } +} diff --git a/tests/Integration/Protocol/ProtocolFactoryJmapTest.php b/tests/Integration/Protocol/ProtocolFactoryJmapTest.php new file mode 100644 index 0000000000..ebabd1e8b3 --- /dev/null +++ b/tests/Integration/Protocol/ProtocolFactoryJmapTest.php @@ -0,0 +1,43 @@ +protocolFactory = Server::get(ProtocolFactory::class); + } + + public function testJmapClientConnection(): void { + $account = new Account($this->createTestAccount()); + + $client = $this->protocolFactory->jmapClient($account); + + $this->assertInstanceOf(JmapClient::class, $client); + + $session = $client->connect(); + + $this->assertTrue($client->sessionStatus(), 'JMAP session should be established'); + $this->assertNotEmpty($session->username(), 'Session should report a username'); + $this->assertNotEmpty($session->commandUrl(), 'Session should provide an API URL'); + } +} diff --git a/tests/Integration/Protocol/ProtocolFactoryTest.php b/tests/Integration/Protocol/ProtocolFactoryTest.php new file mode 100644 index 0000000000..fe31916457 --- /dev/null +++ b/tests/Integration/Protocol/ProtocolFactoryTest.php @@ -0,0 +1,55 @@ +protocolFactory = Server::get(ProtocolFactory::class); + } + + public function testImapClientConnection(): void { + $account = $this->createTestAccount(); + + $client = $this->protocolFactory->imapClient($account); + + $this->assertInstanceOf(Horde_Imap_Client_Socket::class, $client); + $client->login(); + $client->logout(); + } + + public function testJmapClientConnection(): void { + $account = $this->createTestAccount(); + + $client = $this->protocolFactory->jmapClient($account); + + $this->assertInstanceOf(JmapClient::class, $client); + + $session = $client->connect(); + + $this->assertTrue($client->sessionStatus(), 'JMAP session should be established'); + $this->assertNotEmpty($session->username(), 'Session should report a username'); + $this->assertNotEmpty($session->commandUrl(), 'Session should provide an API URL'); + } +} diff --git a/tests/Unit/Command/CreateAccountTest.php b/tests/Unit/Command/CreateImapAccountTest.php similarity index 84% rename from tests/Unit/Command/CreateAccountTest.php rename to tests/Unit/Command/CreateImapAccountTest.php index 9f523c8d83..4f59b775f5 100644 --- a/tests/Unit/Command/CreateAccountTest.php +++ b/tests/Unit/Command/CreateImapAccountTest.php @@ -11,13 +11,13 @@ namespace OCA\Mail\Tests\Unit\Command; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Command\CreateAccount; +use OCA\Mail\Command\CreateImapAccount; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCP\IUserManager; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class CreateAccountTest extends TestCase { +class CreateImapAccountTest extends TestCase { private $service; private $crypto; private $userManager; @@ -50,15 +50,19 @@ protected function setUp(): void { $this->userManager = $this->createMock(IUserManager::class); $this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class); - $this->command = new CreateAccount($this->service, $this->crypto, $this->userManager, $this->classificationSettingsService); + $this->command = new CreateImapAccount($this->service, $this->crypto, $this->userManager, $this->classificationSettingsService); } public function testName() { - $this->assertSame('mail:account:create', $this->command->getName()); + $this->assertSame('mail:account:create-imap', $this->command->getName()); + } + + public function testAlias() { + $this->assertSame(['mail:account:create'], $this->command->getAliases()); } public function testDescription() { - $this->assertSame('creates IMAP account', $this->command->getDescription()); + $this->assertSame('creates an IMAP mail account', $this->command->getDescription()); } public function testArguments() { diff --git a/tests/Unit/Command/CreateJmapAccountTest.php b/tests/Unit/Command/CreateJmapAccountTest.php new file mode 100644 index 0000000000..aa1012a97d --- /dev/null +++ b/tests/Unit/Command/CreateJmapAccountTest.php @@ -0,0 +1,166 @@ +service = $this->getMockBuilder(AccountService::class) + ->disableOriginalConstructor() + ->getMock(); + $this->crypto = $this->createMock(ICrypto::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class); + + $this->command = new CreateJmapAccount( + $this->service, + $this->crypto, + $this->userManager, + $this->classificationSettingsService, + ); + } + + public function testName(): void { + $this->assertSame('mail:account:create-jmap', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('creates a JMAP mail account', $this->command->getDescription()); + } + + public function testArguments(): void { + $actual = $this->command->getDefinition()->getArguments(); + + foreach ($actual as $actArg) { + if ($actArg->getName() === 'path') { + self::assertFalse($actArg->isRequired()); + } else { + self::assertTrue($actArg->isRequired()); + } + self::assertTrue(in_array($actArg->getName(), $this->args, true)); + } + } + + public function testInvalidUserId(): void { + $userId = 'invalidUser'; + $data = [ + 'user-id' => $userId, + 'name' => '', + 'email' => '', + 'host' => '', + 'port' => 0, + 'ssl-mode' => '', + 'basic-auth-user' => '', + 'basic-auth-password' => '', + 'path' => null, + ]; + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnCallback(fn (string $arg) => $data[$arg] ?? null); + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with("User $userId does not exist"); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($userId) + ->willReturn(false); + + $this->assertEquals(1, $this->command->run($input, $output)); + } + + public function testExecuteCreatesJmapAccount(): void { + $data = [ + 'user-id' => 'user-id', + 'name' => 'Personal', + 'email' => 'user@example.com', + 'host' => 'mail.example.com', + 'port' => '443', + 'ssl-mode' => 'ssl', + 'basic-auth-user' => 'jmap-user', + 'basic-auth-password' => 'jmap-password', + 'path' => '/.well-known/jmap', + ]; + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnCallback(fn (string $arg) => $data[$arg] ?? null); + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with('JMAP account 42 for user@example.com created'); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with('user-id') + ->willReturn(true); + + $this->crypto->expects($this->once()) + ->method('encrypt') + ->with('jmap-password') + ->willReturn('encrypted-password'); + + $this->classificationSettingsService->expects($this->once()) + ->method('isClassificationEnabledByDefault') + ->willReturn(true); + + $this->service->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account): MailAccount { + self::assertSame('user-id', $account->getUserId()); + self::assertSame('Personal', $account->getName()); + self::assertSame('user@example.com', $account->getEmail()); + self::assertSame(MailAccount::PROTOCOL_JMAP, $account->getProtocol()); + self::assertSame('mail.example.com', $account->getInboundHost()); + self::assertSame(443, $account->getInboundPort()); + self::assertSame('ssl', $account->getInboundSslMode()); + self::assertSame('jmap-user', $account->getInboundUser()); + self::assertSame('encrypted-password', $account->getInboundPassword()); + self::assertSame('/.well-known/jmap', $account->getPath()); + self::assertTrue($account->getClassificationEnabled()); + + $account->setId(42); + return $account; + }); + + $this->assertEquals(0, $this->command->run($input, $output)); + } +}