diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c41049d105..570e95457f 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: @@ -100,7 +101,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@sha256:b76a062268d7342ccec549f5323a0b6dc1b6ac55b03367f474934927653f5cb5 env: MAILNAME: mail.domain.tld @@ -111,6 +112,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.8:latest@sha256:9232089007ddc49b42e5a7e4cc158ac373efebed6885ea0f76e3d4e5f8532bac env: @@ -148,6 +158,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: @@ -180,6 +200,7 @@ jobs: run: | php -f nextcloud/occ config:system:set app.mail.debug --type bool --value true php -f nextcloud/occ config:system:set app.mail.verify-tls-peer --type bool --value false + php -f nextcloud/occ config:system:set allow_local_remote_servers --type bool --value true - name: Enable slow mysql query logs if: ${{ matrix.db == 'mysql' }} run: | diff --git a/appinfo/info.xml b/appinfo/info.xml index 0fdb533cd0..5c520d764e 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.10.0-rc.1 + 5.11.0-dev.1 agpl Christoph Wurst GretaD @@ -79,18 +79,20 @@ 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 - OCA\Mail\Command\UpdateAccount + OCA\Mail\Command\UpdateImapAccount + OCA\Mail\Command\UpdateJmapAccount OCA\Mail\Command\UpdateSystemAutoresponders OCA\Mail\Command\RunMetaEstimator diff --git a/composer.json b/composer.json index 1fe2a5557e..10597c3163 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "sort-packages": true, "allow-plugins": { "bamarni/composer-bin-plugin": true, - "cweagans/composer-patches": true + "cweagans/composer-patches": true, + "php-http/discovery": true }, "optimize-autoloader": true, "autoloader-suffix": "Mail" @@ -40,9 +41,9 @@ "nextcloud/kitinerary-flatpak": "^1.0", "nextcloud/kitinerary-sys": "^2.0.0", "phpmailer/dkimvalidator": "^0.3.1", - "psr/log": "^3.0.2", "rubix/ml": "2.5.3", "sabberworm/php-css-parser": "^9.4.0", + "sebastiankrupinski/jmap-client-php": "^2.0.0", "wamania/php-stemmer": "4.0 as 3.0", "youthweb/urllinker": "^2.1.0" }, diff --git a/composer.lock b/composer.lock index 345bc4b54c..61f7751ba2 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": "0b7206fc1061851cf7de747579bbf413", + "content-hash": "e8c6b7d4f4a52f473d50141c85ed0e6c", "packages": [ { "name": "amphp/amp", @@ -2155,12 +2155,12 @@ "version": "v1.0.1", "source": { "type": "git", - "url": "https://github.com/nextcloud/horde-managesieve.git", + "url": "https://github.com/nextcloud-libraries/horde-managesieve.git", "reference": "722bbe9de54022e7102161ab1f34d8bb8b8ae9c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud/horde-managesieve/zipball/722bbe9de54022e7102161ab1f34d8bb8b8ae9c7", + "url": "https://api.github.com/repos/nextcloud-libraries/horde-managesieve/zipball/722bbe9de54022e7102161ab1f34d8bb8b8ae9c7", "reference": "722bbe9de54022e7102161ab1f34d8bb8b8ae9c7", "shasum": "" }, @@ -2422,6 +2422,85 @@ }, "time": "2025-01-08T14:40:01+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpmailer/dkimvalidator", "version": "v0.3.1", @@ -2479,6 +2558,166 @@ }, "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": "rubix/ml", "version": "2.5.3", @@ -2821,7 +3060,70 @@ "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" }, - "time": "2026-06-18T15:10:53+00:00" + "time": "2026-03-03T17:31:43+00:00" + }, + { + "name": "sebastiankrupinski/jmap-client-php", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/SebastianKrupinski/jmap-client-php.git", + "reference": "720ba6ee0579202e5fedab39847680cc5c7ebf58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/720ba6ee0579202e5fedab39847680cc5c7ebf58", + "reference": "720ba6ee0579202e5fedab39847680cc5c7ebf58", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "php-http/discovery": "^1.19", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.89", + "guzzlehttp/guzzle": "^7.0", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "JmapClient\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-only" + ], + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com", + "homepage": "https://github.com/SebastianKrupinski/", + "role": "Just another Minion in the cogs of time" + } + ], + "description": "Type-safe PHP client for JMAP mail, calendar, contacts, files, and tasks APIs", + "homepage": "https://github.com/SebastianKrupinski/jmap-client-php", + "keywords": [ + "API-Client", + "Tasks", + "calendar", + "contacts", + "email", + "files", + "jmap", + "mail" + ], + "support": { + "issues": "https://github.com/SebastianKrupinski/jmap-client-php/issues", + "source": "https://github.com/SebastianKrupinski/jmap-client-php" + }, + "time": "2026-06-27T23:55:59+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4071,5 +4373,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.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 aa515ca448..d2d26d52d8 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'; @@ -53,8 +53,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); @@ -94,7 +95,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(); @@ -120,6 +121,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..cac98ea387 --- /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 (yes/no)'); + $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 3e8570cac2..0000000000 --- a/lib/Command/DiagnoseAccount.php +++ /dev/null @@ -1,120 +0,0 @@ -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); - $nMailboxes = count($mailboxes); - $output->writeln("Account has $messages messages in $nMailboxes mailboxes"); - } -} diff --git a/lib/Command/ExportAccount.php b/lib/Command/ExportAccount.php index 7d11cdbe77..72746f3628 100644 --- a/lib/Command/ExportAccount.php +++ b/lib/Command/ExportAccount.php @@ -7,6 +7,7 @@ namespace OCA\Mail\Command; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Service\AccountService; use OCP\Security\ICrypto; use Symfony\Component\Console\Command\Command; @@ -33,41 +34,11 @@ public function __construct( */ protected function configure() { $this->setName('mail:account:export'); - $this->setDescription('Exports a user\'s IMAP account(s)'); + $this->setDescription('Exports a user\'s mail account(s)'); $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED); $this->addOption(self::ARGUMENT_OUTPUT_FORMAT, '', InputOption::VALUE_OPTIONAL); } - private function getAccountsData($accounts) { - $accountsData = []; - - foreach ($accounts as $account) { - $accountsData[] = [ - 'id' => $account->getId(), - 'email' => $account->getEmail(), - 'name' => $account->getName(), - 'provision' => [ - 'status' => $account->getMailAccount()->getProvisioningId() ? 'set' : 'none', - 'id' => $account->getMailAccount()->getProvisioningId() ?: 'N/A' - ], - 'imap' => [ - 'user' => $account->getMailAccount()->getInboundUser(), - 'host' => $account->getMailAccount()->getInboundHost(), - 'port' => $account->getMailAccount()->getInboundPort(), - 'security' => $account->getMailAccount()->getInboundSslMode() - ], - 'smtp' => [ - 'user' => $account->getMailAccount()->getOutboundUser(), - 'host' => $account->getMailAccount()->getOutboundHost(), - 'port' => $account->getMailAccount()->getOutboundPort(), - 'security' => $account->getMailAccount()->getOutboundSslMode() - ] - ]; - } - - return $accountsData; - } - protected function execute(InputInterface $input, OutputInterface $output): int { $userId = $input->getArgument(self::ARGUMENT_USER_ID); @@ -83,16 +54,102 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln("Account $accountId:"); $output->writeln('- E-Mail: ' . $account->getEmail()); $output->writeln('- Name: ' . $account->getName()); - $output->writeln('- Provision: ' . ($account->getMailAccount()->getProvisioningId() ? 'set' : 'none') . ' ID: ' . ($account->getMailAccount()->getProvisioningId() ?: 'N/A')); - $output->writeln('- IMAP user: ' . $account->getMailAccount()->getInboundUser()); - $inboundPort = $account->getMailAccount()->getInboundPort(); - $output->writeln('- IMAP host: ' . $account->getMailAccount()->getInboundHost() . ":$inboundPort, security: " . $account->getMailAccount()->getInboundSslMode()); - $output->writeln('- SMTP user: ' . $account->getMailAccount()->getOutboundUser()); - $outboundPort = $account->getMailAccount()->getOutboundPort(); - $output->writeln('- SMTP host: ' . $account->getMailAccount()->getOutboundHost() . ":$outboundPort, security: " . $account->getMailAccount()->getOutboundSslMode()); + $provision = $this->getProvisionData($account); + $output->writeln('- Provision: ' . $provision['status'] . ' ID: ' . $provision['id']); + $this->writeProtocolDetails($account, $output); } } return 0; } + + private function getProvisionData($account): array { + $provisioningId = $account->getMailAccount()->getProvisioningId(); + + return [ + 'status' => $provisioningId ? 'set' : 'none', + 'id' => $provisioningId ?: 'N/A', + ]; + } + + private function getProtocolData($account): array { + $mailAccount = $account->getMailAccount(); + $protocol = $mailAccount->getProtocol(); + + return match ($protocol) { + MailAccount::PROTOCOL_JMAP => [ + 'jmap' => [ + 'user' => $mailAccount->getInboundUser(), + 'host' => $mailAccount->getInboundHost(), + 'port' => $mailAccount->getInboundPort(), + 'security' => $mailAccount->getInboundSslMode(), + 'path' => $mailAccount->getPath() ?? '/.well-known/jmap', + ], + ], + MailAccount::PROTOCOL_IMAP => [ + 'imap' => [ + 'user' => $mailAccount->getInboundUser(), + 'host' => $mailAccount->getInboundHost(), + 'port' => $mailAccount->getInboundPort(), + 'security' => $mailAccount->getInboundSslMode(), + ], + 'smtp' => [ + 'user' => $mailAccount->getOutboundUser(), + 'host' => $mailAccount->getOutboundHost(), + 'port' => $mailAccount->getOutboundPort(), + 'security' => $mailAccount->getOutboundSslMode(), + ], + ], + default => [ + 'unsupported' => [ + 'protocol' => $protocol, + ], + ], + }; + } + + private function getAccountsData($accounts) { + $accountsData = []; + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + $accountsData[] = [ + 'id' => $account->getId(), + 'email' => $account->getEmail(), + 'name' => $account->getName(), + 'protocol' => $mailAccount->getProtocol(), + 'provision' => $this->getProvisionData($account), + ...$this->getProtocolData($account), + ]; + } + + return $accountsData; + } + + private function writeProtocolDetails($account, OutputInterface $output): void { + $mailAccount = $account->getMailAccount(); + $protocol = $mailAccount->getProtocol(); + + switch ($protocol) { + case MailAccount::PROTOCOL_JMAP: + $port = $mailAccount->getInboundPort(); + $path = $mailAccount->getPath() ?? '/.well-known/jmap'; + $output->writeln('- Protocol: JMAP'); + $output->writeln('- JMAP user: ' . $mailAccount->getInboundUser()); + $output->writeln('- JMAP endpoint: ' . $mailAccount->getInboundHost() . ":$port$path, security: " . $mailAccount->getInboundSslMode()); + return; + case MailAccount::PROTOCOL_IMAP: + $output->writeln('- Protocol: IMAP'); + $output->writeln('- IMAP user: ' . $mailAccount->getInboundUser()); + $inboundPort = $mailAccount->getInboundPort(); + $output->writeln('- IMAP host: ' . $mailAccount->getInboundHost() . ":$inboundPort, security: " . $mailAccount->getInboundSslMode()); + $output->writeln('- SMTP user: ' . $mailAccount->getOutboundUser()); + $outboundPort = $mailAccount->getOutboundPort(); + $output->writeln('- SMTP host: ' . $mailAccount->getOutboundHost() . ":$outboundPort, security: " . $mailAccount->getOutboundSslMode()); + return; + default: + $output->writeln('- Protocol: ' . $protocol . ' (unsupported export format)'); + } + } + } 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/Command/UpdateAccount.php b/lib/Command/UpdateImapAccount.php similarity index 74% rename from lib/Command/UpdateAccount.php rename to lib/Command/UpdateImapAccount.php index b23605b010..01b3dfe98c 100644 --- a/lib/Command/UpdateAccount.php +++ b/lib/Command/UpdateImapAccount.php @@ -1,5 +1,7 @@ mapper = $mapper; - $this->crypto = $crypto; } - /** - * @return void - */ - protected function configure() { - $this->setName('mail:account:update'); - $this->setDescription('Update a user\'s IMAP account'); + protected function configure(): void { + $this->setName('mail:account:update-imap'); + $this->setAliases(['mail:account:update']); + $this->setDescription('Update an IMAP mail account'); $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); $this->addOption(self::ARGUMENT_NAME, '', InputOption::VALUE_OPTIONAL); @@ -94,70 +89,75 @@ protected function execute(InputInterface $input, OutputInterface $output): int $mailAccount = $this->mapper->findById($accountId); } catch (DoesNotExistException $e) { $output->writeln("No Email Account found with ID $accountId "); - return 1; + return self::FAILURE; + } + + if ($mailAccount->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + $output->writeln('Account ' . $accountId . ' uses protocol ' . $mailAccount->getProtocol() . '. Use mail:account:update-jmap instead.'); + return self::FAILURE; } $output->writeLn('Found account with email: ' . $mailAccount->getEmail() . ''); //AUTH METHOD - if ($input->getOption(self::ARGUMENT_AUTH_METHOD)) { + if ($input->getOption(self::ARGUMENT_AUTH_METHOD) !== null) { $mailAccount->setAuthMethod($authMethod); } //ACCOUNT OPTIONS - if ($input->getOption(self::ARGUMENT_NAME)) { + if ($input->getOption(self::ARGUMENT_NAME) !== null) { $mailAccount->setName($name); } - if ($input->getOption(self::ARGUMENT_EMAIL)) { + if ($input->getOption(self::ARGUMENT_EMAIL) !== null) { $mailAccount->setEmail($email); } //INBOUND - if ($input->getOption(self::ARGUMENT_IMAP_HOST)) { + if ($input->getOption(self::ARGUMENT_IMAP_HOST) !== null) { $mailAccount->setInboundHost($imapHost); } - if ($input->getOption(self::ARGUMENT_IMAP_PORT)) { + if ($input->getOption(self::ARGUMENT_IMAP_PORT) !== null) { $mailAccount->setInboundPort((int)$imapPort); } - if ($input->getOption(self::ARGUMENT_IMAP_SSL_MODE)) { + if ($input->getOption(self::ARGUMENT_IMAP_SSL_MODE) !== null) { $mailAccount->setInboundSslMode($imapSslMode); } - if ($input->getOption(self::ARGUMENT_IMAP_PASSWORD)) { + if ($input->getOption(self::ARGUMENT_IMAP_PASSWORD) !== null) { $mailAccount->setInboundPassword($this->crypto->encrypt($imapPassword)); } - if ($input->getOption(self::ARGUMENT_SMTP_USER)) { + if ($input->getOption(self::ARGUMENT_IMAP_USER) !== null) { $mailAccount->setInboundUser($imapUser); } // OUTBOUND - if ($input->getOption(self::ARGUMENT_SMTP_HOST)) { + if ($input->getOption(self::ARGUMENT_SMTP_HOST) !== null) { $mailAccount->setOutboundHost($smtpHost); } - if ($input->getOption(self::ARGUMENT_SMTP_PORT)) { + if ($input->getOption(self::ARGUMENT_SMTP_PORT) !== null) { $mailAccount->setOutboundPort((int)$smtpPort); } - if ($input->getOption(self::ARGUMENT_SMTP_SSL_MODE)) { + if ($input->getOption(self::ARGUMENT_SMTP_SSL_MODE) !== null) { $mailAccount->setOutboundSslMode($smtpSslMode); } - if ($input->getOption(self::ARGUMENT_SMTP_PASSWORD)) { + if ($input->getOption(self::ARGUMENT_SMTP_PASSWORD) !== null) { $mailAccount->setOutboundPassword($this->crypto->encrypt($smtpPassword)); } - if ($input->getOption(self::ARGUMENT_SMTP_USER)) { + if ($input->getOption(self::ARGUMENT_SMTP_USER) !== null) { $mailAccount->setOutboundUser($smtpUser); } $this->mapper->save($mailAccount); $output->writeln('Account ' . $mailAccount->getEmail() . " with ID $accountId succesfully updated "); - return 0; + return self::SUCCESS; } } diff --git a/lib/Command/UpdateJmapAccount.php b/lib/Command/UpdateJmapAccount.php new file mode 100644 index 0000000000..60e3e09eda --- /dev/null +++ b/lib/Command/UpdateJmapAccount.php @@ -0,0 +1,111 @@ +setName('mail:account:update-jmap'); + $this->setDescription('Update a JMAP mail account'); + $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); + + $this->addOption(self::ARGUMENT_NAME, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_EMAIL, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_HOST, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_PORT, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_SSL_MODE, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_BAUTH_USER, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_BAUTH_PASSWORD, null, InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_PATH, null, InputOption::VALUE_OPTIONAL); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID); + + $name = $input->getOption(self::ARGUMENT_NAME); + $email = $input->getOption(self::ARGUMENT_EMAIL); + $host = $input->getOption(self::ARGUMENT_HOST); + $port = $input->getOption(self::ARGUMENT_PORT); + $sslMode = $input->getOption(self::ARGUMENT_SSL_MODE); + $basicAuthUser = $input->getOption(self::ARGUMENT_BAUTH_USER); + $basicAuthPassword = $input->getOption(self::ARGUMENT_BAUTH_PASSWORD); + $path = $input->getOption(self::ARGUMENT_PATH); + + try { + $mailAccount = $this->mapper->findById($accountId); + } catch (DoesNotExistException $e) { + $output->writeln("No Email Account found with ID $accountId "); + return self::FAILURE; + } + + if ($mailAccount->getProtocol() !== MailAccount::PROTOCOL_JMAP) { + $output->writeln('Account ' . $accountId . ' uses protocol ' . $mailAccount->getProtocol() . '. Use mail:account:update-imap instead.'); + return self::FAILURE; + } + + $output->writeln('Found JMAP account with email: ' . $mailAccount->getEmail() . ''); + + if ($input->getOption(self::ARGUMENT_NAME) !== null) { + $mailAccount->setName($name); + } + if ($input->getOption(self::ARGUMENT_EMAIL) !== null) { + $mailAccount->setEmail($email); + } + if ($input->getOption(self::ARGUMENT_HOST) !== null) { + $mailAccount->setInboundHost($host); + } + if ($input->getOption(self::ARGUMENT_PORT) !== null) { + $mailAccount->setInboundPort((int)$port); + } + if ($input->getOption(self::ARGUMENT_SSL_MODE) !== null) { + $mailAccount->setInboundSslMode($sslMode); + } + if ($input->getOption(self::ARGUMENT_BAUTH_USER) !== null) { + $mailAccount->setInboundUser($basicAuthUser); + } + if ($input->getOption(self::ARGUMENT_BAUTH_PASSWORD) !== null) { + $mailAccount->setInboundPassword($this->crypto->encrypt($basicAuthPassword)); + } + if ($input->getOption(self::ARGUMENT_PATH) !== null) { + $mailAccount->setPath($path); + } + + $this->mapper->save($mailAccount); + + $output->writeln('JMAP account ' . $mailAccount->getEmail() . " with ID $accountId succesfully updated "); + return self::SUCCESS; + } +} diff --git a/lib/Contracts/IMailboxConnector.php b/lib/Contracts/IMailboxConnector.php new file mode 100644 index 0000000000..931e383465 --- /dev/null +++ b/lib/Contracts/IMailboxConnector.php @@ -0,0 +1,48 @@ +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/Exception/JmapTransportException.php b/lib/JMAP/Exception/JmapTransportException.php new file mode 100644 index 0000000000..9da6713435 --- /dev/null +++ b/lib/JMAP/Exception/JmapTransportException.php @@ -0,0 +1,15 @@ + $defaultOptions Options applied to every + * request, e.g. 'verify' and 'timeout'. SSRF protection stays on + * unless explicitly disabled here. + */ + public function __construct( + private IClient $client, + private ResponseFactoryInterface $responseFactory, + private StreamFactoryInterface $streamFactory, + private array $defaultOptions = [], + ) { + } + + public function sendRequest(RequestInterface $request): ResponseInterface { + // convert PSR-7 request to native transport client request + $options = $this->defaultOptions; + foreach (array_keys($request->getHeaders()) as $name) { + $options['headers'][$name] = $request->getHeaderLine($name); + } + $options['allow_redirects'] = false; + $options['http_errors'] = false; + // Always request a streamed response so the body is exposed as a live + // resource rather than buffered into memory. Small responses are read + // in full by the caller; large blob downloads stay streamed end-to-end. + $options['stream'] = true; + + $body = (string)$request->getBody(); + if ($body !== '') { + $options['body'] = $body; + } + + // transceive and catch any transport-level exceptions + try { + $nativeResponse = $this->client->request( + $request->getMethod(), + (string)$request->getUri(), + $options, + ); + } catch (\Throwable $e) { + throw new JmapTransportException($e->getMessage(), (int)$e->getCode(), $e); + } + + // Convert the native transport client response to a PSR-7 response. + $body = $nativeResponse->getBody(); + $stream = is_resource($body) + ? $this->streamFactory->createStreamFromResource($body) + : $this->streamFactory->createStream((string)$body); + + $response = $this->responseFactory + ->createResponse($nativeResponse->getStatusCode()) + ->withBody($stream); + + foreach ($nativeResponse->getHeaders() as $name => $values) { + $response = $response->withHeader($name, $values); + } + + return $response; + } +} diff --git a/lib/JMAP/JmapClientFactory.php b/lib/JMAP/JmapClientFactory.php new file mode 100644 index 0000000000..ae7aab7132 --- /dev/null +++ b/lib/JMAP/JmapClientFactory.php @@ -0,0 +1,91 @@ +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, + ); + } + + $responseFactory = Psr17FactoryDiscovery::findResponseFactory(); + $requestFactory = Psr17FactoryDiscovery::findRequestFactory(); + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + $adapter = new JmapClientAdapter( + $this->clientService->newClient(), + $responseFactory, + $streamFactory, + [ + 'verify' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), + 'timeout' => 30, + ], + ); + + $client = new JmapClient('', null, $adapter, $requestFactory, $streamFactory); + $client->configureTransportMode($secure ? 'https' : 'http'); + $client->setHost($host . ':' . $port); + if ($path !== '/.well-known/jmap') { + $client->setDiscoveryPath($path); + } + $client->setAuthentication(new Basic($user, $password)); + + return $client; + } +} diff --git a/lib/Migration/Version5011Date20260401000001.php b/lib/Migration/Version5011Date20260401000001.php new file mode 100644 index 0000000000..178221bce2 --- /dev/null +++ b/lib/Migration/Version5011Date20260401000001.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/Version5011Date20260401000002.php b/lib/Migration/Version5011Date20260401000002.php new file mode 100644 index 0000000000..35c491b550 --- /dev/null +++ b/lib/Migration/Version5011Date20260401000002.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/Version5011Date20260401000003.php b/lib/Migration/Version5011Date20260401000003.php new file mode 100644 index 0000000000..c9c87b076c --- /dev/null +++ b/lib/Migration/Version5011Date20260401000003.php @@ -0,0 +1,54 @@ +getTable('mail_messages'); + + if (!$messagesTable->hasColumn('remote_id')) { + $messagesTable->addColumn('remote_id', Types::STRING, [ + 'length' => 255, + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$messagesTable->hasIndex('mail_msg_by_remote_id_idx')) { + $messagesTable->addIndex(['mailbox_id', 'remote_id'], 'mail_msg_by_remote_id_idx'); + } + + 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/lib/Protocol/SyncResult.php b/lib/Protocol/SyncResult.php new file mode 100644 index 0000000000..99d506dd5f --- /dev/null +++ b/lib/Protocol/SyncResult.php @@ -0,0 +1,27 @@ + $stats + */ + public function __construct( + public readonly array $new = [], + public readonly array $modified = [], + public readonly array $deleted = [], + public readonly ?string $state = null, + public readonly array $stats = [], + ) { + } +} diff --git a/package-lock.json b/package-lock.json index ceb03dc242..15566fe0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextcloud-mail", - "version": "5.10.0-rc.1", + "version": "5.11.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nextcloud-mail", - "version": "5.10.0-rc.1", + "version": "5.11.0-dev.1", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 2d3e39ad17..ecdd3b432d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextcloud-mail", - "version": "5.10.0-rc.1", + "version": "5.11.0-dev.1", "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..6de1eaecba --- /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..c34de829f6 --- /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..1f77368cf6 --- /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..eda12b6415 --- /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)); + } +} diff --git a/tests/Unit/Command/UpdateImapAccountTest.php b/tests/Unit/Command/UpdateImapAccountTest.php new file mode 100644 index 0000000000..dcb0b656df --- /dev/null +++ b/tests/Unit/Command/UpdateImapAccountTest.php @@ -0,0 +1,135 @@ +mapper = $this->createMock(MailAccountMapper::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->command = new UpdateImapAccount($this->mapper, $this->crypto); + } + + public function testName(): void { + self::assertSame('mail:account:update-imap', $this->command->getName()); + } + + public function testAlias(): void { + self::assertSame(['mail:account:update'], $this->command->getAliases()); + } + + public function testRejectsJmapAccount(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP); + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->with('account-id') + ->willReturn('42'); + $input->method('getOption') + ->willReturn(null); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with('Account 42 uses protocol jmap. Use mail:account:update-jmap instead.'); + + $this->mapper->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn($mailAccount); + + self::assertSame(1, $this->command->run($input, $output)); + } + + public function testExecuteUpdatesImapAccount(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProtocol(MailAccount::PROTOCOL_IMAP); + $mailAccount->setEmail('old@example.com'); + $mailAccount->setInboundUser('old-imap-user'); + + $data = [ + 'name' => 'Updated name', + 'email' => 'updated@example.com', + 'auth-method' => 'password', + 'imap-host' => 'imap.example.com', + 'imap-port' => '993', + 'imap-ssl-mode' => 'ssl', + 'imap-user' => 'imap-user', + 'imap-password' => 'imap-password', + 'smtp-host' => 'smtp.example.com', + 'smtp-port' => '465', + 'smtp-ssl-mode' => 'ssl', + 'smtp-user' => 'smtp-user', + 'smtp-password' => 'smtp-password', + ]; + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnCallback(static fn (string $arg) => $arg === 'account-id' ? '42' : null); + $input->method('getOption') + ->willReturnCallback(static fn (string $option) => $data[$option] ?? null); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->exactly(2)) + ->method('writeln') + ->withConsecutive( + ['Found account with email: old@example.com'], + ['Account updated@example.com with ID 42 succesfully updated '], + ); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->willReturnMap([ + ['imap-password', '', 'encrypted-imap-password'], + ['smtp-password', '', 'encrypted-smtp-password'], + ]); + + $this->mapper->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn($mailAccount); + $this->mapper->expects($this->once()) + ->method('save') + ->with($this->callback(static function (MailAccount $account): bool { + self::assertSame('Updated name', $account->getName()); + self::assertSame('updated@example.com', $account->getEmail()); + self::assertSame('password', $account->getAuthMethod()); + self::assertSame('imap.example.com', $account->getInboundHost()); + self::assertSame(993, $account->getInboundPort()); + self::assertSame('ssl', $account->getInboundSslMode()); + self::assertSame('imap-user', $account->getInboundUser()); + self::assertSame('encrypted-imap-password', $account->getInboundPassword()); + self::assertSame('smtp.example.com', $account->getOutboundHost()); + self::assertSame(465, $account->getOutboundPort()); + self::assertSame('ssl', $account->getOutboundSslMode()); + self::assertSame('smtp-user', $account->getOutboundUser()); + self::assertSame('encrypted-smtp-password', $account->getOutboundPassword()); + + return true; + })); + + self::assertSame(0, $this->command->run($input, $output)); + } +} diff --git a/tests/Unit/Command/UpdateJmapAccountTest.php b/tests/Unit/Command/UpdateJmapAccountTest.php new file mode 100644 index 0000000000..24dd2a5611 --- /dev/null +++ b/tests/Unit/Command/UpdateJmapAccountTest.php @@ -0,0 +1,90 @@ +mapper = $this->createMock(MailAccountMapper::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->command = new UpdateJmapAccount($this->mapper, $this->crypto); + } + + public function testName(): void { + self::assertSame('mail:account:update-jmap', $this->command->getName()); + } + + public function testExecuteUpdatesJmapAccount(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP); + $mailAccount->setEmail('old@example.com'); + + $data = [ + 'name' => 'Updated JMAP', + 'email' => 'updated@example.com', + 'host' => 'mail.example.com', + 'port' => '443', + 'ssl-mode' => 'ssl', + 'basic-auth-user' => 'jmap-user', + 'basic-auth-password' => 'jmap-password', + 'path' => '/jmap/session', + ]; + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnCallback(static fn (string $arg) => $arg === 'account-id' ? '99' : null); + $input->method('getOption') + ->willReturnCallback(static fn (string $option) => $data[$option] ?? null); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->exactly(2)) + ->method('writeln'); + + $this->crypto->expects($this->once()) + ->method('encrypt') + ->with('jmap-password') + ->willReturn('encrypted-jmap-password'); + + $this->mapper->expects($this->once()) + ->method('findById') + ->with(99) + ->willReturn($mailAccount); + $this->mapper->expects($this->once()) + ->method('save') + ->with($this->callback(static function (MailAccount $account): bool { + self::assertSame('Updated JMAP', $account->getName()); + self::assertSame('updated@example.com', $account->getEmail()); + 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-jmap-password', $account->getInboundPassword()); + self::assertSame('/jmap/session', $account->getPath()); + + return true; + })); + + self::assertSame(0, $this->command->run($input, $output)); + } +} diff --git a/tests/Unit/JMAP/JmapClientAdapterTest.php b/tests/Unit/JMAP/JmapClientAdapterTest.php new file mode 100644 index 0000000000..220d172d60 --- /dev/null +++ b/tests/Unit/JMAP/JmapClientAdapterTest.php @@ -0,0 +1,114 @@ +ncClient = $this->createMock(IClient::class); + $this->factory = new HttpFactory(); + $this->client = new JmapClientAdapter( + $this->ncClient, + $this->factory, + $this->factory, + ['verify' => true], + ); + } + + /** + * @param string|resource $body + */ + private function ncResponse(int $status, $body, array $headers): IResponse&MockObject { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($status); + $response->method('getBody')->willReturn($body); + $response->method('getHeaders')->willReturn($headers); + return $response; + } + + public function testForwardsRequestAndAdaptsResponse(): void { + $request = $this->factory->createRequest('POST', 'https://jmap.example.com/api') + ->withHeader('Authorization', 'Basic abc') + ->withBody($this->factory->createStream('{"a":1}')); + $ncResponse = $this->ncResponse(200, '{"ok":true}', ['Content-Type' => ['application/json']]); + + $captured = []; + $this->ncClient->expects(self::once()) + ->method('request') + ->willReturnCallback(function (string $method, string $uri, array $options) use (&$captured, $ncResponse) { + $captured = [$method, $uri, $options]; + return $ncResponse; + }); + + $response = $this->client->sendRequest($request); + + self::assertSame('POST', $captured[0]); + self::assertSame('https://jmap.example.com/api', $captured[1]); + self::assertFalse($captured[2]['allow_redirects']); + self::assertFalse($captured[2]['http_errors']); + self::assertTrue($captured[2]['stream']); + self::assertTrue($captured[2]['verify']); + self::assertSame('Basic abc', $captured[2]['headers']['Authorization']); + self::assertSame('{"a":1}', $captured[2]['body']); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('{"ok":true}', (string)$response->getBody()); + self::assertSame('application/json', $response->getHeaderLine('Content-Type')); + } + + public function testOmitsBodyWhenEmpty(): void { + $request = $this->factory->createRequest('GET', 'https://jmap.example.com/.well-known/jmap'); + $ncResponse = $this->ncResponse(200, '', []); + + $captured = []; + $this->ncClient->method('request') + ->willReturnCallback(function (string $method, string $uri, array $options) use (&$captured, $ncResponse) { + $captured = $options; + return $ncResponse; + }); + + $this->client->sendRequest($request); + + self::assertArrayNotHasKey('body', $captured); + } + + public function testAdaptsStreamedResourceBody(): void { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'streamed-bytes'); + rewind($resource); + $this->ncClient->method('request')->willReturn($this->ncResponse(200, $resource, [])); + + $response = $this->client->sendRequest( + $this->factory->createRequest('GET', 'https://jmap.example.com/blob'), + ); + + self::assertSame('streamed-bytes', (string)$response->getBody()); + } + + public function testWrapsClientFailureAsClientException(): void { + $request = $this->factory->createRequest('GET', 'https://jmap.example.com/api'); + $this->ncClient->method('request')->willThrowException(new \RuntimeException('connection refused')); + + $this->expectException(JmapTransportException::class); + $this->client->sendRequest($request); + } +} diff --git a/tests/Unit/JMAP/JmapClientFactoryTest.php b/tests/Unit/JMAP/JmapClientFactoryTest.php new file mode 100644 index 0000000000..9b9a2feeed --- /dev/null +++ b/tests/Unit/JMAP/JmapClientFactoryTest.php @@ -0,0 +1,88 @@ +crypto = $this->createMock(ICrypto::class); + $this->config = $this->createMock(IConfig::class); + $this->clientService = $this->createMock(IClientService::class); + + $this->factory = new JmapClientFactory( + $this->crypto, + $this->config, + $this->clientService, + ); + } + + private function account(array $overrides = []): Account { + $mailAccount = new MailAccount(); + $mailAccount->setInboundHost($overrides['host'] ?? 'jmap.example.com'); + $mailAccount->setInboundPort($overrides['port'] ?? 443); + $mailAccount->setInboundSslMode($overrides['ssl'] ?? 'yes'); + $mailAccount->setInboundUser($overrides['user'] ?? 'user@example.com'); + $mailAccount->setInboundPassword(array_key_exists('password', $overrides) ? $overrides['password'] : 'encrypted'); + if (array_key_exists('path', $overrides)) { + $mailAccount->setPath($overrides['path']); + } + return new Account($mailAccount); + } + + public function testBuildsConfiguredClient(): void { + $this->crypto->method('decrypt')->with('encrypted')->willReturn('secret'); + $this->config->method('getSystemValueBool')->willReturn(true); + $this->clientService->expects(self::once()) + ->method('newClient') + ->willReturn($this->createMock(IClient::class)); + + $client = $this->factory->getClient($this->account()); + + self::assertInstanceOf(JmapClient::class, $client); + self::assertSame('jmap.example.com:443', $client->getHost()); + } + + public function testThrowsWhenHostMissing(): void { + $this->expectException(ServiceException::class); + + $this->factory->getClient($this->account(['host' => ''])); + } + + public function testThrowsWhenPasswordMissing(): void { + $this->expectException(ServiceException::class); + + $this->factory->getClient($this->account(['password' => null])); + } + + public function testWrapsDecryptionFailure(): void { + $this->crypto->method('decrypt')->willThrowException(new \Exception('bad key')); + + $this->expectException(ServiceException::class); + $this->factory->getClient($this->account()); + } +}