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());
+ }
+}