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..9493cb55ab 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", @@ -3902,6 +4504,7 @@ "minimum-stability": "stable", "stability-flags": { "gravatarphp/gravatar": 20, + "sebastiankrupinski/jmap-client-php": 20, "roave/security-advisories": 20 }, "prefer-stable": false, @@ -3910,9 +4513,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/Command/CreateAccount.php b/lib/Command/CreateImapAccount.php similarity index 95% rename from lib/Command/CreateAccount.php rename to lib/Command/CreateImapAccount.php index af85924309..115b77d77d 100644 --- a/lib/Command/CreateAccount.php +++ b/lib/Command/CreateImapAccount.php @@ -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/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/IMailboxConnector.php b/lib/Contracts/IMailboxConnector.php new file mode 100644 index 0000000000..f2554af882 --- /dev/null +++ b/lib/Contracts/IMailboxConnector.php @@ -0,0 +1,49 @@ +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/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/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/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..e1a97b1613 --- /dev/null +++ b/lib/Migration/Version5800Date20260401000002.php @@ -0,0 +1,61 @@ +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..b226f4bb0e --- /dev/null +++ b/lib/Protocol/ProtocolFactory.php @@ -0,0 +1,113 @@ + 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, + // ITransmissionConnector::class => JmapTransmissionConnector::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 + */ + 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/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/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)); + } +}