diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5f2267685f..cc84b165d2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,7 +7,8 @@ permissions:
contents: read
env:
- E2E_NODE_VERSION: "20" # TODO: Extract automatically using another action
+ E2E_NODE_VERSION: "20"
+ STALWART_PWD: "secretpassword"
jobs:
unit-tests:
@@ -94,7 +95,7 @@ jobs:
cache: 'redis'
name: ${{ matrix.nextcloud-versions }} w/ php${{ matrix.php-versions }}-${{ matrix.db }}-${{ matrix.cache }} integration tests ${{ matrix.coverage && '(coverage)' || ''}}
services:
- mail-service:
+ mail-dovecot-service:
image: ghcr.io/christophwurst/docker-imap-devel:latest
env:
MAILNAME: mail.domain.tld
@@ -105,6 +106,15 @@ jobs:
- 143:143
- 993:993
- 4190:4190
+ mail-stalwart-service:
+ image: stalwartlabs/stalwart:v0.15.5
+ env:
+ STALWART_ADMIN_PASSWORD: ${{ env.STALWART_PWD }}
+ ports:
+ - 10080:8080
+ - 10025:25
+ - 10143:143
+ - 10993:993
mariadb-service:
image: ghcr.io/nextcloud/continuous-integration-mariadb-11.4:latest
env:
@@ -142,6 +152,16 @@ jobs:
ports:
- 6379:6379
steps:
+ - name: Create domain and account in Stalwart
+ run: |
+ curl -sf -X POST http://localhost:10080/api/principal \
+ -u "admin:${{ env.STALWART_PWD }}" \
+ -H 'Content-Type: application/json' \
+ -d '{"type":"domain","name":"example.com"}'
+ curl -sf -X POST http://localhost:10080/api/principal \
+ -u "admin:${{ env.STALWART_PWD }}" \
+ -H 'Content-Type: application/json' \
+ -d '{"type":"individual","name":"user@example.com","secrets":["mypassword"],"emails":["user@example.com"],"roles":["user"]}'
- name: Set up Nextcloud env
uses: nextcloud/setup-server-action@34b73d5b0e3633f83a52227d00cc2a6c41d01d9a # v1.0.0
with:
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 9ab13f49cb..d4f666fbd3 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove
Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/).
]]>
- 5.8.0-dev.2
+ 5.8.0-dev.3
agpl
Christoph Wurst
GretaD
@@ -79,14 +79,15 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
OCA\Mail\Command\AddMissingTags
OCA\Mail\Command\CleanUp
- OCA\Mail\Command\CreateAccount
+ OCA\Mail\Command\CreateImapAccount
+ OCA\Mail\Command\CreateJmapAccount
OCA\Mail\Command\CreateTagMigrationJobEntry
OCA\Mail\Command\DebugAccount
OCA\Mail\Command\DeleteAccount
- OCA\Mail\Command\DiagnoseAccount
OCA\Mail\Command\ExportAccount
OCA\Mail\Command\ExportAccountThreads
OCA\Mail\Command\PredictImportance
+ OCA\Mail\Command\TestAccount
OCA\Mail\Command\SyncAccount
OCA\Mail\Command\Thread
OCA\Mail\Command\TrainAccount
diff --git a/composer.json b/composer.json
index 9a0a23dbed..be70a0413d 100644
--- a/composer.json
+++ b/composer.json
@@ -11,6 +11,12 @@
"optimize-autoloader": true,
"autoloader-suffix": "Mail"
},
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/sebastiankrupinski/jmap-client-php"
+ }
+ ],
"require": {
"php": ">=8.1 <=8.4",
"ext-openssl": "*",
@@ -43,6 +49,7 @@
"psr/log": "^3.0.2",
"rubix/ml": "2.5.3",
"sabberworm/php-css-parser": "^8.9.0",
+ "sebastiankrupinski/jmap-client-php": "dev-main",
"wamania/php-stemmer": "4.0 as 3.0",
"youthweb/urllinker": "^2.1.0"
},
diff --git a/composer.lock b/composer.lock
index f550f012cc..9493cb55ab 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "6b6a3e5ea86fb4218afe65e7470bc3ac",
+ "content-hash": "b6303381d8f809be8a093fa8d3dd061c",
"packages": [
{
"name": "amphp/amp",
@@ -1940,6 +1940,332 @@
],
"time": "2021-12-01T16:22:57+00:00"
},
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^2.3",
+ "guzzlehttp/psr7": "^2.8",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-curl": "*",
+ "guzzle/client-integration-tests": "3.0.2",
+ "php-http/message-factory": "^1.1",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-23T22:36:01+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-22T14:34:08+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "http-interop/http-factory-tests": "0.9.0",
+ "jshttp/mime-db": "1.54.0.1",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-10T16:41:02+00:00"
+ },
{
"name": "hamza221/html2text",
"version": "v1.0.0",
@@ -2479,6 +2805,210 @@
},
"time": "2025-10-09T12:29:49+00:00"
},
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
{
"name": "rubix/ml",
"version": "2.5.3",
@@ -2809,6 +3339,78 @@
},
"time": "2025-07-11T13:20:48+00:00"
},
+ {
+ "name": "sebastiankrupinski/jmap-client-php",
+ "version": "dev-main",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/SebastianKrupinski/jmap-client-php.git",
+ "reference": "30dce48d854abc7d416f3784bc796b379775bdd3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/30dce48d854abc7d416f3784bc796b379775bdd3",
+ "reference": "30dce48d854abc7d416f3784bc796b379775bdd3",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "^7.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.89",
+ "phpunit/phpunit": "^11.0"
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "JmapClient\\": "lib/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "JmapClient\\Tests\\Unit\\": "tests/unit/",
+ "JmapClient\\Tests\\Integration\\": "tests/integration/"
+ }
+ },
+ "scripts": {
+ "test:unit": [
+ "./vendor/bin/phpunit --testsuite 'Unit Tests'"
+ ],
+ "test:integration": [
+ "./vendor/bin/phpunit --testsuite 'Integration Tests'"
+ ],
+ "cs:check": [
+ "php-cs-fixer fix --dry-run --diff"
+ ],
+ "cs:fix": [
+ "php-cs-fixer fix"
+ ]
+ },
+ "license": [
+ "AGL3"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Krupinski",
+ "email": "krupinski01@gmail.com",
+ "homepage": "https://github.com/SebastianKrupinski/",
+ "role": "Just another Minion in the cogs of time"
+ }
+ ],
+ "description": "JMAP PHP Client",
+ "homepage": "https://github.com/SebastianKrupinski/jmap-client-php",
+ "keywords": [
+ "enum"
+ ],
+ "support": {
+ "source": "https://github.com/SebastianKrupinski/jmap-client-php/tree/main",
+ "issues": "https://github.com/SebastianKrupinski/jmap-client-php/issues"
+ },
+ "time": "2026-03-05T04:24:01+00:00"
+ },
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
@@ -3902,6 +4504,7 @@
"minimum-stability": "stable",
"stability-flags": {
"gravatarphp/gravatar": 20,
+ "sebastiankrupinski/jmap-client-php": 20,
"roave/security-advisories": 20
},
"prefer-stable": false,
@@ -3910,9 +4513,9 @@
"php": ">=8.1 <=8.4",
"ext-openssl": "*"
},
- "platform-dev": {},
+ "platform-dev": [],
"platform-overrides": {
"php": "8.1"
},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.3.0"
}
diff --git a/lib/Command/CreateAccount.php b/lib/Command/CreateImapAccount.php
similarity index 95%
rename from lib/Command/CreateAccount.php
rename to lib/Command/CreateImapAccount.php
index af85924309..115b77d77d 100644
--- a/lib/Command/CreateAccount.php
+++ b/lib/Command/CreateImapAccount.php
@@ -20,7 +20,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-final class CreateAccount extends Command {
+final class CreateImapAccount extends Command {
public const ARGUMENT_USER_ID = 'user-id';
public const ARGUMENT_NAME = 'name';
public const ARGUMENT_EMAIL = 'email';
@@ -57,8 +57,9 @@ public function __construct(
* @return void
*/
protected function configure() {
- $this->setName('mail:account:create');
- $this->setDescription('creates IMAP account');
+ $this->setName('mail:account:create-imap');
+ $this->setAliases(['mail:account:create']);
+ $this->setDescription('creates an IMAP mail account');
$this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED);
$this->addArgument(self::ARGUMENT_NAME, InputArgument::REQUIRED);
$this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED);
@@ -98,7 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if (!$this->userManager->userExists($userId)) {
$output->writeln("User $userId does not exist");
- return 1;
+ return self::FAILURE;
}
$account = new MailAccount();
@@ -124,6 +125,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln('Account ' . $account->getId() . " for $email created");
- return 0;
+ return self::SUCCESS;
}
}
diff --git a/lib/Command/CreateJmapAccount.php b/lib/Command/CreateJmapAccount.php
new file mode 100644
index 0000000000..70b1a65c54
--- /dev/null
+++ b/lib/Command/CreateJmapAccount.php
@@ -0,0 +1,93 @@
+setName('mail:account:create-jmap');
+ $this->setDescription('creates a JMAP mail account');
+ $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED, 'user to add the account for');
+ $this->addArgument(self::ARGUMENT_NAME, InputArgument::REQUIRED, 'display name of the account');
+ $this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED, 'email address');
+ $this->addArgument(self::ARGUMENT_HOST, InputArgument::REQUIRED, 'JMAP server hostname (e.g. mail.example.com)');
+ $this->addArgument(self::ARGUMENT_PORT, InputArgument::REQUIRED, 'JMAP server port (e.g. 443)');
+ $this->addArgument(self::ARGUMENT_SSL_MODE, InputArgument::REQUIRED, 'SSL mode (ssl or none)');
+ $this->addArgument(self::ARGUMENT_BAUTH_USER, InputArgument::REQUIRED, 'Basic authentication user');
+ $this->addArgument(self::ARGUMENT_BAUTH_PASSWORD, InputArgument::REQUIRED, 'Basic authentication password');
+ $this->addArgument(self::ARGUMENT_PATH, InputArgument::OPTIONAL, 'JMAP session endpoint path (e.g. /jmap/session)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument(self::ARGUMENT_USER_ID);
+ $name = $input->getArgument(self::ARGUMENT_NAME);
+ $email = $input->getArgument(self::ARGUMENT_EMAIL);
+ $host = $input->getArgument(self::ARGUMENT_HOST);
+ $port = (int)$input->getArgument(self::ARGUMENT_PORT);
+ $sslMode = $input->getArgument(self::ARGUMENT_SSL_MODE);
+ $bauthUser = $input->getArgument(self::ARGUMENT_BAUTH_USER);
+ $bauthPassword = $input->getArgument(self::ARGUMENT_BAUTH_PASSWORD);
+ $path = $input->getArgument(self::ARGUMENT_PATH);
+
+ if (!$this->userManager->userExists($userId)) {
+ $output->writeln("User $userId does not exist");
+ return self::FAILURE;
+ }
+
+ $account = new MailAccount();
+ $account->setUserId($userId);
+ $account->setName($name);
+ $account->setEmail($email);
+ $account->setProtocol(MailAccount::PROTOCOL_JMAP);
+ $account->setInboundHost($host);
+ $account->setInboundPort($port);
+ $account->setInboundSslMode($sslMode);
+ $account->setInboundUser($bauthUser);
+ $account->setInboundPassword($this->crypto->encrypt($bauthPassword));
+ if ($path !== null) {
+ $account->setPath($path);
+ }
+ $account->setClassificationEnabled($this->classificationSettingsService->isClassificationEnabledByDefault());
+
+ $account = $this->accountService->save($account);
+
+ $output->writeln('JMAP account ' . $account->getId() . " for $email created");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/lib/Command/DiagnoseAccount.php b/lib/Command/DiagnoseAccount.php
deleted file mode 100644
index 60a3377b10..0000000000
--- a/lib/Command/DiagnoseAccount.php
+++ /dev/null
@@ -1,125 +0,0 @@
-accountService = $service;
- $this->clientFactory = $clientFactory;
- $this->logger = $logger;
- }
-
- /**
- * @return void
- */
- protected function configure() {
- $this->setName('mail:account:diagnose');
- $this->setDescription('Diagnose a user\'s IMAP connection');
- $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED);
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int {
- $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID);
-
- try {
- $account = $this->accountService->findById($accountId);
- } catch (DoesNotExistException $e) {
- $output->writeln("Account $accountId does not exist");
- return 1;
- }
-
- if ($account->getMailAccount()->getInboundPassword() === null) {
- $output->writeln('No IMAP passwort set. The user might have to log into their account to set it.');
- }
- $imapClient = $this->clientFactory->getClient($account);
- try {
- $this->printCapabilitiesStats($output, $imapClient);
- $this->printMailboxesMessagesStats($output, $imapClient);
- } catch (Horde_Imap_Client_Exception $e) {
- $this->logger->error('Could not get account statistics: ' . $e, [
- 'exception' => $e,
- ]);
- $output->writeln('Horde error occurred: ' . $e->getMessage() . '. See nextcloud.log for more details.');
- return 2;
- } finally {
- $imapClient->logout();
- }
-
- return 0;
- }
-
- /**
- * @param OutputInterface $output
- * @param Horde_Imap_Client_Socket $imapClient
- *
- * @throws Horde_Imap_Client_Exception
- */
- private function printCapabilitiesStats(OutputInterface $output,
- Horde_Imap_Client_Socket $imapClient): void {
- $output->writeln('IMAP capabilities:');
- // Once logged in more capabilities are advertised
- $imapClient->login();
- $capabilities = array_keys(
- json_decode(
- $imapClient->capability->serialize(),
- true
- )
- );
- sort($capabilities);
- foreach ($capabilities as $capability) {
- $output->writeln("- $capability");
- }
- $output->writeln('');
- }
-
- /**
- * @param OutputInterface $output
- * @param Horde_Imap_Client_Socket $imapClient
- *
- * @throws Horde_Imap_Client_Exception
- */
- protected function printMailboxesMessagesStats(OutputInterface $output,
- Horde_Imap_Client_Socket $imapClient): void {
- $mailboxes = $imapClient->listMailboxes('*', Horde_Imap_Client::MBOX_ALL, [
- 'flat' => true,
- ]);
- $messages = array_reduce($mailboxes, static function (int $c, Horde_Imap_Client_Mailbox $mb) use ($imapClient) {
- $status = $imapClient->status($mb, Horde_Imap_Client::STATUS_MESSAGES);
- return $c + $status['messages'];
- }, 0);
- $output->writeln('Account has ' . $messages . ' messages in ' . count($mailboxes) . ' mailboxes');
- }
-}
diff --git a/lib/Command/TestAccount.php b/lib/Command/TestAccount.php
new file mode 100644
index 0000000000..32abbba9dd
--- /dev/null
+++ b/lib/Command/TestAccount.php
@@ -0,0 +1,153 @@
+setName('mail:account:test');
+ $this->setAliases(['mail:account:diagnose']);
+ $this->setDescription('Test the connection for a mail account (IMAP or JMAP)');
+ $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED, 'The ID of the mail account');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID);
+
+ try {
+ $account = $this->accountService->findById($accountId);
+ } catch (DoesNotExistException $e) {
+ $output->writeln("Account $accountId does not exist");
+ return 1;
+ }
+
+ $protocol = $account->getMailAccount()->getProtocol();
+ $output->writeln("Account $accountId uses protocol: $protocol");
+
+ return match ($protocol) {
+ MailAccount::PROTOCOL_IMAP => $this->testImap($account, $output),
+ MailAccount::PROTOCOL_JMAP => $this->testJmap($account, $output),
+ default => $this->unsupportedProtocol($protocol, $output),
+ };
+ }
+
+ private function testImap(\OCA\Mail\Account $account, OutputInterface $output): int {
+ $output->writeln('Testing IMAP connection...');
+
+ $mailAccount = $account->getMailAccount();
+ $sslMode = $mailAccount->getInboundSslMode();
+ $scheme = ($sslMode === 'none') ? 'imap' : 'imaps';
+ $host = $mailAccount->getInboundHost() ?? '(not set)';
+ $port = $mailAccount->getInboundPort();
+ $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . '');
+
+ if ($account->getMailAccount()->getInboundPassword() === null) {
+ $output->writeln('No IMAP password set. The user may need to log in to set it.');
+ return 1;
+ }
+
+ try {
+ $imapClient = $this->protocolFactory->imapClient($account);
+ } catch (\Exception $e) {
+ $output->writeln('Could not create IMAP client: ' . $e->getMessage() . '');
+ return 2;
+ }
+
+ try {
+ $imapClient->login();
+ $output->writeln('Login successful');
+
+ $capabilities = array_keys(
+ json_decode($imapClient->capability->serialize(), true)
+ );
+ sort($capabilities);
+ $output->writeln('Capabilities: ' . implode(', ', $capabilities) . '');
+
+ $output->writeln('IMAP connection test passed');
+ return 0;
+ } catch (Horde_Imap_Client_Exception $e) {
+ $this->logger->error('IMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ $output->writeln('IMAP connection test failed: ' . $e->getMessage() . '');
+ return 2;
+ } finally {
+ $imapClient->logout();
+ }
+ }
+
+ private function testJmap(\OCA\Mail\Account $account, OutputInterface $output): int {
+ $output->writeln('Testing JMAP connection...');
+
+ $mailAccount = $account->getMailAccount();
+ $sslMode = $mailAccount->getInboundSslMode();
+ $scheme = ($sslMode === 'none') ? 'http' : 'https';
+ $host = $mailAccount->getInboundHost() ?? '(not set)';
+ $port = $mailAccount->getInboundPort();
+ $path = $mailAccount->getPath() ?? '/.well-known/jmap';
+ $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . $path . '');
+
+ try {
+ $client = $this->protocolFactory->jmapClient($account);
+ $session = $client->connect();
+ } catch (\Exception $e) {
+ $this->logger->error('JMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ $output->writeln('JMAP connection test failed: ' . $e->getMessage() . '');
+ return 2;
+ }
+
+ if (!$client->sessionStatus()) {
+ $output->writeln('JMAP session discovery failed. Check the server and credentials.');
+ return 2;
+ }
+
+ $output->writeln('JMAP session established');
+ $output->writeln('Username: ' . $session->username() . '');
+ $output->writeln('API URL: ' . $session->commandUrl() . '');
+ $output->writeln('State: ' . $session->state() . '');
+
+ $capabilities = [];
+ foreach ($session->capabilities() as $capability) {
+ $capabilities[] = $capability->id();
+ }
+ sort($capabilities);
+ $output->writeln('Capabilities: ' . implode(', ', $capabilities) . '');
+
+ $output->writeln('JMAP connection test passed');
+ return 0;
+ }
+
+ private function unsupportedProtocol(string $protocol, OutputInterface $output): int {
+ $output->writeln("Unsupported protocol: $protocol");
+ return 1;
+ }
+}
diff --git a/lib/Contracts/IMailboxConnector.php b/lib/Contracts/IMailboxConnector.php
new file mode 100644
index 0000000000..f2554af882
--- /dev/null
+++ b/lib/Contracts/IMailboxConnector.php
@@ -0,0 +1,49 @@
+setImipCreate($params['imipCreate']);
}
+ if (isset($params['protocol'])) {
+ $this->setProtocol($params['protocol']);
+ }
+ if (isset($params['path'])) {
+ $this->setPath($params['path']);
+ }
$this->addType('inboundPort', 'integer');
$this->addType('outboundPort', 'integer');
@@ -286,6 +302,8 @@ public function __construct(array $params = []) {
$this->addType('debug', 'boolean');
$this->addType('classificationEnabled', 'boolean');
$this->addType('imipCreate', 'boolean');
+ $this->addType('protocol', 'string');
+ $this->addType('path', 'string');
}
public function getOutOfOfficeFollowsSystem(): bool {
@@ -337,6 +355,8 @@ public function toJson() {
'debug' => $this->getDebug(),
'classificationEnabled' => $this->getClassificationEnabled(),
'imipCreate' => $this->getImipCreate(),
+ 'protocol' => $this->getProtocol(),
+ 'path' => $this->getPath(),
];
if (!is_null($this->getOutboundHost())) {
diff --git a/lib/Db/Mailbox.php b/lib/Db/Mailbox.php
index 30be4ea423..acbf4c929d 100644
--- a/lib/Db/Mailbox.php
+++ b/lib/Db/Mailbox.php
@@ -56,6 +56,12 @@
* @method void setShared(bool $shared)
* @method string getNameHash()
* @method void setNameHash(string $nameHash)
+ * @method string|null getRemoteParentId()
+ * @method void setRemoteParentId(?string $remoteParentId)
+ * @method string|null getRemoteId()
+ * @method void setRemoteId(?string $remoteId)
+ * @method string|null getState()
+ * @method void setState(?string $state)
*/
class Mailbox extends Entity implements JsonSerializable {
protected $name;
@@ -76,6 +82,9 @@ class Mailbox extends Entity implements JsonSerializable {
protected $myAcls;
protected $shared;
protected $nameHash;
+ protected ?string $remoteParentId = null;
+ protected ?string $remoteId = null;
+ protected ?string $state = null;
/**
* @var int
diff --git a/lib/Db/Message.php b/lib/Db/Message.php
index 2c68e1f083..35d4aa1af3 100644
--- a/lib/Db/Message.php
+++ b/lib/Db/Message.php
@@ -73,6 +73,8 @@
* @method void setEncrypted(bool|null $encrypted)
* @method bool getMentionsMe()
* @method void setMentionsMe(bool $isMentionned)
+ * @method string|null getRemoteId()
+ * @method void setRemoteId(?string $remoteId)
*/
class Message extends Entity implements JsonSerializable {
private const MUTABLE_FLAGS = [
@@ -117,6 +119,7 @@ class Message extends Entity implements JsonSerializable {
protected $imipProcessed = false;
protected $imipError = false;
protected $mentionsMe = false;
+ protected ?string $remoteId = null;
/**
* @var bool|null
@@ -342,6 +345,7 @@ public function jsonSerialize() {
return [
'databaseId' => $this->getId(),
'uid' => $this->getUid(),
+ 'remoteId' => $this->getRemoteId(),
'subject' => $this->getSubject(),
'dateInt' => $this->getSentAt(),
'flags' => [
diff --git a/lib/JMAP/JmapClientFactory.php b/lib/JMAP/JmapClientFactory.php
new file mode 100644
index 0000000000..fe1624aaeb
--- /dev/null
+++ b/lib/JMAP/JmapClientFactory.php
@@ -0,0 +1,79 @@
+getMailAccount();
+
+ $host = $mailAccount->getInboundHost();
+ if ($host === null || $host === '') {
+ throw new ServiceException('JMAP host is not configured for account ' . $account->getId());
+ }
+
+ $port = $mailAccount->getInboundPort();
+ $secure = $mailAccount->getInboundSslMode() === 'yes';
+ $path = $mailAccount->getPath() ?? '/.well-known/jmap';
+ $user = $mailAccount->getInboundUser();
+ $encryptedPassword = $mailAccount->getInboundPassword();
+
+ if ($encryptedPassword === null) {
+ throw new ServiceException('No password set for JMAP account ' . $account->getId());
+ }
+
+ try {
+ $password = $this->crypto->decrypt($encryptedPassword);
+ } catch (\Exception $e) {
+ throw new ServiceException(
+ 'Could not decrypt password for JMAP account ' . $account->getId() . ': ' . $e->getMessage(),
+ 0,
+ $e,
+ );
+ }
+
+ $client = new JmapClient();
+ $client->configureTransportMode($secure ? 'https' : 'http');
+ $client->setHost($host . ':' . $port);
+ if ($path !== '/.well-known/jmap') {
+ $client->setDiscoveryPath($path);
+ }
+ $client->configureTransportVerification(
+ $this->config->getSystemValueBool('app.mail.verify-tls-peer', true)
+ );
+ $client->setAuthentication(new Basic($user, $password));
+
+ return $client;
+ }
+}
diff --git a/lib/Migration/Version5800Date20260401000001.php b/lib/Migration/Version5800Date20260401000001.php
new file mode 100644
index 0000000000..eea8738d10
--- /dev/null
+++ b/lib/Migration/Version5800Date20260401000001.php
@@ -0,0 +1,53 @@
+getTable('mail_accounts');
+ if (!$accountsTable->hasColumn('protocol')) {
+ $accountsTable->addColumn('protocol', Types::STRING, [
+ 'length' => 16,
+ 'default' => 'imap',
+ 'notnull' => true,
+ ]);
+ }
+ if (!$accountsTable->hasColumn('path')) {
+ $accountsTable->addColumn('path', Types::STRING, [
+ 'length' => 512,
+ 'notnull' => false,
+ 'default' => null,
+ ]);
+ }
+ return $schema;
+ }
+}
diff --git a/lib/Migration/Version5800Date20260401000002.php b/lib/Migration/Version5800Date20260401000002.php
new file mode 100644
index 0000000000..e1a97b1613
--- /dev/null
+++ b/lib/Migration/Version5800Date20260401000002.php
@@ -0,0 +1,61 @@
+getTable('mail_mailboxes');
+ if (!$mailboxesTable->hasColumn('remote_parent_id')) {
+ $mailboxesTable->addColumn('remote_parent_id', Types::STRING, [
+ 'length' => 255,
+ 'notnull' => false,
+ 'default' => null,
+ ]);
+ }
+ if (!$mailboxesTable->hasColumn('remote_id')) {
+ $mailboxesTable->addColumn('remote_id', Types::STRING, [
+ 'length' => 255,
+ 'notnull' => false,
+ 'default' => null,
+ ]);
+ }
+ if (!$mailboxesTable->hasColumn('state')) {
+ $mailboxesTable->addColumn('state', Types::STRING, [
+ 'length' => 64,
+ 'notnull' => false,
+ 'default' => null,
+ ]);
+ }
+ return $schema;
+ }
+}
diff --git a/lib/Migration/Version5800Date20260401000003.php b/lib/Migration/Version5800Date20260401000003.php
new file mode 100644
index 0000000000..e01e21dcc7
--- /dev/null
+++ b/lib/Migration/Version5800Date20260401000003.php
@@ -0,0 +1,47 @@
+getTable('mail_messages');
+
+ if (!$messagesTable->hasColumn('remote_id')) {
+ $messagesTable->addColumn('remote_id', Types::STRING, [
+ 'length' => 255,
+ 'notnull' => false,
+ 'default' => null,
+ ]);
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Protocol/ProtocolFactory.php b/lib/Protocol/ProtocolFactory.php
new file mode 100644
index 0000000000..b226f4bb0e
--- /dev/null
+++ b/lib/Protocol/ProtocolFactory.php
@@ -0,0 +1,113 @@
+ connector interface => class name
+ */
+ private const CONNECTOR_MAP = [
+ // MailAccount::PROTOCOL_IMAP => [
+ // IMailboxConnector::class => ImapMailboxConnector::class,
+ // IMessageConnector::class => ImapMessageConnector::class,
+ // ITransmissionConnector::class => ImapTransmissionConnector::class,
+ // ],
+ // MailAccount::PROTOCOL_JMAP => [
+ // IMailboxConnector::class => JmapMailboxConnector::class,
+ // IMessageConnector::class => JmapMessageConnector::class,
+ // ITransmissionConnector::class => JmapTransmissionConnector::class,
+ // ],
+ ];
+
+ public function __construct(
+ private ContainerInterface $container,
+ private IMAPClientFactory $imapClientFactory,
+ private JmapClientFactory $jmapClientFactory,
+ ) {
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ public function imapClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket {
+ $this->verifyProtocol($account, MailAccount::PROTOCOL_IMAP);
+ return $this->imapClientFactory->getClient($account, $useCache);
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ public function jmapClient(Account $account): JmapClient {
+ $this->verifyProtocol($account, MailAccount::PROTOCOL_JMAP);
+ return $this->jmapClientFactory->getClient($account);
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ private function verifyProtocol(Account $account, string $expected): void {
+ $actual = $account->getMailAccount()->getProtocol();
+ if ($actual !== $expected) {
+ throw new ServiceException("Expected protocol $expected but account uses $actual");
+ }
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ public function mailboxConnector(Account $account): IMailboxConnector {
+ return $this->resolveConnector($account, IMailboxConnector::class);
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ public function messageConnector(Account $account): IMessageConnector {
+ return $this->resolveConnector($account, IMessageConnector::class);
+ }
+
+ /**
+ * @throws ServiceException
+ */
+ public function transmissionConnector(Account $account): ITransmissionConnector {
+ return $this->resolveConnector($account, ITransmissionConnector::class);
+ }
+
+ /**
+ * @template T
+ * @param Account $account
+ * @param class-string $interface
+ * @return T
+ * @throws ServiceException
+ */
+ private function resolveConnector(Account $account, string $interface): mixed {
+ $protocol = $account->getMailAccount()->getProtocol();
+ $class = self::CONNECTOR_MAP[$protocol][$interface] ?? null;
+
+ if ($class === null) {
+ throw new ServiceException("No $interface implementation for protocol $protocol");
+ }
+
+ return $this->container->get($class);
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 341a693242..36c0e9f3bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nextcloud-mail",
- "version": "5.8.0-dev.1",
+ "version": "5.8.0-dev.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nextcloud-mail",
- "version": "5.8.0-dev.1",
+ "version": "5.8.0-dev.3",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
diff --git a/package.json b/package.json
index e161992106..aaf75feceb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nextcloud-mail",
- "version": "5.8.0-dev.1",
+ "version": "5.8.0-dev.3",
"private": true,
"description": "Nextcloud Mail",
"license": "AGPL-3.0-only",
diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php
index 0d2efc5bae..b290978744 100644
--- a/tests/Integration/Db/MailAccountTest.php
+++ b/tests/Integration/Db/MailAccountTest.php
@@ -74,6 +74,8 @@ public function testToAPI() {
'classificationEnabled' => true,
'authMethod' => 'password',
'imipCreate' => false,
+ 'protocol' => 'imap',
+ 'path' => null,
], $a->toJson());
}
@@ -115,6 +117,8 @@ public function testMailAccountConstruct() {
'classificationEnabled' => true,
'authMethod' => 'password',
'imipCreate' => false,
+ 'protocol' => 'imap',
+ 'path' => null,
];
$a = new MailAccount($expected);
// TODO: fix inconsistency
diff --git a/tests/Integration/Framework/JmapTestAccount.php b/tests/Integration/Framework/JmapTestAccount.php
new file mode 100644
index 0000000000..5613431ae5
--- /dev/null
+++ b/tests/Integration/Framework/JmapTestAccount.php
@@ -0,0 +1,44 @@
+setUserId($userId ?? $this->getTestAccountUserId());
+ $mailAccount->setName('Tester');
+ $mailAccount->setEmail('user@example.com');
+ $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP);
+ $mailAccount->setInboundHost('127.0.0.1');
+ $mailAccount->setInboundPort(10080);
+ $mailAccount->setInboundSslMode('none');
+ $mailAccount->setInboundUser('user@example.com');
+ $mailAccount->setInboundPassword(Server::get(ICrypto::class)->encrypt('mypassword'));
+
+ $saved = $accountService->save($mailAccount);
+
+ return $saved;
+ }
+}
diff --git a/tests/Integration/Protocol/ProtocolFactoryImapTest.php b/tests/Integration/Protocol/ProtocolFactoryImapTest.php
new file mode 100644
index 0000000000..1223fea706
--- /dev/null
+++ b/tests/Integration/Protocol/ProtocolFactoryImapTest.php
@@ -0,0 +1,39 @@
+protocolFactory = Server::get(ProtocolFactory::class);
+ }
+
+ public function testImapClientConnection(): void {
+ $account = new Account($this->createTestAccount());
+
+ $client = $this->protocolFactory->imapClient($account);
+
+ $this->assertInstanceOf(Horde_Imap_Client_Socket::class, $client);
+ $client->login();
+ $client->logout();
+ }
+}
diff --git a/tests/Integration/Protocol/ProtocolFactoryJmapTest.php b/tests/Integration/Protocol/ProtocolFactoryJmapTest.php
new file mode 100644
index 0000000000..ebabd1e8b3
--- /dev/null
+++ b/tests/Integration/Protocol/ProtocolFactoryJmapTest.php
@@ -0,0 +1,43 @@
+protocolFactory = Server::get(ProtocolFactory::class);
+ }
+
+ public function testJmapClientConnection(): void {
+ $account = new Account($this->createTestAccount());
+
+ $client = $this->protocolFactory->jmapClient($account);
+
+ $this->assertInstanceOf(JmapClient::class, $client);
+
+ $session = $client->connect();
+
+ $this->assertTrue($client->sessionStatus(), 'JMAP session should be established');
+ $this->assertNotEmpty($session->username(), 'Session should report a username');
+ $this->assertNotEmpty($session->commandUrl(), 'Session should provide an API URL');
+ }
+}
diff --git a/tests/Unit/Command/CreateAccountTest.php b/tests/Unit/Command/CreateImapAccountTest.php
similarity index 84%
rename from tests/Unit/Command/CreateAccountTest.php
rename to tests/Unit/Command/CreateImapAccountTest.php
index 9f523c8d83..4f59b775f5 100644
--- a/tests/Unit/Command/CreateAccountTest.php
+++ b/tests/Unit/Command/CreateImapAccountTest.php
@@ -11,13 +11,13 @@
namespace OCA\Mail\Tests\Unit\Command;
use ChristophWurst\Nextcloud\Testing\TestCase;
-use OCA\Mail\Command\CreateAccount;
+use OCA\Mail\Command\CreateImapAccount;
use OCA\Mail\Service\Classification\ClassificationSettingsService;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-class CreateAccountTest extends TestCase {
+class CreateImapAccountTest extends TestCase {
private $service;
private $crypto;
private $userManager;
@@ -50,15 +50,19 @@ protected function setUp(): void {
$this->userManager = $this->createMock(IUserManager::class);
$this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class);
- $this->command = new CreateAccount($this->service, $this->crypto, $this->userManager, $this->classificationSettingsService);
+ $this->command = new CreateImapAccount($this->service, $this->crypto, $this->userManager, $this->classificationSettingsService);
}
public function testName() {
- $this->assertSame('mail:account:create', $this->command->getName());
+ $this->assertSame('mail:account:create-imap', $this->command->getName());
+ }
+
+ public function testAlias() {
+ $this->assertSame(['mail:account:create'], $this->command->getAliases());
}
public function testDescription() {
- $this->assertSame('creates IMAP account', $this->command->getDescription());
+ $this->assertSame('creates an IMAP mail account', $this->command->getDescription());
}
public function testArguments() {
diff --git a/tests/Unit/Command/CreateJmapAccountTest.php b/tests/Unit/Command/CreateJmapAccountTest.php
new file mode 100644
index 0000000000..aa1012a97d
--- /dev/null
+++ b/tests/Unit/Command/CreateJmapAccountTest.php
@@ -0,0 +1,166 @@
+service = $this->getMockBuilder(AccountService::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->crypto = $this->createMock(ICrypto::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class);
+
+ $this->command = new CreateJmapAccount(
+ $this->service,
+ $this->crypto,
+ $this->userManager,
+ $this->classificationSettingsService,
+ );
+ }
+
+ public function testName(): void {
+ $this->assertSame('mail:account:create-jmap', $this->command->getName());
+ }
+
+ public function testDescription(): void {
+ $this->assertSame('creates a JMAP mail account', $this->command->getDescription());
+ }
+
+ public function testArguments(): void {
+ $actual = $this->command->getDefinition()->getArguments();
+
+ foreach ($actual as $actArg) {
+ if ($actArg->getName() === 'path') {
+ self::assertFalse($actArg->isRequired());
+ } else {
+ self::assertTrue($actArg->isRequired());
+ }
+ self::assertTrue(in_array($actArg->getName(), $this->args, true));
+ }
+ }
+
+ public function testInvalidUserId(): void {
+ $userId = 'invalidUser';
+ $data = [
+ 'user-id' => $userId,
+ 'name' => '',
+ 'email' => '',
+ 'host' => '',
+ 'port' => 0,
+ 'ssl-mode' => '',
+ 'basic-auth-user' => '',
+ 'basic-auth-password' => '',
+ 'path' => null,
+ ];
+
+ $input = $this->createMock(InputInterface::class);
+ $input->method('getArgument')
+ ->willReturnCallback(fn (string $arg) => $data[$arg] ?? null);
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with("User $userId does not exist");
+
+ $this->userManager->expects($this->once())
+ ->method('userExists')
+ ->with($userId)
+ ->willReturn(false);
+
+ $this->assertEquals(1, $this->command->run($input, $output));
+ }
+
+ public function testExecuteCreatesJmapAccount(): void {
+ $data = [
+ 'user-id' => 'user-id',
+ 'name' => 'Personal',
+ 'email' => 'user@example.com',
+ 'host' => 'mail.example.com',
+ 'port' => '443',
+ 'ssl-mode' => 'ssl',
+ 'basic-auth-user' => 'jmap-user',
+ 'basic-auth-password' => 'jmap-password',
+ 'path' => '/.well-known/jmap',
+ ];
+
+ $input = $this->createMock(InputInterface::class);
+ $input->method('getArgument')
+ ->willReturnCallback(fn (string $arg) => $data[$arg] ?? null);
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with('JMAP account 42 for user@example.com created');
+
+ $this->userManager->expects($this->once())
+ ->method('userExists')
+ ->with('user-id')
+ ->willReturn(true);
+
+ $this->crypto->expects($this->once())
+ ->method('encrypt')
+ ->with('jmap-password')
+ ->willReturn('encrypted-password');
+
+ $this->classificationSettingsService->expects($this->once())
+ ->method('isClassificationEnabledByDefault')
+ ->willReturn(true);
+
+ $this->service->expects($this->once())
+ ->method('save')
+ ->willReturnCallback(function (MailAccount $account): MailAccount {
+ self::assertSame('user-id', $account->getUserId());
+ self::assertSame('Personal', $account->getName());
+ self::assertSame('user@example.com', $account->getEmail());
+ self::assertSame(MailAccount::PROTOCOL_JMAP, $account->getProtocol());
+ self::assertSame('mail.example.com', $account->getInboundHost());
+ self::assertSame(443, $account->getInboundPort());
+ self::assertSame('ssl', $account->getInboundSslMode());
+ self::assertSame('jmap-user', $account->getInboundUser());
+ self::assertSame('encrypted-password', $account->getInboundPassword());
+ self::assertSame('/.well-known/jmap', $account->getPath());
+ self::assertTrue($account->getClassificationEnabled());
+
+ $account->setId(42);
+ return $account;
+ });
+
+ $this->assertEquals(0, $this->command->run($input, $output));
+ }
+}