diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f785c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log + +# Old files +lib.old/ +src.old/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..da91e74 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# IMAP Mail Provider Module + +This module provides an implementation of an IMAP mail provider using the `gricob/imap` library. It is designed to facilitate email operations such as managing mailboxes and messages through the IMAP protocol. + +## Features + +- **Service Location**: Configures connection details including host, port, and encryption type. +- **Service Identity**: Manages user credentials securely. +- **Mailbox Management**: Supports operations for listing, fetching, creating, modifying, and deleting mailboxes and messages. +- **Autodiscovery**: Implements methods for discovering IMAP services automatically. + +## Installation + +To install the module, run the following command in the module directory: + +```bash +composer install +``` + +This will install the required dependencies, including `gricob/imap`. + +## Usage + +1. **Service Test**: Use the `serviceTest()` method to check connectivity to the IMAP server. +2. **Discover Services**: Call `serviceDiscover()` to find available mail services. +3. **Mailbox Operations**: Utilize the `RemoteMailService` to perform operations such as listing mailboxes and managing messages. + +## Example + +```php +$provider = new KTXM\ProviderImapMail\Providers\Provider(); +$provider->serviceTest(); +$mailboxes = $provider->serviceDiscover(); +``` + +## Contributing + +Contributions are welcome! Please submit a pull request or open an issue for any enhancements or bug fixes. + +## License + +This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..14c7d7a --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "ktxm/provider-imap-mail", + "description": "IMAP Mail Provider Module", + "type": "library", + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "ProviderImapMail", + "vendor-dir": "lib/vendor", + "allow-plugins": { + "bamarni/composer-bin-plugin": true + } + }, + "require": { + "php": ">=8.2 <=8.5", + "ext-iconv": "*", + "ext-ctype": "*", + "psr/log": "^1.0|^2.0|^3.0", + "doctrine/lexer": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "KTXM\\ProviderImapMail\\": "lib/", + "Gricob\\IMAP\\": "lib/Client" + } + }, + "autoload-dev": { + "psr-4": { + "KTXT\\ProviderImapMail\\": "tests/php/" + } + }, + "scripts": { + "post-install-cmd": [ + ], + "post-update-cmd": [ + ], + "test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox", + "test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text" + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..a45a0f4 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1935 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "46ea36f63cc6f77b99348c76fc0f72b2", + "packages": [ + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2 <=8.5", + "ext-iconv": "*", + "ext-ctype": "*" + }, + "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.3.0" +} diff --git a/lib/Client/Client.php b/lib/Client/Client.php new file mode 100644 index 0000000..f475183 --- /dev/null +++ b/lib/Client/Client.php @@ -0,0 +1,515 @@ +transport, + $configuration->host, + $configuration->port, + $configuration->timeout, + $configuration->verifyPeer, + $configuration->verifyPeerName, + $configuration->allowSelfSigned, + ); + + if (null !== $logger) { + $connection = new TraceableConnection($connection, $logger); + } + + $this->configuration = $configuration; + $this->imap = new Imap($connection); + $this->selectedMailbox = new Mailbox([], '', ''); + } + + public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self + { + return new self($configuration, $logger); + } + + public function connect(): void + { + $this->imap->connect(); + } + + /** + * Perform STARTTLS negotiation (patch). + * + * Call after connect() but before logIn(). The underlying Imap protocol + * layer sends the STARTTLS command and upgrades the socket to TLS. + */ + public function startTls(): void + { + $this->imap->startTls(); + } + + public function disconnect(): void + { + $this->imap->disconnect(); + } + + public function logIn(string $username, string $password): void + { + $this->send(new LogInCommand($username, $password)); + } + + public function authenticate(SASLMechanism $mechanism): void + { + $this->send(new AuthenticateCommand($mechanism)); + } + + /** + * @return array + */ + public function mailboxes(string $referenceName = '', string $pattern = '*'): array + { + $response = $this->send(new ListCommand($referenceName, $pattern)); + + return array_map( + fn (ListData $data) => new Mailbox($data->nameAttributes, $data->hierarchyDelimiter, $data->name), + $response->getData(ListData::class), + ); + } + + public function select(Mailbox|string $mailbox): Mailbox + { + if (is_string($mailbox)) { + $mailbox = new Mailbox([], '', $mailbox); + } + + $response = $this->send(new SelectCommand($mailbox->name)); + + if ($flagsData = $response->getData(FlagsData::class)[0] ?? null) { + $mailbox->flags = $flagsData->flags; + } + + if ($existsData = $response->getData(ExistsData::class)[0] ?? null) { + $mailbox->exists = $existsData->numberOfMessages; + } + + if ($recentData = $response->getData(RecentData::class)[0] ?? null) { + $mailbox->recent = $recentData->numberOfMessages; + } + + foreach ($response->getData(Status::class) as $status) { + if ($status->code instanceof UnseenCode) { + $mailbox->unseen = $status->code->seq; + } elseif ($status->code instanceof UidValidityCode) { + $mailbox->uidValidity = $status->code->value; + } elseif ($status->code instanceof UidNextCode) { + $mailbox->uidNext = $status->code->value; + } elseif ($status->code instanceof PermanentFlagsCode) { + $mailbox->permanentFlags = $status->code->flags; + } + } + + return $this->selectedMailbox = $mailbox; + } + + public function search(): Search + { + return new Search($this); + } + + /** + * @throws MessageNotFound + */ + public function fetch(int $id): Message + { + $response = $this->imap->send( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet($id), + ['INTERNALDATE', 'BODY[HEADER]', 'BODYSTRUCTURE'] + ) + ); + + $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); + + if (null === $internalDate = $data->internalDate) { + throw new Exception('Unable to fetch internal date from message '.$id); + } + + if (null === $part = $data->bodyStructure?->part) { + throw new Exception('Unable to fetch body structure from message '.$id); + } + + return new Message( + $id, + $this->createHeaders($data) ?? [], + $this->createMessagePart($id, '0', $part), + $internalDate, + ); + } + + /** + * Stream FetchData for a specific set of UIDs, one response line at a time. + * + * Uses the same sendStreaming path as fetchMultiple() so responses are + * processed as they arrive off the socket without buffering the entire + * server reply. Items can be tailored per call-site; defaults to a rich + * set that populates EntityResource fully (flags, envelope, body structure, + * size, arrival date). + * + * @param int[] $uids + * @param string[] $items IMAP fetch data items + * @return Generator Yields uid => FetchData + */ + public function streamByUids( + array $uids, + array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'], + ): Generator { + $gen = $this->imap->sendStreaming( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet(...$uids), + $items, + ) + ); + + foreach ($gen as $line) { + if (!$line instanceof FetchData) { + continue; + } + + $id = $line->id; + if ($this->configuration->useUid) { + $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); + } + + yield $id => $line; + } + } + + /** + * Stream messages from a sequence range as a Generator, yielding each + * LazyMessage as soon as its FETCH response line arrives off the socket — + * without waiting for the entire batch to complete. + * + * Usage with an NDJSON HTTP response: + * + * foreach ($client->fetchMultiple(1, 50) as $message) { + * echo json_encode($message) . "\n"; + * flush(); + * } + * + * @param int $from First sequence number (inclusive) + * @param int $to Last sequence number (inclusive) + * @return Generator + */ + public function fetchMultiple(int $from, int $to): Generator + { + $items = ['FLAGS', 'INTERNALDATE', 'BODY[HEADER]']; + + $gen = $this->imap->sendStreaming( + new FetchCommand( + $this->configuration->useUid, + SequenceSet::range($from, $to), + $items, + ) + ); + + foreach ($gen as $line) { + if (!$line instanceof FetchData) { + continue; + } + + $id = $line->id; + if ($this->configuration->useUid) { + $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); + } + + yield new LazyMessage( + $this, + $id, + $this->createHeaders($line), + $line->internalDate, + ); + } + } + + /** + * @return array + * @throws MessageNotFound + */ + public function fetchHeaders(int $id): array + { + $response = $this->imap->send( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet($id), + ['BODY[HEADER]'] + ) + ); + + /** @var FetchData $data */ + $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); + + return $this->createHeaders($data) ?? []; + } + + public function fetchBody(int $id): Part + { + $response = $this->send( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet($id), + ['BODYSTRUCTURE'] + ) + ); + + $data = $response->getData(FetchData::class)[0]; + + if (null === $part = $data->bodyStructure?->part) { + throw new Exception('Unable to fetch body from message '.$id); + } + + return $this->createMessagePart($id, '0', $part); + } + + public function fetchInternalDate(int $id): DateTimeImmutable + { + $response = $this->send( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet($id), + ['INTERNALDATE'] + ) + ); + + $data = $response->getData(FetchData::class)[0]; + + if (null === $internalDate = $data->internalDate) { + throw new Exception('Unable to fetch internal date from message '.$id); + } + + return $internalDate; + } + + public function fetchSectionBody(int $id, string $section): string + { + $response = $this->send( + new FetchCommand( + $this->configuration->useUid, + new SequenceSet($id), + ["BODY[$section]"] + ) + ); + + $data = $response->getData(FetchData::class)[0]; + + return $data->getBodySection($section)?->text ?? ''; + } + + public function deleteMessage(Message|int $message): void + { + $id = $message instanceof Message ? $message->id() : $message; + + $this->send( + new StoreCommand( + $this->configuration->useUid, + new SequenceSet($id), + new Flags(['\Deleted'], '+') + ) + ); + + $this->send(new ExpungeCommand()); + } + + public function createMailbox(string $name): void + { + $this->send(new CreateCommand($name)); + } + + /** + * @param list|null $flags + */ + public function append( + string $message, + string $mailbox = 'INBOX', + ?array $flags = null, + ?DateTimeInterface $internalDate = null + ): int + { + $response = $this->send(new AppendCommand($mailbox, $message, $flags, $internalDate)); + + $code = $response->status->code; + if ($code instanceof AppendUidCode) { + return $code->uid; + } + + throw new RuntimeException('Unable to retrieve uid from append response'); + } + + public function send(Command $command): Response + { + $this->imap->connect(); + + return $this->imap->send($command); + } + + /** + * @param array $criteria + * @return array + */ + public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array + { + $response = $this->send( + new Protocol\Command\SearchCommand( + $this->configuration->useUid, + ...$criteria + ) + ); + + $ids = []; + foreach ($response->data as $data) { + if ($data instanceof SearchData) { + array_push($ids, ...$data->numbers); + } + } + + if (empty($ids)) { + return []; + } + + if (null !== $preFetchOptions) { + $items = []; + + if ($preFetchOptions->headers) { + $items[] = 'BODY[HEADER]'; + } + + if ($preFetchOptions->internalDate) { + $items[] = 'INTERNALDATE'; + } + + $preFetchResult = $this->send(new FetchCommand( + $this->configuration->useUid, + new SequenceSet(...$ids), + $items, + )); + + $messages = []; + foreach ($preFetchResult->data as $data) { + if ($data instanceof FetchData) { + $id = $data->id; + if ($this->configuration->useUid) { + $id = $data->uid ?? throw new RuntimeException('Unable to get uid from message '.$id); + } + + $messages[] = new LazyMessage( + $this, + $id, + $this->createHeaders($data), + $data->internalDate, + ); + } + } + + return $messages; + } + + return array_map(fn (int $id) => new LazyMessage($this, $id), $ids); + } + + /** + * @return array|null + */ + private function createHeaders(FetchData $data): ?array + { + if (null === $headerSection = $data->getBodySection('HEADER')) { + return null; + } + + return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: []; + } + + private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part + { + if ($part instanceof BodyStructure\SinglePart) { + return new SinglePart( + $part->type, + $part->subtype, + $part->attributes, + new LazyBody($this, $id, $section === '0' ? '1' : $section), + $part->attributes['charset'] ?? 'utf-8', + $part->encoding, + null !== $part->disposition + ? new Disposition( + $part->disposition->type, + $part->disposition->attributes['filename'] ?? null + ) : null, + ); + } + + if (!$part instanceof BodyStructure\MultiPart) { + throw new Exception('Unable to create message part from body structure part of class '.$part::class); + } + + $childParts = []; + foreach ($part->parts as $index => $childPart) { + $childIndex = (string) ($index + 1); + $childSection = $section === '0' ? $childIndex : $section.'.'.$childIndex; + $childParts[] = $this->createMessagePart($id, $childSection, $childPart); + } + + return new MultiPart($part->subtype, $part->attributes, $childParts); + } +} diff --git a/lib/Client/Configuration.php b/lib/Client/Configuration.php new file mode 100644 index 0000000..9c82339 --- /dev/null +++ b/lib/Client/Configuration.php @@ -0,0 +1,20 @@ + $nameAttributes + */ + public function __construct( + public array $nameAttributes, + public string $hierarchyDelimiter, + public string $name, + ) { + } + + public function isSelectable(): bool + { + return !in_array(self::ATTRIBUTE_NOSELECT, $this->nameAttributes); + } +} \ No newline at end of file diff --git a/lib/Client/MessageNotFound.php b/lib/Client/MessageNotFound.php new file mode 100644 index 0000000..53093c7 --- /dev/null +++ b/lib/Client/MessageNotFound.php @@ -0,0 +1,11 @@ +id = $id; + + if (null !== $headers) { + $this->headers = $headers; + } + + if (null !== $internalDate) { + $this->internalDate = $internalDate; + } + } + + public function headers(): array + { + if (!isset($this->headers)) { + $this->headers = $this->client->fetchHeaders($this->id); + } + + return parent::headers(); + } + + public function body(): Part + { + if (!isset($this->body)) { + $this->body = $this->client->fetchBody($this->id); + } + + return parent::body(); + } + + public function internalDate(): DateTimeImmutable + { + if (!isset($this->internalDate)) { + $this->internalDate = $this->client->fetchInternalDate($this->id); + } + + return parent::internalDate(); + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Message.php b/lib/Client/Mime/Message.php new file mode 100644 index 0000000..71836c3 --- /dev/null +++ b/lib/Client/Mime/Message.php @@ -0,0 +1,55 @@ + $headers + */ + public function __construct( + protected int $id, + protected array $headers, + protected Part $body, + protected DateTimeImmutable $internalDate, + ) { + } + + public function id(): int + { + return $this->id; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + + public function body(): Part + { + return $this->body; + } + + public function internalDate(): DateTimeImmutable + { + return $this->internalDate; + } + + public function textBody(): ?string + { + return $this->body()->findPartByMimeType('text/plain')?->decodedBody(); + } + + public function htmlBody(): ?string + { + return $this->body()->findPartByMimeType('text/html')?->decodedBody(); + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Body.php b/lib/Client/Mime/Part/Body.php new file mode 100644 index 0000000..49e9e50 --- /dev/null +++ b/lib/Client/Mime/Part/Body.php @@ -0,0 +1,20 @@ +value; + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Disposition.php b/lib/Client/Mime/Part/Disposition.php new file mode 100644 index 0000000..e7d26b6 --- /dev/null +++ b/lib/Client/Mime/Part/Disposition.php @@ -0,0 +1,14 @@ +value)) { + $this->value = $this->client->fetchSectionBody($this->id, $this->section); + } + + return $this->value; + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Part/MultiPart.php b/lib/Client/Mime/Part/MultiPart.php new file mode 100644 index 0000000..e1151cb --- /dev/null +++ b/lib/Client/Mime/Part/MultiPart.php @@ -0,0 +1,31 @@ + $attributes + * @param list $parts + */ + public function __construct( + string $subtype, + array $attributes, + public array $parts, + ) { + parent::__construct('multipart', $subtype, $attributes); + } + + public function findPartByMimeType(string $mimeType): ?SinglePart + { + foreach ($this->parts as $part) { + if ($matchedPart = $part->findPartByMimeType(strtolower($mimeType))) { + return $matchedPart; + } + } + + return null; + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Part.php b/lib/Client/Mime/Part/Part.php new file mode 100644 index 0000000..467d059 --- /dev/null +++ b/lib/Client/Mime/Part/Part.php @@ -0,0 +1,36 @@ + + */ + public array $attributes; + + /** + * @param array $attributes + */ + public function __construct( + string $type, + string $subtype, + array $attributes, + ) { + $this->subtype = strtolower($subtype); + $this->type = strtolower($type); + $this->attributes = $attributes; + } + + abstract public function findPartByMimeType(string $mimeType): ?SinglePart; + + public function mimeType(): string + { + return $this->type.'/'.$this->subtype; + } +} \ No newline at end of file diff --git a/lib/Client/Mime/Part/SinglePart.php b/lib/Client/Mime/Part/SinglePart.php new file mode 100644 index 0000000..d94e828 --- /dev/null +++ b/lib/Client/Mime/Part/SinglePart.php @@ -0,0 +1,62 @@ +encoding = strtolower($encoding); + parent::__construct($type, $subtype, $attributes); + } + + public function body(): string + { + return (string) $this->body; + } + + public function decodedBody(): string + { + return match ($this->encoding) { + 'quoted-printable' => quoted_printable_decode($this->body()), + 'base64' => base64_decode($this->body()), + default => $this->body(), + }; + } + + public function charset(): string + { + return $this->charset; + } + + public function encoding(): string + { + return $this->encoding; + } + + public function disposition(): ?Disposition + { + return $this->disposition; + } + + public function findPartByMimeType(string $mimeType): ?SinglePart + { + if ($this->mimeType() === strtolower($mimeType)) { + return $this; + } + + return null; + } +} diff --git a/lib/Client/PreFetchOptions.php b/lib/Client/PreFetchOptions.php new file mode 100644 index 0000000..753419c --- /dev/null +++ b/lib/Client/PreFetchOptions.php @@ -0,0 +1,14 @@ +|null $flags + */ + public function __construct( + string $mailboxName, + private string $message, + ?array $flags, + ?DateTimeInterface $internalDate + ) { + parent::__construct( + 'APPEND', + ...array_filter([ + new QuotedString($mailboxName), + ParenthesizedList::tryFrom($flags), + DateTime::tryFrom($internalDate), + new SynchronizingLiteral($this->message), + ]) + ); + } + + public function continue(): string + { + return $this->message; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Argument.php b/lib/Client/Protocol/Command/Argument/Argument.php new file mode 100644 index 0000000..f077eaf --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Argument.php @@ -0,0 +1,10 @@ +value->format('d-M-Y'); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/DateTime.php b/lib/Client/Protocol/Command/Argument/DateTime.php new file mode 100644 index 0000000..edd3074 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/DateTime.php @@ -0,0 +1,24 @@ +value->format('d-M-Y H:i:s O').'"'; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/ParenthesizedList.php b/lib/Client/Protocol/Command/Argument/ParenthesizedList.php new file mode 100644 index 0000000..46b1c3d --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/ParenthesizedList.php @@ -0,0 +1,28 @@ + $items + */ + public function __construct(public array $items) + { + } + + /** + * @param list $items + */ + public static function tryFrom(?array $items): ?self + { + return empty($items) ? null : new self($items); + } + + public function __toString(): string + { + return sprintf('(%s)', implode(' ', $this->items)); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/QuotedString.php b/lib/Client/Protocol/Command/Argument/QuotedString.php new file mode 100644 index 0000000..6bb178b --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/QuotedString.php @@ -0,0 +1,17 @@ +value); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/All.php b/lib/Client/Protocol/Command/Argument/Search/All.php new file mode 100644 index 0000000..de29f88 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/All.php @@ -0,0 +1,13 @@ +fieldName, new QuotedString($this->value)); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/Not.php b/lib/Client/Protocol/Command/Argument/Search/Not.php new file mode 100644 index 0000000..2b3756c --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Not.php @@ -0,0 +1,17 @@ +criteria.')'; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/Since.php b/lib/Client/Protocol/Command/Argument/Search/Since.php new file mode 100644 index 0000000..a5faef2 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Since.php @@ -0,0 +1,15 @@ + + */ + private array $numbers; + private ?string $range; + + public function __construct(int ...$numbers) + { + $this->numbers = $numbers; + $this->range = null; + } + + public static function range(int $from, int $to): self + { + $set = new self(); + $set->range = $from . ':' . $to; + return $set; + } + + public function __toString(): string + { + if ($this->range !== null) { + return $this->range; + } + + return implode(',', $this->numbers); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Store/Flags.php b/lib/Client/Protocol/Command/Argument/Store/Flags.php new file mode 100644 index 0000000..62503f4 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Store/Flags.php @@ -0,0 +1,30 @@ + $flags + */ + public function __construct( + private array $flags, + private string $modifier = '', + private bool $silent = true, + ) { + } + + public function __toString(): string + { + return sprintf( + '%sFLAGS%s (%s)', + $this->modifier, + $this->silent ? '.SILENT' : '', + implode(' ', $this->flags), + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php b/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php new file mode 100644 index 0000000..50a9022 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php @@ -0,0 +1,20 @@ +value) + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php b/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php new file mode 100644 index 0000000..97f7247 --- /dev/null +++ b/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php @@ -0,0 +1,12 @@ +user, $this->accessToken) + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/AuthenticateCommand.php b/lib/Client/Protocol/Command/AuthenticateCommand.php new file mode 100644 index 0000000..81e3ac7 --- /dev/null +++ b/lib/Client/Protocol/Command/AuthenticateCommand.php @@ -0,0 +1,20 @@ +mechanism->continue(); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Command.php b/lib/Client/Protocol/Command/Command.php new file mode 100644 index 0000000..8ed7576 --- /dev/null +++ b/lib/Client/Protocol/Command/Command.php @@ -0,0 +1,40 @@ +command = $command; + $this->arguments = $arguments; + } + + public function command(): string + { + return $this->command; + } + + public function __toString(): string + { + return sprintf( + '%s %s', + $this->command, + implode(' ', $this->arguments) + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Continuable.php b/lib/Client/Protocol/Command/Continuable.php new file mode 100644 index 0000000..0ea6545 --- /dev/null +++ b/lib/Client/Protocol/Command/Continuable.php @@ -0,0 +1,10 @@ + $items + */ + public function __construct( + bool $uid, + SequenceSet $sequenceSet, + array $items, + ) { + parent::__construct( + $uid ? 'UID FETCH' : 'FETCH', + $sequenceSet, + new ParenthesizedList($items), + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/ListCommand.php b/lib/Client/Protocol/Command/ListCommand.php new file mode 100644 index 0000000..720b4dd --- /dev/null +++ b/lib/Client/Protocol/Command/ListCommand.php @@ -0,0 +1,19 @@ +message); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/CommandInteraction.php b/lib/Client/Protocol/CommandInteraction.php new file mode 100644 index 0000000..5195564 --- /dev/null +++ b/lib/Client/Protocol/CommandInteraction.php @@ -0,0 +1,70 @@ +tag, + $this->command, + ); + + $this->connection->send($request); + $streamResponse = $this->connection->receive(); + + return $this->responseHandler->handle($this->tag, $streamResponse, $this); + } + + /** + * Like interact() but yields each untagged Line immediately as it arrives. + * The terminal Status is the generator's return value. + * + * @return Generator + */ + public function streamInteract(): Generator + { + $request = sprintf( + "%s %s\r\n", + $this->tag, + $this->command, + ); + + $this->connection->send($request); + $streamResponse = $this->connection->receive(); + + yield from $this->responseHandler->stream($this->tag, $streamResponse, $this); + } + + public function continue(): void + { + if (!$this->command instanceof Continuable) { + throw new RuntimeException( + sprintf('Command %s does not support continuable interaction', $this->command->command()) + ); + } + + $this->connection->send($this->command->continue()."\r\n"); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/ConnectionRejected.php b/lib/Client/Protocol/ConnectionRejected.php new file mode 100644 index 0000000..ea39c9d --- /dev/null +++ b/lib/Client/Protocol/ConnectionRejected.php @@ -0,0 +1,11 @@ +connection = $connection; + $this->tagGenerator = new TagGenerator(); + $this->responseHandler = new ResponseHandler(new Parser()); + } + + public function __destruct() + { + $this->disconnect(); + } + + public function connect(): void + { + if ($this->connection->isOpen()) { + return; + } + + $this->connection->open(); + + $responseStream = $this->connection->receive(); + + $greeting = $this->responseHandler->handle('*', $responseStream, new UnexpectedContinuationHandler()); + + match ($greeting->status->type) { + StatusType::OK => null, // Do nothing + StatusType::PREAUTH => throw new RuntimeException('pre-auth is not supported'), + StatusType::BAD, + StatusType::NO, + StatusType::BYE => throw new ConnectionRejected($greeting->status->message), + }; + } + + public function disconnect(): void + { + $this->connection->close(); + } + + /** + * Perform STARTTLS negotiation (patch). + * + * Sends the STARTTLS command and upgrades the underlying socket to TLS. + * The connection must be a SocketConnection (or any Connection that + * implements upgradeTls()). Call this after connect() but before logIn(). + * + * @throws \RuntimeException if the server rejects STARTTLS + * @throws \BadMethodCallException if the connection does not support TLS upgrade + */ + public function startTls(): void + { + if (!method_exists($this->connection, 'upgradeTls')) { + throw new \BadMethodCallException( + 'The current Connection implementation does not support STARTTLS upgrade' + ); + } + + $response = $this->send(new StartTlsCommand()); + + if ($response->status->type !== StatusType::OK) { + throw new \RuntimeException( + 'Server rejected STARTTLS: ' . $response->status->message + ); + } + + $this->connection->upgradeTls(); + } + + public function send(Command $command): Response + { + $interaction = new CommandInteraction( + $this->connection, + $this->responseHandler, + $this->tagGenerator->next(), + $command, + ); + + $response = $interaction->interact(); + + if ($response->status->type != StatusType::OK) { + throw CommandFailed::withStatus($response->status); + } + + return $response; + } + + /** + * Sends $command and returns a Generator that yields each untagged Line as + * it arrives from the socket. CommandFailed is thrown (inside the generator) + * if the server responds with NO or BAD. + * + * @return Generator + */ + public function sendStreaming(Command $command): Generator + { + $this->connect(); + + $interaction = new CommandInteraction( + $this->connection, + $this->responseHandler, + $this->tagGenerator->next(), + $command, + ); + + yield from $interaction->streamInteract(); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/CommandContinuation.php b/lib/Client/Protocol/Response/Line/CommandContinuation.php new file mode 100644 index 0000000..5305850 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/CommandContinuation.php @@ -0,0 +1,13 @@ + $capabilities + */ + public function __construct(public array $capabilities) + { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Data.php b/lib/Client/Protocol/Response/Line/Data/Data.php new file mode 100644 index 0000000..3bd72a0 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Data.php @@ -0,0 +1,11 @@ + $attributes + */ + public function __construct( + public string $type, + public array $attributes, + ) { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php new file mode 100644 index 0000000..e7fae11 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php @@ -0,0 +1,42 @@ + $attributes + * @param string[]|null $language + */ + public function __construct( + array $attributes, + ?string $id, + ?string $description, + string $encoding, + int $size, + public Envelope $envelope, + public BodyStructure $bodyStructure, + public int $textLines, + ?string $md5, + ?Disposition $disposition, + ?array $language, + ?string $location, + ) { + parent::__construct( + 'MESSAGE', + 'RFC822', + $attributes, + $id, + $description, + $encoding, + $size, + $md5, + $disposition, + $language, + $location, + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php new file mode 100644 index 0000000..268881a --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php @@ -0,0 +1,24 @@ + $attributes + * @param string[] $language + * @param list $parts + */ + public function __construct( + string $subtype, + array $attributes, + public array $parts, + public ?Disposition $disposition, + public ?array $language, + public ?string $location, + ) { + parent::__construct('MULTIPART', $subtype, $attributes); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php new file mode 100644 index 0000000..78f2302 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php @@ -0,0 +1,18 @@ + $attributes + */ + public function __construct( + public string $type, + public string $subtype, + public array $attributes, + ) { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php new file mode 100644 index 0000000..69c3678 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php @@ -0,0 +1,28 @@ + $attributes + * @param string[]|null $language + */ + public function __construct( + string $type, + string $subtype, + array $attributes, + public ?string $id, + public ?string $description, + public string $encoding, + public int $size, + public ?string $md5, + public ?Disposition $disposition, + public ?array $language, + public ?string $location, + ) { + parent::__construct($type, $subtype, $attributes); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php new file mode 100644 index 0000000..d4c41e9 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php @@ -0,0 +1,38 @@ + $attributes + * @param string[]|null $language + */ + public function __construct( + string $subtype, + array $attributes, + ?string $id, + ?string $description, + string $encoding, + int $size, + public int $textLines, + ?string $md5, + ?Disposition $disposition, + ?array $language, + ?string $location, + ) { + parent::__construct( + 'TEXT', + $subtype, + $attributes, + $id, + $description, + $encoding, + $size, + $md5, + $disposition, + $language, + $location, + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php b/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php new file mode 100644 index 0000000..162d41c --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php @@ -0,0 +1,32 @@ +|null $flags + * @param BodySection[] $bodySections + */ + public function __construct( + public int $id, + public ?array $flags = null, + public ?\DateTimeImmutable $internalDate = null, + public ?Envelope $envelope = null, + public ?int $rfc822Size = null, + public ?string $rfc822 = null, + public ?int $uid = null, + public ?BodyStructure $bodyStructure = null, + public array $bodySections = [], + ) { + } + + public function getBodySection(string $name): ?BodySection + { + foreach (($this->bodySections ?? []) as $bodySection) { + if ($bodySection->section == $name) { + return $bodySection; + } + } + + return null; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/FlagsData.php b/lib/Client/Protocol/Response/Line/Data/FlagsData.php new file mode 100644 index 0000000..4761eb0 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/FlagsData.php @@ -0,0 +1,15 @@ + $flags + */ + public function __construct(public array $flags) + { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/ListData.php b/lib/Client/Protocol/Response/Line/Data/ListData.php new file mode 100644 index 0000000..99846fd --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/ListData.php @@ -0,0 +1,18 @@ + $nameAttributes + */ + public function __construct( + public array $nameAttributes, + public string $hierarchyDelimiter, + public string $name + ) { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/RecentData.php b/lib/Client/Protocol/Response/Line/Data/RecentData.php new file mode 100644 index 0000000..10a8431 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Data/RecentData.php @@ -0,0 +1,12 @@ + $numbers + */ + public function __construct(public array $numbers) + { + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Line.php b/lib/Client/Protocol/Response/Line/Line.php new file mode 100644 index 0000000..a650689 --- /dev/null +++ b/lib/Client/Protocol/Response/Line/Line.php @@ -0,0 +1,9 @@ + + */ +class Lexer extends AbstractLexer +{ + protected function getCatchablePatterns(): array + { + return [ + '[a-zA-Z0-9\.\-]+', + '\r\n', + ]; + } + + protected function getNonCatchablePatterns(): array + { + return []; + } + + protected function getType(string &$value) + { + $normalizedValue = strtoupper($value); + + return match($normalizedValue) { + ' ' => TokenType::SP, + '.' => TokenType::DOT, + '*' => TokenType::ASTERISK, + '%' => TokenType::PERCENT_SIGN, + '+' => TokenType::PLUS_SIGN, + '=' => TokenType::EQUALS_SIGN, + '"' => TokenType::DOUBLE_QUOTE, + '[' => TokenType::OPEN_BRACKETS, + ']' => TokenType::CLOSE_BRACKETS, + '{' => TokenType::OPEN_BRACES, + '}' => TokenType::CLOSE_BRACES, + '(' => TokenType::OPEN_PARENTHESIS, + ')' => TokenType::CLOSE_PARENTHESIS, + '\\' => TokenType::BACKSLASH, + "\r\n" => TokenType::CRLF, + 'NIL' => TokenType::NIL, + 'OK', 'NO', 'BAD', 'BYE', 'PREAUTH' => TokenType::STATUS, + 'APPENDUID' => TokenType::APPENDUID, + 'UNSEEN' => TokenType::UNSEEN, + 'UIDVALIDITY' => TokenType::UIDVALIDITY, + 'UIDNEXT' => TokenType::UIDNEXT, + 'PERMANENTFLAGS' => TokenType::PERMANENTFLAGS, + 'READ-WRITE' => TokenType::READ_WRITE, + 'READ-ONLY' => TokenType::READ_ONLY, + 'CAPABILITY' => TokenType::CAPABILITY, + 'LIST' => TokenType::LIST, + 'FLAGS' => TokenType::FLAGS, + 'RECENT' => TokenType::RECENT, + 'FETCH' => TokenType::FETCH, + 'INTERNALDATE' => TokenType::INTERNALDATE, + 'SEARCH' => TokenType::SEARCH, + 'EXISTS' => TokenType::EXISTS, + 'EXPUNGE' => TokenType::EXPUNGE, + 'BODY' => TokenType::BODY, + 'BODYSTRUCTURE' => TokenType::BODYSTRUCTURE, + 'ENVELOPE' => TokenType::ENVELOPE, + 'RFC822' => TokenType::RFC822, + 'RFC822.SIZE' => TokenType::RFC822_SIZE, + 'RFC822.TEXT' => TokenType::RFC822_TEXT, + 'RFC822.HEAD' => TokenType::RFC822_HEAD, + 'UID' => TokenType::UID, + default => match (true) { + is_numeric($value) => TokenType::NUMBER, + ctype_alnum($value) => TokenType::ALPHANUMERIC, + ctype_cntrl($value) => TokenType::CTL, + default => TokenType::UNKNOWN, + }, + }; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Parser/ParseError.php b/lib/Client/Protocol/Response/Parser/ParseError.php new file mode 100644 index 0000000..fda30f3 --- /dev/null +++ b/lib/Client/Protocol/Response/Parser/ParseError.php @@ -0,0 +1,24 @@ + $type->name, $expected) + ), + $given?->name ?? 'null', + $input + ) + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Parser/Parser.php b/lib/Client/Protocol/Response/Parser/Parser.php new file mode 100644 index 0000000..a718a1a --- /dev/null +++ b/lib/Client/Protocol/Response/Parser/Parser.php @@ -0,0 +1,1017 @@ +lexer = new Lexer(); + } + + /** + * @throws ParseError + */ + public function parse(string $raw): Line + { + $raw = $this->sanitizeInvalidEncoding($raw); + + $this->lexer->setInput($raw); + $this->lexer->moveNext(); + + if ($this->lexer->isNextToken(TokenType::PLUS_SIGN)) { + return $this->commandContinuation(); + } + + $tag = $this->getToken(TokenType::ASTERISK, TokenType::NUMBER, TokenType::ALPHANUMERIC)->value; + $this->space(); + + if ($this->lexer->isNextToken(TokenType::NUMBER)) { + $value = $this->number(); + $this->space(); + + return match ($this->lexer->lookahead?->type) { + TokenType::EXISTS => $this->exists($value), + TokenType::EXPUNGE => $this->expunge($value), + TokenType::RECENT => $this->recent($value), + TokenType::FETCH => $this->fetch($value), + default => throw new ParseError() + }; + } + + return match ($this->lexer->lookahead?->type) { + TokenType::STATUS => $this->status($tag), + TokenType::CAPABILITY => $this->capability(), + TokenType::LIST => $this->list(), + TokenType::FLAGS => $this->flags(), + TokenType::SEARCH => $this->search(), + default => throw new ParseError() + }; + } + + /** + * @throws ParseError + */ + private function commandContinuation(): CommandContinuation + { + $this->getToken(TokenType::PLUS_SIGN); + $message = ''; + + if ($this->nextIsSpace()) { + $this->space(); + + $message = $this->getValueUntil(TokenType::CRLF); + } + + return new CommandContinuation($message); + } + + /** + * @throws ParseError + */ + private function status(string $tag): Status + { + $type = StatusType::from($this->getToken(TokenType::STATUS)->value); + + $code = null; + $message = ''; + + if ($this->nextIsSpace()) { + $this->space(); + + if ($this->lexer->isNextToken(TokenType::OPEN_BRACKETS)) { + $code = $this->statusCode(); + + if ($this->nextIsSpace()) { + $this->space(); + } + } + + $message = $this->getValueUntil(TokenType::CRLF); + } + + return new Status($tag, $type, $code, $message); + } + + /** + * @throws ParseError + */ + private function statusCode(): ?Code + { + $this->getToken(TokenType::OPEN_BRACKETS); + + switch ($this->lexer->lookahead?->type) { + case TokenType::APPENDUID: + $code = $this->appendUidStatusCode(); + break; + case TokenType::UNSEEN: + $code = $this->unseenStatusCode(); + break; + case TokenType::UIDVALIDITY: + $code = $this->uidValidityStatusCode(); + break; + case TokenType::UIDNEXT: + $code = $this->uidNextStatusCode(); + break; + case TokenType::PERMANENTFLAGS: + $code = $this->permanentFlagsStatusCode(); + break; + case TokenType::READ_WRITE: + $this->getToken(TokenType::READ_WRITE); + $code = new ReadWriteCode(); + break; + case TokenType::READ_ONLY: + $this->getToken(TokenType::READ_ONLY); + $code = new ReadOnlyCode(); + break; + default: + $this->getValueUntil(TokenType::CLOSE_BRACKETS); + $code = null; + } + + $this->getToken(TokenType::CLOSE_BRACKETS); + + return $code; + } + + /** + * @throws ParseError + */ + private function unseenStatusCode(): UnseenCode + { + $this->getToken(TokenType::UNSEEN); + $this->space(); + $seq = $this->number(); + + return new UnseenCode($seq); + } + + /** + * @throws ParseError + */ + private function uidValidityStatusCode(): UidValidityCode + { + $this->getToken(TokenType::UIDVALIDITY); + $this->space(); + $value = $this->number(); + + return new UidValidityCode($value); + } + + /** + * @throws ParseError + */ + private function uidNextStatusCode(): UidNextCode + { + $this->getToken(TokenType::UIDNEXT); + $this->space(); + $value = $this->number(); + + return new UidNextCode($value); + } + + /** + * @throws ParseError + */ + private function permanentFlagsStatusCode(): PermanentFlagsCode + { + $this->getToken(TokenType::PERMANENTFLAGS); + $this->space(); + $this->getToken(TokenType::OPEN_PARENTHESIS); + + $flags = []; + $isFirst = true; + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + if (!$isFirst) { + $this->space(); + } + $flags[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); + $isFirst = false; + } + + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return new PermanentFlagsCode($flags); + } + + /** + * @throws ParseError + */ + private function appendUidStatusCode(): AppendUidCode + { + $this->getToken(TokenType::APPENDUID); + $this->space(); + $uidValidity = $this->number(); + $this->space(); + $uid = (int) $this->getToken(TokenType::NUMBER)->value; + + return new AppendUidCode($uidValidity, $uid); + } + + /** + * @throws ParseError + */ + private function capability(): CapabilityData + { + $this->getToken(TokenType::CAPABILITY); + $capabilities = []; + + while ($this->nextIsSpace()) { + $this->space(); + $capabilities[] = $this->atom(); + } + + return new CapabilityData($capabilities); + } + + /** + * @throws ParseError + */ + private function list(): ListData + { + $this->getToken(TokenType::LIST); + $this->space(); + + $this->getToken(TokenType::OPEN_PARENTHESIS); + $attributes = []; + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + $attributes[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); + + if ($this->nextIsSpace()) { + $this->space(); + } + } + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + $this->space(); + $hierarchy = $this->string(); + $this->space(); + $name = $this->astring(); + + return new ListData($attributes, $hierarchy, $name); + } + + /** + * @throws ParseError + */ + private function flags(): FlagsData + { + return new FlagsData($this->flagList()); + } + + /** + * @throws ParseError + */ + private function search(): SearchData + { + $this->getToken(TokenType::SEARCH); + + $numbers = []; + while (!$this->lexer->isNextToken(TokenType::CRLF)) { + if ($this->nextIsSpace()) { + $this->space(); + } + + $numbers[] = $this->number(); + } + + return new SearchData($numbers); + } + + /** + * @throws ParseError + */ + private function fetch(int $id): FetchData + { + $this->getToken(TokenType::FETCH); + $this->space(); + $this->getToken(TokenType::OPEN_PARENTHESIS); + $flags = null; + $internalDate = null; + $envelope = null; + $rfc822 = null; + $rfc822Size = null; + $uid = null; + $bodyStructure = null; + $bodySections = []; + + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + switch ($this->lexer->lookahead?->type) { + case TokenType::FLAGS: + $flags = $this->flagList(); + break; + case TokenType::INTERNALDATE: + $this->getToken(TokenType::INTERNALDATE); + $this->space(); + $internalDate = $this->dateTime(); + break; + case TokenType::UID: + $this->getToken(TokenType::UID); + $this->space(); + $uid = $this->number(); + break; + case TokenType::RFC822_SIZE: + $this->getToken(TokenType::RFC822_SIZE); + $this->space(); + $rfc822Size = $this->number(); + break; + case TokenType::BODY: + $this->getToken(TokenType::BODY); + if ($this->lexer->isNextToken(TokenType::OPEN_BRACKETS)) { + $this->getToken(TokenType::OPEN_BRACKETS); + $section = $this->getValueUntil(TokenType::CLOSE_BRACKETS); + $this->getToken(TokenType::CLOSE_BRACKETS); + $this->space(); + $text = $this->literal(); + + $bodySections[] = new BodySection($section, $text); + } + break; + case TokenType::ENVELOPE: + $this->getToken(TokenType::ENVELOPE); + $this->space(); + $envelope = $this->envelope(); + break; + case TokenType::BODYSTRUCTURE: + $this->getToken(TokenType::BODYSTRUCTURE); + $this->space(); + $bodyStructure = $this->bodyStructure(); + break; + default: + $this->getToken(); + } + } + + return new FetchData( + $id, + $flags, + $internalDate, + $envelope, + $rfc822Size, + $rfc822, + $uid, + $bodyStructure, + bodySections: $bodySections, + ); + } + + /** + * @throws ParseError + */ + private function envelope(): Envelope + { + $this->getToken(TokenType::OPEN_PARENTHESIS); + $date = $this->envelopeDate(); + $this->space(); + $subject = match($this->lexer->lookahead?->type) { + TokenType::OPEN_BRACES => $this->literal(), + default => $this->nstring(), + }; + $this->space(); + $from = $this->nullableAddressList(); + $this->space(); + $sender = $this->nullableAddressList(); + $this->space(); + $replyTo = $this->nullableAddressList(); + $this->space(); + $to = $this->nullableAddressList(); + $this->space(); + $cc = $this->nullableAddressList(); + $this->space(); + $bcc = $this->nullableAddressList(); + $this->space(); + $inReplyTo = $this->nstring(); + $this->space(); + $messageId = $this->nstring(); + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return new Envelope( + $date, + $subject, + $from, + $sender, + $replyTo, + $to, + $cc, + $bcc, + $inReplyTo, + $messageId, + ); + } + + /** + * @throws ParseError + */ + private function envelopeDate(): ?DateTimeImmutable + { + $value = $this->nstring(); + + if (null === $value) { + return null; + } + + try { + $date = new DateTimeImmutable($value); + } catch (\Exception) { + $date = null; + } + + return $date ?: throw new ParseError('Unable to parse envelope date'); + } + + /** + * @return Address[]|null + * @throws ParseError + */ + private function nullableAddressList(): ?array + { + if ($this->lexer->isNextToken(TokenType::NIL)) { + return $this->nil(); + } + + $this->getToken(TokenType::OPEN_PARENTHESIS); + $addresses = []; + while ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { + $addresses[] = $this->address(); + } + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return $addresses; + } + + /** + * @throws ParseError + */ + private function address(): Address + { + $this->getToken(TokenType::OPEN_PARENTHESIS); + $displayName = $this->nstring(); + $this->space(); + $atDomainList = $this->nstring(); + $this->space(); + $mailboxName = $this->nstring(); + $this->space(); + $hostname = $this->nstring(); + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return new Address( + $displayName, + $atDomainList, + $mailboxName, + $hostname, + ); + } + + /** + * @throws ParseError + */ + public function bodyStructure(): BodyStructure + { + $part = $this->part(); + + return new BodyStructure($part); + } + + /** + * @throws ParseError + */ + private function part(): BodyStructure\Part + { + return $this->lexer->glimpse()?->isA(TokenType::OPEN_PARENTHESIS) + ? $this->multipart() + : $this->simplePart(); + } + + /** + * @throws ParseError + */ + private function multipart(): BodyStructure\MultiPart + { + $parts = []; + $disposition = null; + $language = null; + $location = null; + + $this->getToken(TokenType::OPEN_PARENTHESIS); + + while ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { + $parts[] = $this->part(); + } + + $this->space(); + $subtype = $this->string(); + + if ($this->nextIsSpace()) { + $this->space(); + $attributes = $this->attributeValuePairs(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $disposition = $this->disposition(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $language = $this->bodyLanguage(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $location = $this->nstring(); + } + + $this->getValueUntil(TokenType::CLOSE_PARENTHESIS); + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return new BodyStructure\MultiPart( + $subtype, + $attributes ?? [], + $parts, + $disposition, + $language, + $location + ); + } + + /** + * @throws ParseError + */ + private function simplePart(): BodyStructure\SinglePart + { + $this->getToken(TokenType::OPEN_PARENTHESIS); + $type = $this->quoted(); + $normalizedType = strtoupper($type); + $this->space(); + $subtype = $this->quoted(); + $normalizedSubtype = strtoupper($subtype); + $this->space(); + $attributes = $this->attributeValuePairs(); + $this->space(); + $id = $this->nstring(); + $this->space(); + $description = $this->nstring(); + $this->space(); + $encoding = $this->string(); + $this->space(); + $size = $this->number(); + + $textLines = 0; + $md5 = null; + $disposition = null; + $language = null; + $location = null; + + $isTextPart = $normalizedType === 'TEXT'; + $isMessagePart = $normalizedType === 'MESSAGE' && $normalizedSubtype === 'RFC822'; + + if ($isTextPart) { + $this->space(); + $textLines = $this->number(); + } + + if ($isMessagePart) { + $this->space(); + $envelope = $this->envelope(); + $this->space(); + $bodyStructure = $this->bodyStructure(); + $this->space(); + $textLines = $this->number(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $md5 = $this->nstring(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $disposition = $this->disposition(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $language = $this->bodyLanguage(); + } + + if ($this->nextIsSpace()) { + $this->space(); + $location = $this->nstring(); + } + + $this->getValueUntil(TokenType::CLOSE_PARENTHESIS); + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + if ($isTextPart) { + return new BodyStructure\TextPart( + $subtype, + $attributes, + $id, + $description, + $encoding, + $size, + $textLines, + $md5, + $disposition, + $language, + $location, + ); + } + + if ($isMessagePart) { + return new BodyStructure\MessagePart( + $attributes, + $id, + $description, + $encoding, + $size, + $envelope, + $bodyStructure, + $textLines, + $md5, + $disposition, + $language, + $location, + ); + } + + return new BodyStructure\SinglePart( + $type, + $subtype, + $attributes, + $id, + $description, + $encoding, + $size, + $md5, + $disposition, + $language, + $location, + ); + } + + /** + * @return string[]|null + * @throws ParseError + */ + private function bodyLanguage(): ?array + { + if ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { + $this->getToken(TokenType::OPEN_PARENTHESIS); + $lang = []; + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + $lang[] = $this->string(); + + if ($this->nextIsSpace()) { + $this->space(); + } + } + + $this->getToken(TokenType::CLOSE_PARENTHESIS); + return $lang; + } + + $lang = $this->nstring(); + + return $lang ? [$lang] : null; + } + + private function disposition(): ?BodyStructure\Disposition + { + if ($this->lexer->isNextToken(TokenType::NIL)) { + return $this->nil(); + } + + $this->getToken(TokenType::OPEN_PARENTHESIS); + $type = $this->string(); + $this->space(); + $attributes = $this->lexer->isNextToken(TokenType::NIL) + ? $this->nil() + : $this->attributeValuePairs(); + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return new BodyStructure\Disposition( + $type, + $attributes ?? [] + ); + } + + /** + * @return array + * @throws ParseError + */ + private function attributeValuePairs(): array + { + $values = []; + if ($this->lexer->isNextToken(TokenType::NIL)) { + $this->nil(); + return $values; + } + + $this->getToken(TokenType::OPEN_PARENTHESIS); + + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + if ($this->nextIsSpace()) { + $this->space(); + } + + $attribute = $this->quoted(); + $this->space(); + $value = $this->quoted(); + + $values[$attribute] = $value; + } + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return $values; + } + + /** + * @throws ParseError + */ + private function exists(int $numberOfMessages): ExistsData + { + $this->getToken(TokenType::EXISTS); + + return new ExistsData($numberOfMessages); + } + + /** + * @throws ParseError + */ + private function expunge(int $id): ExpungeData + { + $this->getToken(TokenType::EXPUNGE); + + return new ExpungeData($id); + } + + /** + * @throws ParseError + */ + private function recent(int $numberOfMessages): RecentData + { + $this->getToken(TokenType::RECENT); + + return new RecentData($numberOfMessages); + } + + /** + * @return string[] + * @throws ParseError + */ + private function flagList(): array + { + $flags = []; + $this->getToken(TokenType::FLAGS); + $this->space(); + $this->getToken(TokenType::OPEN_PARENTHESIS); + $isFirstFlag = true; + + while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { + if (!$isFirstFlag) { + $this->space(); + } + + $flags[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); + + $isFirstFlag = false; + } + + $this->getToken(TokenType::CLOSE_PARENTHESIS); + + return $flags; + } + + /** + * @throws ParseError + */ + private function dateTime(): DateTimeImmutable + { + $this->getToken(TokenType::DOUBLE_QUOTE); + $value = $this->getValueUntil(TokenType::DOUBLE_QUOTE); + $this->getToken(TokenType::DOUBLE_QUOTE); + + return DateTimeImmutable::createFromFormat('d-M-Y H:i:s O', $value) + ?: throw new ParseError(sprintf('Invalid date time "%s"', $value)); + } + + /** + * @throws ParseError + */ + private function number(): int + { + return (int) $this->getToken(TokenType::NUMBER)->value; + } + + /** + * @throws ParseError + */ + private function astring(): string + { + if ($this->lexer->isNextToken(TokenType::OPEN_BRACES)) { + return $this->literal(); + } + + return $this->string(); + } + + /** + * @throws ParseError + */ + private function nstring(): ?string + { + if ($this->lexer->isNextToken(TokenType::NIL)) { + return $this->nil(); + } + + return $this->string(); + } + + /** + * @throws ParseError + */ + public function atom(): string + { + return $this->getValueUntil( + TokenType::OPEN_PARENTHESIS, + TokenType::CLOSE_PARENTHESIS, + TokenType::OPEN_BRACES, + TokenType::CTL, + TokenType::SP, + TokenType::CRLF, + TokenType::DOUBLE_QUOTE, + TokenType::BACKSLASH, + TokenType::ASTERISK, + TokenType::PERCENT_SIGN, + ); + } + + /** + * @throws ParseError + */ + private function string(): string + { + return match($this->lexer->lookahead?->type) { + TokenType::DOUBLE_QUOTE => $this->quoted(), + default => $this->atom() + }; + } + + /** + * @throws ParseError + */ + private function quoted(): string + { + $this->getToken(TokenType::DOUBLE_QUOTE); + $value = ''; + + while (!$this->lexer->isNextToken(TokenType::DOUBLE_QUOTE)) { + if ($this->lexer->isNextToken(TokenType::BACKSLASH)) { + $value .= $this->quotedSpecials(); + } else { + $value .= $this->getToken()->value; + } + } + + $this->getToken(TokenType::DOUBLE_QUOTE); + + return $value; + } + + /** + * @throws ParseError + */ + private function quotedSpecials(): string + { + $this->getToken(TokenType::BACKSLASH); + + if ($this->lexer->isNextToken(TokenType::DOUBLE_QUOTE)) { + $this->getToken(TokenType::DOUBLE_QUOTE); + return "\""; + } + + return "\\"; + } + + /** + * @throws ParseError + */ + private function literal(): string + { + $this->getToken(TokenType::OPEN_BRACES); + $size = (int) $this->getToken(TokenType::NUMBER)->value; + $this->getToken(TokenType::CLOSE_BRACES); + $this->getToken(TokenType::CRLF); + + $value = ''; + while (strlen($value) < $size) { + $value .= $this->getToken()->value; + } + + return $value; + } + + /** + * @throws ParseError + */ + private function nil(): null + { + $this->getToken(TokenType::NIL); + + return null; + } + + /** + * @return Token + * @throws ParseError + */ + private function getToken(TokenType ...$expected): Token + { + if (!empty($expected) && !in_array($this->lexer->lookahead?->type, $expected)) { + $position = $this->lexer->lookahead?->position; + + throw ParseError::unexpectedToken( + $this->lexer->lookahead?->type, + $expected, + $position ? $this->lexer->getInputUntilPosition($position) : '' + ); + } + + $this->lexer->moveNext(); + + return $this->lexer->token ?? throw new ParseError(); + } + + private function nextIsSpace(): bool + { + return $this->lexer->lookahead?->isA(TokenType::SP) ?? false; + } + + /** + * @throws ParseError + */ + private function space(): void + { + $this->getToken(TokenType::SP); + } + + /** + * @throws ParseError + */ + private function getValueUntil(TokenType ...$types): string + { + $value = ''; + + while (!in_array($this->lexer->lookahead?->type, $types)) { + $value .= $this->getToken()->value; + } + + return $value; + } + + private function sanitizeInvalidEncoding(string $raw): string + { + if (mb_check_encoding($raw, 'US-ASCII')) { + return $raw; + } + + for ($i = 0; $i < strlen($raw); $i++) { + $character = $raw[$i]; + if (!mb_check_encoding($character, 'US-ASCII')) { + $raw[$i] = ' '; + } + } + + return $raw; + } +} diff --git a/lib/Client/Protocol/Response/Parser/TokenType.php b/lib/Client/Protocol/Response/Parser/TokenType.php new file mode 100644 index 0000000..6622363 --- /dev/null +++ b/lib/Client/Protocol/Response/Parser/TokenType.php @@ -0,0 +1,56 @@ + $data + */ + public function __construct( + public Status $status, + public array $data, + ) { + } + + /** + * @template T of Line + * @param class-string $type + * @return T[] + */ + public function getData(string $type): array + { + $result = []; + foreach ($this->data as $data) { + if ($data instanceof $type) { + $result[] = $data; + } + } + + return $result; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/ResponseBuilder.php b/lib/Client/Protocol/Response/ResponseBuilder.php new file mode 100644 index 0000000..700e8cf --- /dev/null +++ b/lib/Client/Protocol/Response/ResponseBuilder.php @@ -0,0 +1,50 @@ + + */ + private array $data = []; + + public function __construct(private readonly string $statusTag) + { + } + + public function addLine(Line $line): void + { + if ($line instanceof Status && $line->tag === $this->statusTag) { + $this->status = $line; + return; + } + + $this->data[] = $line; + } + + public function hasStatus(): bool + { + return $this->status !== null; + } + + public function build(): Response + { + if (null === $this->status) { + throw new BadMethodCallException(); + } + + return new Response( + $this->status, + $this->data, + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/ResponseHandler.php b/lib/Client/Protocol/ResponseHandler.php new file mode 100644 index 0000000..9940fae --- /dev/null +++ b/lib/Client/Protocol/ResponseHandler.php @@ -0,0 +1,88 @@ +readLine(); + while (preg_match('/\{(?\d+)}\r\n$/', $raw, $matches)) { + $raw .= $stream->read((int) $matches['bytes']); + $raw .= $stream->readLine(); + } + $line = $this->parser->parse($raw); + + if ($line instanceof CommandContinuation) { + $continuationHandler->continue(); + continue; + } + + $responseBuilder->addLine($line); + } while (!$responseBuilder->hasStatus()); + + return $responseBuilder->build(); + } + + /** + * Streams parsed response lines one at a time as a Generator, yielding each + * untagged Line immediately as it arrives from the socket. The terminal + * Status line is NOT yielded; instead it is set as the generator return + * value so callers can retrieve it via $gen->getReturn() after exhaustion. + * + * @throws CommandFailed if the tagged status is NO or BAD + * + * @return Generator + */ + public function stream(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Generator + { + $status = null; + + do { + $raw = $stream->readLine(); + while (preg_match('/\{(?\d+)}\r\n$/', $raw, $matches)) { + $raw .= $stream->read((int) $matches['bytes']); + $raw .= $stream->readLine(); + } + $line = $this->parser->parse($raw); + + if ($line instanceof CommandContinuation) { + $continuationHandler->continue(); + continue; + } + + if ($line instanceof Status && $line->tag === $statusTag) { + $status = $line; + break; + } + + yield $line; + } while (true); + + if ($status->type !== StatusType::OK) { + throw CommandFailed::withStatus($status); + } + + return $status; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/TagGenerator.php b/lib/Client/Protocol/TagGenerator.php new file mode 100644 index 0000000..c264a85 --- /dev/null +++ b/lib/Client/Protocol/TagGenerator.php @@ -0,0 +1,36 @@ +number += 1; + + if ($this->number > self::MAX_NUMBER) { + $this->letter++; + $this->number = self::INITIAL_NUMBER; + } + + if (strlen($this->letter) > 1) { + $this->letter = self::INITIAL_LETTER; + } + + return sprintf( + '%s%s', + $this->letter, + str_pad((string) $this->number, self::NUMBER_PART_LENGTH, '0', STR_PAD_LEFT) + ); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/UnexpectedContinuationHandler.php b/lib/Client/Protocol/UnexpectedContinuationHandler.php new file mode 100644 index 0000000..6341c69 --- /dev/null +++ b/lib/Client/Protocol/UnexpectedContinuationHandler.php @@ -0,0 +1,15 @@ + + */ + private array $criteria; + + private bool $not = false; + + public function __construct(private readonly Client $client) + { + } + + public function header(string $fieldName, string $value = ''): self + { + $this->addCriteria(new Header($fieldName, $value)); + + return $this; + } + + public function before(DateTimeInterface $date): self + { + $this->addCriteria(new Before($date)); + + return $this; + } + + public function since(DateTimeInterface $date): self + { + $this->addCriteria(new Since($date)); + + return $this; + } + + public function not(): self + { + $this->not = true; + + return $this; + } + + /** + * @return array + */ + public function get(?PreFetchOptions $preFetchOptions = null): array + { + if ($this->not) { + throw new BadMethodCallException('Not key requires to specify a search key to be applied'); + } + + $criteria = empty($this->criteria) + ? [new All()] + : $this->criteria; + + return $this->client->doSearch($criteria, $preFetchOptions); + } + + private function addCriteria(Criteria $criteria): void + { + if ($this->not) { + $criteria = new Not($criteria); + $this->not = false; + } + + $this->criteria[] = $criteria; + } +} \ No newline at end of file diff --git a/lib/Client/Transport/Connection.php b/lib/Client/Transport/Connection.php new file mode 100644 index 0000000..74fc8b4 --- /dev/null +++ b/lib/Client/Transport/Connection.php @@ -0,0 +1,26 @@ +port = $port; + $this->host = $host; + $this->transport = $transport; + $this->timeout = $timeout; + $this->verifyPeer = $verifyPeer; + $this->verifyPeerName = $verifyPeerName; + $this->allowSelfSigned = $allowSelfSigned; + } + + public function __destruct() + { + $this->close(); + } + + public function isOpen(): bool + { + return false !== $this->stream; + } + + public function open(): void + { + if ($this->isOpen()) { + return; + } + + $this->stream = @stream_socket_client( + sprintf('%s://%s:%s', $this->transport, $this->host, $this->port), + $errorCode, + $errorMessage, + $this->timeout, + context: stream_context_create([ + 'ssl' => [ + 'verify_peer' => $this->verifyPeer, + 'verify_peer_name' => $this->verifyPeerName, + 'allow_self_signed' => $this->allowSelfSigned, + ] + ]) + ); + + if (false === $this->stream) { + throw new ConnectionFailed( + sprintf('SocketConnection failed [%s]: %s', $errorCode, $errorMessage) + ); + } + } + + public function close(): void + { + if (!$this->stream) { + return; + } + + fclose($this->stream); + + $this->stream = false; + } + + /** + * Upgrade an open plain-TCP socket to TLS in-place (STARTTLS patch). + * + * Must be called after the server has responded OK to a STARTTLS command. + * + * @throws ConnectionFailed + */ + public function upgradeTls(): void + { + if (!$this->stream) { + throw new ConnectionFailed('Cannot upgrade TLS: connection is not open'); + } + + stream_context_set_option($this->stream, [ + 'ssl' => [ + 'peer_name' => $this->host, + 'verify_peer' => $this->verifyPeer, + 'verify_peer_name' => $this->verifyPeerName, + 'allow_self_signed' => $this->allowSelfSigned, + ], + ]); + + $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; + + $result = @stream_socket_enable_crypto( + $this->stream, + true, + $cryptoMethod, + ); + + if ($result !== true) { + $last = error_get_last(); + $detail = $last['message'] ?? 'unknown error'; + throw new ConnectionFailed('STARTTLS upgrade failed: ' . $detail); + } + } + + public function send(string $data): void + { + if (!$this->stream) { + throw new Exception('Unable to send data. SocketConnection is not open'); + } + + fwrite($this->stream, $data); + } + + public function receive(): ResponseStream + { + if (!$this->stream) { + throw new Exception('Unable to receive data. SocketConnection is not open'); + } + + return new SocketResponseStream($this->stream); + } +} \ No newline at end of file diff --git a/lib/Client/Transport/Socket/SocketResponseStream.php b/lib/Client/Transport/Socket/SocketResponseStream.php new file mode 100644 index 0000000..8e18f87 --- /dev/null +++ b/lib/Client/Transport/Socket/SocketResponseStream.php @@ -0,0 +1,44 @@ +stream, $remainingBytes); + $remainingBytes = $bytes - strlen($data); + } while ($remainingBytes > 0); + + return $data; + } + + public function readLine(): string + { + $line = ''; + + while ("\n" !== ($char = fread($this->stream, 1))) { + $line .= $char; + } + + return $line."\n"; + } +} \ No newline at end of file diff --git a/lib/Client/Transport/Traceable/TraceableConnection.php b/lib/Client/Transport/Traceable/TraceableConnection.php new file mode 100644 index 0000000..aaea8d5 --- /dev/null +++ b/lib/Client/Transport/Traceable/TraceableConnection.php @@ -0,0 +1,55 @@ +connection->isOpen(); + } + + public function open(): void + { + $this->connection->open(); + } + + public function close(): void + { + $this->connection->close(); + } + + public function send(string $data): void + { + $this->debug(addslashes($data)); + + $this->connection->send($data); + } + + public function receive(): ResponseStream + { + return new TraceableResponseStream( + $this->connection->receive(), + $this->logger, + ); + } + + private function debug(string $data): void + { + $data = str_replace("\r\n", "\\r\\n", $data); + + $this->logger->debug($data); + } +} \ No newline at end of file diff --git a/lib/Client/Transport/Traceable/TraceableResponseStream.php b/lib/Client/Transport/Traceable/TraceableResponseStream.php new file mode 100644 index 0000000..60d7330 --- /dev/null +++ b/lib/Client/Transport/Traceable/TraceableResponseStream.php @@ -0,0 +1,43 @@ +responseStream->read($bytes); + + $this->debug($data); + + return $data; + } + + public function readLine(): string + { + $line = $this->responseStream->readLine(); + + $this->debug($line); + + return $line; + } + + private function debug(string $data): void + { + $data = addslashes($data); + $data = str_replace("\r\n", "\\r\\n", $data); + + $this->logger->debug($data); + } +} \ No newline at end of file diff --git a/lib/Console/ServiceConnectCommand.php b/lib/Console/ServiceConnectCommand.php new file mode 100644 index 0000000..2d4111e --- /dev/null +++ b/lib/Console/ServiceConnectCommand.php @@ -0,0 +1,202 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Console; + +use KTXM\ProviderImapMail\Providers\Provider; +use KTXM\ProviderImapMail\Providers\Service; +use KTXM\ProviderImapMail\Providers\ServiceIdentityBasic; +use KTXM\ProviderImapMail\Providers\ServiceLocation; +use KTXC\SessionTenant; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Manual IMAP service connection wizard. + * + * Interactively prompts for all connection details (host, port, encryption, + * username, password), runs a live connection test, then optionally persists + * the service to the store. + * + * Usage: + * bin/console provider_imap_mail:service:connect + * bin/console provider_imap_mail:service:connect --tenant=t1 --user=u1 + */ +#[AsCommand( + name: 'provider_imap_mail:service:connect', + description: 'Manually configure and connect an IMAP service', +)] +class ServiceConnectCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'IMAP server hostname') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'IMAP port (default: 993)') + ->addOption('encryption', null, InputOption::VALUE_REQUIRED, 'Encryption: ssl | starttls | none (default: ssl)') + ->addOption('username', null, InputOption::VALUE_REQUIRED, 'IMAP username / e-mail') + ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') + ->addOption('no-save', null, InputOption::VALUE_NONE, 'Test connection only; do not persist') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:connect command walks you through + manually configuring an IMAP account. All prompts can be pre-filled via + options to support non-interactive / scripted usage. + + Examples: + + Fully interactive: + bin/console provider_imap_mail:service:connect + + Pre-fill common options: + bin/console provider_imap_mail:service:connect \ + --host=mail.example.com --username=user@example.com \ + --tenant=t1 --user=u1 + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $io->title('IMAP Service — Manual Configuration'); + + if (!$input->getOption('tenant')) { + $tenant = $io->ask('Tenant ID'); + if ($tenant) $input->setOption('tenant', $tenant); + } + + if (!$input->getOption('user')) { + $user = $io->ask('User ID'); + if ($user) $input->setOption('user', $user); + } + + if (!$input->getOption('host')) { + $host = $io->ask('IMAP server hostname'); + if ($host) $input->setOption('host', $host); + } + + if (!$input->getOption('port')) { + $port = $io->ask('Port', '993'); + if ($port) $input->setOption('port', $port); + } + + if (!$input->getOption('encryption')) { + $enc = $io->choice('Encryption', ['ssl', 'starttls', 'none'], 'ssl'); + $input->setOption('encryption', $enc); + } + + if (!$input->getOption('username')) { + $username = $io->ask('Username / e-mail address'); + if ($username) $input->setOption('username', $username); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + $host = (string) ($input->getOption('host') ?? ''); + $port = (int) ($input->getOption('port') ?? 993); + $encryption = (string) ($input->getOption('encryption') ?? 'ssl'); + $username = (string) ($input->getOption('username') ?? ''); + $noVerify = (bool) $input->getOption('no-verify'); + $noSave = (bool) $input->getOption('no-save'); + + // ── Validate required fields ───────────────────────────────────────── + + $errors = []; + if ($host === '') $errors[] = 'Hostname is required.'; + if ($username === '') $errors[] = 'Username is required.'; + if (!$noSave) { + if ($tenantId === '') $errors[] = 'Tenant ID is required (or pass --no-save to test only).'; + if ($userId === '') $errors[] = 'User ID is required (or pass --no-save to test only).'; + } + + if (!empty($errors)) { + $io->error($errors); + return Command::FAILURE; + } + + // ── Prompt for password (always interactive — never passed via option) ─ + + $password = $io->askHidden('Password'); + + if (!$password) { + $io->error('Password is required.'); + return Command::FAILURE; + } + + // ── Build service object ──────────────────────────────────────────── + + $location = new ServiceLocation( + host: $host, + port: $port > 0 ? $port : 993, + encryption: $encryption, + verifyPeer: !$noVerify, + verifyPeerName: !$noVerify, + allowSelfSigned: $noVerify, + ); + + $identity = (new ServiceIdentityBasic())->jsonDeserialize([ + 'identity' => $username, + 'secret' => $password, + ]); + + $service = new Service(); + $service->setLocation($location); + $service->setIdentity($identity); + + // ── Test connection ────────────────────────────────────────────────── + + $io->text('Testing connection to ' . $host . ':' . $port . '…'); + + $result = $this->provider->serviceTest($service); + + if (!$result['success']) { + $io->error('Connection test failed: ' . $result['message']); + return Command::FAILURE; + } + + $io->success($result['message']); + + if ($noSave) { + $io->note('Connection test passed. Service not saved (--no-save).'); + return Command::SUCCESS; + } + + // ── Persist ────────────────────────────────────────────────────────── + + $this->sessionTenant->configureById($tenantId); + + $label = $io->ask('Service label', $username); + if ($label) { + $service->setLabel($label); + } + + $id = $this->provider->serviceCreate($tenantId, $userId, $service); + $io->success("Service saved with ID: {$id}"); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/ServiceDisconnectCommand.php b/lib/Console/ServiceDisconnectCommand.php new file mode 100644 index 0000000..6a41aa8 --- /dev/null +++ b/lib/Console/ServiceDisconnectCommand.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Console; + +use KTXM\ProviderImapMail\Providers\Provider; +use KTXM\ProviderImapMail\Providers\Service; +use KTXC\SessionTenant; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Remove a stored IMAP service connection. + * + * Looks up the service by its ID (or prompts for one), confirms with the + * operator, then permanently deletes the service document from the store. + * + * Usage: + * bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 + */ +#[AsCommand( + name: 'provider_imap_mail:service:disconnect', + description: 'Remove a stored IMAP service connection', +)] +class ServiceDisconnectCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('service-id', InputArgument::OPTIONAL, 'Service ID to remove') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:disconnect command permanently removes + a stored IMAP service configuration from the store. + + Examples: + + Interactive (lists services and prompts for ID): + bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 + + Direct, with confirmation: + bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 + + Skip confirmation: + bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 --force + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getOption('tenant')) { + $tenant = $io->ask('Tenant ID'); + if ($tenant) $input->setOption('tenant', $tenant); + } + + if (!$input->getOption('user')) { + $user = $io->ask('User ID'); + if ($user) $input->setOption('user', $user); + } + + // If no service ID given, list available services and let the operator pick + if (!$input->getArgument('service-id')) { + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + + if ($tenantId !== '' && $userId !== '') { + $this->sessionTenant->configureById($tenantId); + $services = $this->provider->serviceList($tenantId, $userId); + + if (empty($services)) { + // nothing to select — let execute() handle the error + return; + } + + $choices = []; + foreach ($services as $id => $service) { + $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; + $choices[$id] = "{$label} [{$id}]"; + } + + $chosen = $io->choice('Select service to disconnect', array_values($choices)); + // resolve the chosen label back to its key + $serviceId = (string) array_search($chosen, $choices, true); + if ($serviceId !== '') { + $input->setArgument('service-id', $serviceId); + } + } + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + $serviceId = (string) ($input->getArgument('service-id') ?? ''); + $force = (bool) $input->getOption('force'); + + $errors = []; + if ($tenantId === '') $errors[] = 'Tenant ID is required (--tenant).'; + if ($userId === '') $errors[] = 'User ID is required (--user).'; + if ($serviceId === '') $errors[] = 'Service ID is required.'; + + if (!empty($errors)) { + $io->error($errors); + return Command::FAILURE; + } + + // ── Fetch service for display ──────────────────────────────────────── + $this->sessionTenant->configureById($tenantId); + $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); + + if ($service === null) { + $io->error("Service '{$serviceId}' not found."); + return Command::FAILURE; + } + + $label = $service->getLabel() ?? $serviceId; + $host = $service->getLocation()?->getHost() ?? 'unknown'; + + $io->title('Disconnect IMAP Service'); + $io->definitionList( + ['ID' => $serviceId], + ['Label' => $label], + ['Host' => $host], + ); + + // ── Confirmation ───────────────────────────────────────────────────── + + if (!$force) { + $confirm = $io->confirm( + "Permanently remove service {$label} ({$serviceId})?", + false + ); + if (!$confirm) { + $io->note('Aborted.'); + return Command::SUCCESS; + } + } + + // ── Destroy ────────────────────────────────────────────────────────── + + $deleted = $this->provider->serviceDestroy($tenantId, $userId, $service); + + if (!$deleted) { + $io->error("Failed to remove service '{$serviceId}'."); + return Command::FAILURE; + } + + $io->success("Service '{$label}' ({$serviceId}) has been removed."); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/ServiceDiscoverCommand.php b/lib/Console/ServiceDiscoverCommand.php new file mode 100644 index 0000000..a005ab7 --- /dev/null +++ b/lib/Console/ServiceDiscoverCommand.php @@ -0,0 +1,226 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Console; + +use KTXM\ProviderImapMail\Providers\Provider; +use KTXM\ProviderImapMail\Providers\Service; +use KTXM\ProviderImapMail\Providers\ServiceIdentityBasic; +use KTXM\ProviderImapMail\Providers\ServiceLocation; +use KTXM\ProviderImapMail\Service\Discovery; +use KTXM\ProviderImapMail\Service\Remote\RemoteService; +use KTXC\SessionTenant; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Automated IMAP service discovery. + * + * Probes DNS SRV records and common hostnames to determine the correct IMAP + * server settings for a given e-mail address, then optionally persists the + * resulting service to the store. + * + * Usage: + * bin/console provider_imap_mail:service:discover user@example.com + * bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save + */ +#[AsCommand( + name: 'provider_imap_mail:service:discover', + description: 'Auto-discover IMAP server settings from an e-mail address', +)] +class ServiceDiscoverCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly Discovery $discovery, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('address', InputArgument::OPTIONAL, 'E-mail address to discover settings for') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID (required when --save is set)') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID (required when --save is set)') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Explicit hostname to probe instead of DNS lookup') + ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') + ->addOption('save', null, InputOption::VALUE_NONE, 'Persist the discovered service after a successful test') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:discover command auto-discovers IMAP + server settings by probing DNS SRV records (_imaps._tcp / _imap._tcp) and + common hostname conventions (mail., imap., …). + + Examples: + + Dry-run discovery (no persistence): + bin/console provider_imap_mail:service:discover user@example.com + + Discover and save under a specific tenant/user pair: + bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save + + Probe an explicit host: + bin/console provider_imap_mail:service:discover user@example.com --host=mail.example.com + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getArgument('address')) { + $address = $io->ask('E-mail address'); + if ($address) { + $input->setArgument('address', $address); + } + } + + if ($input->getOption('save')) { + if (!$input->getOption('tenant')) { + $tenant = $io->ask('Tenant ID'); + if ($tenant) { + $input->setOption('tenant', $tenant); + } + } + if (!$input->getOption('user')) { + $user = $io->ask('User ID'); + if ($user) { + $input->setOption('user', $user); + } + } + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $address = (string) $input->getArgument('address'); + $host = $input->getOption('host'); + $noVerify = (bool) $input->getOption('no-verify'); + $save = (bool) $input->getOption('save'); + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + + if ($address === '') { + $io->error('An e-mail address is required.'); + return Command::FAILURE; + } + + if ($save && ($tenantId === '' || $userId === '')) { + $io->error('--tenant and --user are required when --save is set.'); + return Command::FAILURE; + } + + $io->title('IMAP Service Discovery'); + $io->text("Discovering settings for {$address}…"); + + // ── Discovery ──────────────────────────────────────────────────────── + + $candidates = $this->discovery->discoverAll( + identity: $address, + location: $host, + verifySSL: !$noVerify, + ); + + if ($candidates === []) { + $io->error('Discovery failed — no reachable IMAP server found.'); + return Command::FAILURE; + } + + // ── Build labelled choice list ──────────────────────────────────────── + + $encLabel = static fn (string $e): string => match ($e) { + 'ssl' => 'SSL/TLS', + 'starttls' => 'STARTTLS', + default => 'None (plain)', + }; + + /** @var array $choiceMap */ + $choiceMap = []; + foreach ($candidates as $c) { + $label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption())); + $choiceMap[$label] = $c; + } + + $io->text(sprintf('%d location(s) found:', count($candidates))); + $io->newLine(); + + if (count($candidates) === 1) { + $chosenLabel = array_key_first($choiceMap); + } else { + $chosenLabel = $io->choice('Select which server to use', array_keys($choiceMap), array_key_first($choiceMap)); + } + + $location = $choiceMap[$chosenLabel]; + + // ── Display chosen result ───────────────────────────────────────────── + + $io->success('Server selected:'); + $io->definitionList( + ['Host' => $location->getHost()], + ['Port' => (string) $location->getPort()], + ['Encryption' => $encLabel($location->getEncryption())], + ); + + if (!$save) { + $io->note('Run with --save --tenant= --user= to persist this service.'); + return Command::SUCCESS; + } + + // ── Interactive credential prompt ──────────────────────────────────── + + $username = $io->ask('Username', $address); + $password = $io->askHidden('Password'); + + if (!$username || !$password) { + $io->error('Username and password are required to save the service.'); + return Command::FAILURE; + } + + // ── Test before saving ─────────────────────────────────────────────── + + $service = new Service(); + $service->setLocation($location); + $service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([ + 'identity' => $username, + 'secret' => $password, + ])); + + $io->text('Testing connection…'); + $result = $this->provider->serviceTest($service); + + if (!$result['success']) { + $io->error('Connection test failed: ' . $result['message']); + return Command::FAILURE; + } + + $io->success($result['message']); + + // ── Persist ────────────────────────────────────────────────────────── + + $this->sessionTenant->configureById($tenantId); + + $label = $io->ask('Service label', $address); + if ($label) { + $service->setLabel($label); + } + + $id = $this->provider->serviceCreate($tenantId, $userId, $service); + $io->success("Service saved with ID: {$id}"); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/ServiceTestCommand.php b/lib/Console/ServiceTestCommand.php new file mode 100644 index 0000000..2fffe96 --- /dev/null +++ b/lib/Console/ServiceTestCommand.php @@ -0,0 +1,203 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Console; + +use KTXM\ProviderImapMail\Providers\Provider; +use KTXM\ProviderImapMail\Providers\Service; +use KTXM\ProviderImapMail\Service\Remote\RemoteService; +use KTXC\SessionTenant; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Live IMAP connection test. + * + * Connects to the server using a stored service's credentials, authenticates, + * and lists the root-level mailboxes with their unread message counts as a + * quick end-to-end sanity check. + * + * Usage: + * bin/console provider_imap_mail:service:test --tenant=t1 --user=u1 + */ +#[AsCommand( + name: 'provider_imap_mail:service:test', + description: 'Test an IMAP service connection and list root mailboxes', +)] +class ServiceTestCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('service-id', InputArgument::OPTIONAL, 'Service ID to test') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('all', 'a', InputOption::VALUE_NONE, 'List all mailboxes, not just the root level') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:test command opens a live IMAP + connection for a stored service, authenticates, and lists the available + mailboxes together with their message counts. + + Examples: + + Test a specific service: + bin/console provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 + + Interactive (lists services, lets you choose one): + bin/console provider_imap_mail:service:test --tenant=t1 --user=u1 + + Show all mailboxes (not just top-level): + bin/console provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 --all + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getOption('tenant')) { + $tenant = $io->ask('Tenant ID'); + if ($tenant) $input->setOption('tenant', $tenant); + } + + if (!$input->getOption('user')) { + $user = $io->ask('User ID'); + if ($user) $input->setOption('user', $user); + } + + if (!$input->getArgument('service-id')) { + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + + if ($tenantId !== '' && $userId !== '') { + $this->sessionTenant->configureById($tenantId); + $services = $this->provider->serviceList($tenantId, $userId); + + if (!empty($services)) { + $choices = []; + foreach ($services as $id => $service) { + $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; + $choices[$id] = "{$label} [{$id}]"; + } + + $chosen = $io->choice('Select service to test', array_values($choices)); + $serviceId = (string) array_search($chosen, $choices, true); + if ($serviceId !== '') { + $input->setArgument('service-id', $serviceId); + } + } + } + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + $serviceId = (string) ($input->getArgument('service-id') ?? ''); + $showAll = (bool) $input->getOption('all'); + + $errors = []; + if ($tenantId === '') $errors[] = 'Tenant ID is required (--tenant).'; + if ($userId === '') $errors[] = 'User ID is required (--user).'; + if ($serviceId === '') $errors[] = 'Service ID is required.'; + + if (!empty($errors)) { + $io->error($errors); + return Command::FAILURE; + } + + // ── Fetch stored service ───────────────────────────────────────────── + + $this->sessionTenant->configureById($tenantId); + + $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); + + if (!($service instanceof Service)) { + $io->error("Service '{$serviceId}' not found."); + return Command::FAILURE; + } + + $host = $service->getLocation()?->getHost() ?? 'unknown'; + $port = $service->getLocation()?->getPort() ?? 993; + $enc = $service->getLocation()?->getEncryption() ?? 'ssl'; + + $io->title('IMAP Connection Test'); + $io->definitionList( + ['Service' => $service->getLabel() ?? $serviceId], + ['Host' => $host], + ['Port' => (string) $port], + ['Encryption' => $enc], + ['Username' => $service->getIdentity()?->getIdentity() ?? '–'], + ); + + // ── Quick single-call test via Provider ────────────────────────────── + + $io->text('Authenticating…'); + $startTime = microtime(true); + $testResult = $this->provider->serviceTest($service); + $latency = (int) round((microtime(true) - $startTime) * 1000); + + if (!$testResult['success']) { + $io->error($testResult['message']); + return Command::FAILURE; + } + + $io->success($testResult['message']); + + // ── Mailbox listing ────────────────────────────────────────────────── + + $io->text('Fetching mailbox list…'); + + try { + $wrapper = RemoteService::freshClient($service); + $mailboxes = $wrapper->mailboxes(); + + $rows = []; + foreach ($mailboxes as $mailbox) { + // Filter to root-level only unless --all + if (!$showAll && substr_count($mailbox->name, $mailbox->hierarchyDelimiter ?: '/') > 0) { + continue; + } + + $selectable = $mailbox->isSelectable() ? '✓' : '–'; + $rows[] = [ + $mailbox->name, + $selectable, + ]; + } + + if (empty($rows)) { + $io->note('No mailboxes found' . ($showAll ? '.' : ' at the root level. Use --all to see all mailboxes.')); + } else { + $io->table(['Mailbox', 'Selectable'], $rows); + $noun = count($rows) === 1 ? 'mailbox' : 'mailboxes'; + $io->text(sprintf('%d %s listed. Latency: %d ms.', count($rows), $noun, $latency)); + } + } catch (\Throwable $e) { + $io->warning('Could not list mailboxes: ' . $e->getMessage()); + } + + return Command::SUCCESS; + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..35db440 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,97 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail; + +use KTXC\Resource\ProviderManager; +use KTXF\Module\ModuleBrowserInterface; +use KTXF\Module\ModuleConsoleInterface; +use KTXF\Module\ModuleInstanceAbstract; +use KTXF\Resource\Provider\ProviderInterface; +use KTXM\ProviderImapMail\Console\ServiceConnectCommand; +use KTXM\ProviderImapMail\Console\ServiceDiscoverCommand; +use KTXM\ProviderImapMail\Console\ServiceDisconnectCommand; +use KTXM\ProviderImapMail\Console\ServiceTestCommand; +use KTXM\ProviderImapMail\Providers\Provider as MailProvider; + +/** + * IMAP Mail Provider Module + * + * Registers the IMAP mail provider with the Ktrix provider manager. + */ +class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface +{ + public function __construct( + private readonly ProviderManager $providerManager, + ) {} + + public function handle(): string + { + return 'provider_imap_mail'; + } + + public function label(): string + { + return 'IMAP Mail Provider'; + } + + public function author(): string + { + return 'Ktrix'; + } + + public function description(): string + { + return 'IMAP provider module for Ktrix — provides mail services via the IMAP protocol'; + } + + public function version(): string + { + return '0.0.1'; + } + + public function permissions(): array + { + return [ + 'provider_imap_mail' => [ + 'label' => 'Access IMAP Mail Provider', + 'description' => 'View and access the IMAP mail provider module', + 'group' => 'Providers', + ], + ]; + } + + public function boot(): void + { + $this->providerManager->register(ProviderInterface::TYPE_MAIL, 'imap', MailProvider::class); + } + + public function registerCI(): array + { + return [ + ServiceDiscoverCommand::class, + ServiceConnectCommand::class, + ServiceDisconnectCommand::class, + ServiceTestCommand::class, + ]; + } + + public function registerBI(): array + { + return [ + 'handle' => $this->handle(), + 'namespace' => 'ProviderImapMail', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description(), + 'boot' => 'static/module.mjs', + ]; + } +} diff --git a/lib/Providers/CollectionProperties.php b/lib/Providers/CollectionProperties.php new file mode 100644 index 0000000..729e086 --- /dev/null +++ b/lib/Providers/CollectionProperties.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use Gricob\IMAP\Mailbox; +use KTXF\Mail\Collection\CollectionPropertiesMutableAbstract; +use KTXF\Mail\Collection\CollectionRoles; + +/** + * IMAP Mail Collection Properties + * + * Backed by the same internal $data shape as the JMAP provider so that cache + * documents are interchangeable with fromStore() / toStore(). + */ +class CollectionProperties extends CollectionPropertiesMutableAbstract +{ + // ── IMAP hydration ─────────────────────────────────────────────────────── + + /** + * Populate from a gricob Mailbox object. + * + * Total / unread counts are NOT available from a LIST response alone. + * They must be set separately (after SELECT + SEARCH UNSEEN). + */ + public function fromImap(Mailbox $mailbox): static + { + $this->data['label'] = $mailbox->name; + $this->data['delimiter'] = $mailbox->hierarchyDelimiter; + $this->data['attributes'] = $mailbox->nameAttributes; + $this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true); + $this->data['total'] = 0; + $this->data['unread'] = 0; + $this->data['rank'] = 0; + + // Map standard IMAP role attributes + $this->data['role'] = $this->roleFromAttributes($mailbox->nameAttributes)->value; + + return $this; + } + + // ── Store (MongoDB cache) ──────────────────────────────────────────────── + + public function toStore(): array + { + return $this->data; + } + + public function fromStore(array $data): static + { + $this->data = $data; + return $this; + } + + // ── JSON helpers ───────────────────────────────────────────────────────── + + public function jsonSerialize(): array + { + return $this->data; + } + + public function getDelimiter(): ?string + { + return $this->data['delimiter'] ?? null; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function roleFromAttributes(array $attributes): CollectionRoles + { + foreach ($attributes as $attr) { + $lower = strtolower($attr); + $role = match ($lower) { + '\sent' => CollectionRoles::Sent, + '\trash' => CollectionRoles::Trash, + '\drafts' => CollectionRoles::Drafts, + '\junk' => CollectionRoles::Junk, + '\archive' => CollectionRoles::Archive, + default => null, + }; + if ($role !== null) { + return $role; + } + } + return CollectionRoles::Custom; + } +} diff --git a/lib/Providers/CollectionResource.php b/lib/Providers/CollectionResource.php new file mode 100644 index 0000000..86f9b27 --- /dev/null +++ b/lib/Providers/CollectionResource.php @@ -0,0 +1,101 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use Gricob\IMAP\Mailbox; +use KTXF\Mail\Collection\CollectionMutableAbstract; + +/** + * IMAP Mail Collection Resource + * + * Represents one IMAP mailbox / folder. + */ +class CollectionResource extends CollectionMutableAbstract +{ + public function __construct( + string $provider = 'imap', + string|int|null $service = null, + ) { + parent::__construct($provider, $service); + } + + // ── IMAP hydration ─────────────────────────────────────────────────────── + + /** + * Populate from a gricob Mailbox object. + * + * @param Mailbox $mailbox gricob Mailbox value object from LIST response + */ + public function fromImap(Mailbox $mailbox): static + { + // The mailbox name is its unique identifier within the account + $this->data['identifier'] = $mailbox->name; + + // Derive parent collection from path + delimiter + $delimiter = $mailbox->hierarchyDelimiter; + if ($delimiter && str_contains($mailbox->name, $delimiter)) { + $parts = explode($delimiter, $mailbox->name); + array_pop($parts); + $this->data['collection'] = implode($delimiter, $parts); + } else { + $this->data['collection'] = null; + } + + $this->getProperties()->fromImap($mailbox); + + return $this; + } + + // ── Store (MongoDB cache) ──────────────────────────────────────────────── + + /** + * Serialise to a MongoDB document. + * + * The caller must inject the service UUID as `sid` before persisting. + */ + public function toStore(): array + { + return array_merge( + $this->data, + [ + 'name' => $this->data['identifier'], + 'properties' => $this->getProperties()->toStore(), + ], + ); + } + + public function fromStore(array $data): static + { + $this->data = $data; + if (isset($data['properties'])) { + $this->getProperties()->fromStore($data['properties']); + } + return $this; + } + + // ── Getters (lazy properties init) ─────────────────────────────────────── + + public function getProperties(): CollectionProperties + { + if (!isset($this->properties)) { + $this->properties = new CollectionProperties([]); + } + return $this->properties; + } + + // ── JSON ───────────────────────────────────────────────────────────────── + + public function jsonSerialize(): array + { + $data = $this->data; + $data['properties'] = $this->getProperties()->jsonSerialize(); + return $data; + } +} diff --git a/lib/Providers/EntityResource.php b/lib/Providers/EntityResource.php new file mode 100644 index 0000000..4bbbdaf --- /dev/null +++ b/lib/Providers/EntityResource.php @@ -0,0 +1,90 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use DateTimeInterface; +use Gricob\IMAP\Mime\Part\Part; +use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; +use KTXF\Mail\Entity\EntityMutableAbstract; + +/** + * Mail Entity Resource Implementation + */ +class EntityResource extends EntityMutableAbstract { + + public function __construct( + string $provider = 'imap', + string|int|null $service = null, + ) { + parent::__construct($provider, $service); + } + + /** + * Convert gricob FetchData to mail entity object + * + * @param FetchData $fetchData result from IMAP FETCH command + * @param string $mailbox IMAP mailbox name (used as collection) + * @param Part|null $bodyPart MIME Part tree for body content (optional) + */ + public function fromImap(FetchData $fetchData, string $mailbox, ?Part $bodyPart = null): static { + + // Collection = the IMAP mailbox name + $this->data['collection'] = $mailbox; + + // Identifier = UID (preferred) or sequence number as fallback + $this->data['identifier'] = $fetchData->uid ?? $fetchData->id; + + // Created = INTERNALDATE (server arrival time) + if ($fetchData->internalDate !== null) { + $this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM); + } + + $this->getProperties()->fromImap( + flags: $fetchData->flags ?? [], + envelope: $fetchData->envelope, + bodyStructure: $fetchData->bodyStructure, + size: $fetchData->rfc822Size ?? 0, + bodyPart: $bodyPart, + ); + + return $this; + } + + /** + * Convert mail entity object to store array + */ + public function toStore(): array { + return array_merge( + $this->data, + ['properties' => $this->getProperties()->toStore()] + ); + } + + /** + * Hydrate mail entity object from store array + */ + public function fromStore(array $data): static { + $properties = $data['properties'] ?? []; + unset($data['properties']); + $this->data = $data; + $this->getProperties()->fromStore($properties); + return $this; + } + + /** + * @inheritDoc + */ + public function getProperties(): MessageProperties { + if (!isset($this->properties)) { + $this->properties = new MessageProperties([]); + } + return $this->properties; + } +} diff --git a/lib/Providers/MessageAttachment.php b/lib/Providers/MessageAttachment.php new file mode 100644 index 0000000..4fdf3a8 --- /dev/null +++ b/lib/Providers/MessageAttachment.php @@ -0,0 +1,128 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +/** + * Mail Attachment Object + * + * @since 1.0.0 + */ +class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface { + + protected MessagePart $_meta; + protected ?string $_contents = null; + + public function __construct(?MessagePart $meta = null, ?string $contents = null) { + if ($meta === null) { + $meta = new MessagePart(); + $meta->setDisposition('attachment'); + $meta->setType('application/octet-stream'); + } + $this->setParameters($meta); + if ($contents !== null) { + $this->setContents($contents); + } + } + + /** + * Sets the attachment parameters + */ + public function setParameters(?MessagePart $meta): static { + $this->_meta = $meta; + return $this; + } + + /** + * Gets the attachment parameters + */ + public function getParameters(): MessagePart { + return $this->_meta; + } + + /** + * Returns the unique identifier for this attachment (MIME part id) + */ + public function id(): string { + return $this->_meta->getBlobId() ?? $this->_meta->getId() ?? ''; + } + + /** + * Sets the attachment file name + */ + public function setName(string $value): static { + $this->_meta->setName($value); + return $this; + } + + /** + * Gets the attachment file name + */ + public function getName(): ?string { + return $this->_meta->getName(); + } + + /** + * Sets the attachment MIME type + */ + public function setType(string $value): static { + $this->_meta->setType($value); + return $this; + } + + /** + * Gets the attachment MIME type + */ + public function getType(): ?string { + return $this->_meta->getType(); + } + + /** + * Sets the attachment contents (binary data) + */ + public function setContents(string $value): static { + $this->_contents = $value; + return $this; + } + + /** + * Gets the attachment contents + */ + public function getContents(): ?string { + return $this->_contents; + } + + /** + * Sets whether the attachment is embedded (inline) + */ + public function setEmbedded(bool $value): static { + $this->_meta->setDisposition($value ? 'inline' : 'attachment'); + return $this; + } + + /** + * Gets whether the attachment is embedded (inline) + */ + public function getEmbedded(): bool { + return $this->_meta->getDisposition() === 'inline'; + } + + // ────────────────────────────────────────────────────────────── + // MessagePartInterface pass-throughs + // ────────────────────────────────────────────────────────────── + + public function getBlobId(): ?string { return $this->_meta->getBlobId(); } + public function getId(): ?string { return $this->_meta->getId(); } + public function getDisposition(): ?string { return $this->_meta->getDisposition(); } + public function getCharset(): ?string { return $this->_meta->getCharset(); } + public function getLanguage(): ?string { return $this->_meta->getLanguage(); } + public function getLocation(): ?string { return $this->_meta->getLocation(); } + public function getParts(): array { return $this->_meta->getParts(); } + public function jsonSerialize(): array { return $this->_meta->jsonSerialize(); } +} diff --git a/lib/Providers/MessagePart.php b/lib/Providers/MessagePart.php new file mode 100644 index 0000000..eba49e6 --- /dev/null +++ b/lib/Providers/MessagePart.php @@ -0,0 +1,176 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; +use KTXF\Mail\Object\MessagePartMutableAbstract; + +/** + * Mail Message Part Implementation + */ +class MessagePart extends MessagePartMutableAbstract { + + /** + * Convert gricob BodyStructure part to message part object + * + * @param Part $part gricob BodyStructure Part (SinglePart or MultiPart) + * @param string $partId numeric part identifier (e.g. "1", "1.1", "2") + */ + public function fromImap(Part $part, string $partId = '1'): static { + + $this->data['partId'] = $partId; + + if ($part instanceof SinglePart) { + $mimeType = strtolower($part->type) . '/' . strtolower($part->subtype); + $this->data['type'] = $mimeType; + + if ($part->id !== null) { + $this->data['blobId'] = trim($part->id, '<>'); + } + + // Content-Type parameters (name, charset, etc.) + if (!empty($part->attributes)) { + foreach ($part->attributes as $key => $value) { + $keyLower = strtolower($key); + if ($keyLower === 'name') { + $this->data['name'] = $value; + } elseif ($keyLower === 'charset') { + $this->data['charset'] = $value; + } + } + } + + if ($part->encoding !== null) { + $this->data['encoding'] = strtolower($part->encoding); + } + + if ($part->size !== null) { + $this->data['size'] = $part->size; + } + + if ($part->disposition !== null) { + $this->data['disposition'] = strtolower($part->disposition->type); + // disposition filename attribute + if (!empty($part->disposition->attributes)) { + foreach ($part->disposition->attributes as $key => $value) { + if (strtolower($key) === 'filename') { + $this->data['name'] = $this->data['name'] ?? $value; + } + } + } + } + + if (!empty($part->language)) { + $this->data['language'] = implode(',', $part->language); + } + + if ($part->location !== null) { + $this->data['location'] = $part->location; + } + + } elseif ($part instanceof MultiPart) { + $this->data['type'] = 'multipart/' . strtolower($part->subtype); + + if ($part->disposition !== null) { + $this->data['disposition'] = strtolower($part->disposition->type); + } + + if (!empty($part->language)) { + $this->data['language'] = implode(',', $part->language); + } + + if ($part->location !== null) { + $this->data['location'] = $part->location; + } + + // Recursively process sub-parts + foreach ($part->parts as $index => $subPart) { + $subPartId = $partId . '.' . ($index + 1); + $this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId); + } + } + + return $this; + } + + /** + * Convert message part to store array + */ + public function toStore(): array { + $data = $this->data; + if (count($this->parts) > 0) { + $data['subParts'] = []; + foreach ($this->parts as $subPart) { + if ($subPart instanceof MessagePart) { + $data['subParts'][] = $subPart->toStore(); + } + } + } else { + $data['subParts'] = null; + } + return $data; + } + + /** + * Hydrate message part from store array + */ + public function fromStore(array $data): static { + if (isset($data['subParts']) && is_array($data['subParts'])) { + foreach ($data['subParts'] as $subPart) { + $this->parts[] = (new MessagePart())->fromStore($subPart); + } + unset($data['subParts']); + } + $this->data = $data; + return $this; + } + + /** + * Inject decoded body content from a parallel gricob Mime Part tree. + * + * Walks the gricob Mime Part tree alongside this MessagePart tree and + * sets 'content' on each leaf single-part node from its decoded body. + * + * @param \Gricob\IMAP\Mime\Part\Part $mimePart Corresponding gricob Mime Part node + */ + public function injectBodyContent(\Gricob\IMAP\Mime\Part\Part $mimePart): void + { + if ($mimePart instanceof \Gricob\IMAP\Mime\Part\MultiPart) { + foreach ($mimePart->parts as $index => $childMimePart) { + $childPart = $this->parts[$index] ?? null; + if ($childPart instanceof MessagePart) { + $childPart->injectBodyContent($childMimePart); + } + } + return; + } + + if ($mimePart instanceof \Gricob\IMAP\Mime\Part\SinglePart) { + // Only inject content for text/* parts; binary parts (images, PDFs, …) + // produce raw bytes that cannot be JSON-encoded as UTF-8 strings. + $type = strtolower($this->data['type'] ?? ''); + if (!str_starts_with($type, 'text/')) { + return; + } + try { + $decoded = $mimePart->decodedBody(); + } catch (\Throwable) { + return; + } + if ($decoded !== null && $decoded !== '') { + $charset = $mimePart->charset() ?? 'utf-8'; + $this->data['content'] = MessageProperties::toUtf8($decoded, $charset); + } + } + } + +} diff --git a/lib/Providers/MessageProperties.php b/lib/Providers/MessageProperties.php new file mode 100644 index 0000000..226b177 --- /dev/null +++ b/lib/Providers/MessageProperties.php @@ -0,0 +1,220 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use DateTimeImmutable; +use DateTimeInterface; +use Gricob\IMAP\Mime\Part\Part as MimePart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope; +use KTXF\Mail\Object\MessagePropertiesMutableAbstract; + +/** + * Mail Message Properties Implementation + */ +class MessageProperties extends MessagePropertiesMutableAbstract { + + /** + * Convert IMAP data to mail message properties object + * + * @param array $flags IMAP flags (e.g. ['\Seen', '\Flagged', ...]) + * @param ?Envelope $envelope parsed envelope from gricob + * @param ?BodyStructure $bodyStructure parsed body structure from gricob + * @param int $size RFC822.SIZE byte count + */ + public function fromImap( + array $flags, + ?Envelope $envelope, + ?BodyStructure $bodyStructure, + int $size = 0, + ?MimePart $bodyPart = null, + ): static { + + // ── Size ────────────────────────────────────────────────────── + $this->data['size'] = $size; + + // ── Flags ───────────────────────────────────────────────────── + $this->data['flags'] = []; + foreach ($flags as $flag) { + $flag = ltrim($flag, '\\'); + $normalized = match (strtolower($flag)) { + 'seen' => 'read', + 'flagged' => 'flagged', + 'answered' => 'answered', + 'draft' => 'draft', + 'deleted' => 'deleted', + default => strtolower($flag), + }; + $this->data['flags'][$normalized] = true; + } + + // ── Envelope ────────────────────────────────────────────────── + if ($envelope !== null) { + + if ($envelope->messageId !== null) { + $this->data['urid'] = trim($envelope->messageId, '<>'); + } + + if ($envelope->subject !== null) { + // Decode MIME encoded-word in subject + $this->data['subject'] = mb_decode_mimeheader($envelope->subject); + } + + if ($envelope->date !== null) { + $date = $envelope->date instanceof DateTimeImmutable + ? $envelope->date + : new DateTimeImmutable($envelope->date); + $this->data['date'] = $date->format(DateTimeInterface::ATOM); + } + + if ($envelope->inReplyTo !== null) { + $this->data['inReplyTo'] = $envelope->inReplyTo; + } + + $addressToArray = static function ($addr): array { + $email = ''; + if ($addr->mailboxName !== null && $addr->hostName !== null) { + $email = $addr->mailboxName . '@' . $addr->hostName; + } elseif ($addr->mailboxName !== null) { + $email = $addr->mailboxName; + } + return [ + 'address' => $email, + 'label' => $addr->displayName ?? null, + ]; + }; + + if (!empty($envelope->from)) { + $this->data['from'] = $addressToArray($envelope->from[0]); + } + + if (!empty($envelope->sender)) { + $this->data['sender'] = $addressToArray($envelope->sender[0]); + } + + foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { + $envField = $field === 'replyTo' ? 'replyTo' : $field; + if (!empty($envelope->$envField)) { + $this->data[$field] = []; + foreach ($envelope->$envField as $addr) { + $this->data[$field][] = $addressToArray($addr); + } + } + } + } + + // ── Body Structure ──────────────────────────────────────────── + if ($bodyStructure !== null) { + $rootPart = (new MessagePart())->fromImap($bodyStructure->part, '1'); + + // ── Body Content: inject decoded content onto part nodes ────── + if ($bodyPart !== null) { + $rootPart->injectBodyContent($bodyPart); + } + + $this->data['body'] = $rootPart->toStore(); + + // Collect attachments: non-body parts with name or attachment disposition + $attachments = []; + self::collectAttachments($bodyStructure->part, '1', $attachments); + if (!empty($attachments)) { + $this->data['attachments'] = $attachments; + } + } + + return $this; + } + + /** + * Convert a string to UTF-8 from the given charset. + * + * Tries mb_convert_encoding first; falls back to iconv when mbstring does + * not recognise the charset name (e.g. "windows-1250"). + */ + public static function toUtf8(string $content, string $charset): string + { + if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) { + // Content claims to be UTF-8 but may still have invalid sequences; scrub to be safe. + return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); + } + // Try mbstring first + try { + $converted = mb_convert_encoding($content, 'UTF-8', $charset); + if ($converted !== false) { + return $converted; + } + } catch (\ValueError) { + // charset not recognised by mbstring — fall through to iconv + } + // iconv fallback (handles Windows-125x, ISO-8859-*, etc.) + $converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $content); + $content = ($converted !== false) ? $converted : $content; + // Final scrub: strip any residual invalid UTF-8 bytes so json_encode never fails. + return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); + } + + /** + * Recursively collect attachment parts from body structure + */ + private static function collectAttachments( + Part $part, + string $partId, + array &$attachments, + ): void { + if ($part instanceof SinglePart) { + $type = strtolower($part->type ?? ''); + $subtype = strtolower($part->subtype ?? ''); + $disposition = strtolower($part->disposition?->type ?? ''); + $name = null; + if (!empty($part->attributes)) { + foreach ($part->attributes as $k => $v) { + if (strtolower($k) === 'name') { + $name = $v; + break; + } + } + } + if (!empty($part->disposition?->attributes)) { + foreach ($part->disposition->attributes as $k => $v) { + if (strtolower($k) === 'filename') { + $name = $name ?? $v; + } + } + } + $isInlineText = ($type === 'text' && ($subtype === 'plain' || $subtype === 'html') && $disposition !== 'attachment'); + if (!$isInlineText && ($disposition !== '' || $name !== null)) { + $mp = (new MessagePart())->fromImap($part, $partId); + $attachments[] = $mp->toStore(); + } + } elseif ($part instanceof MultiPart) { + foreach ($part->parts as $index => $subPart) { + self::collectAttachments($subPart, $partId . '.' . ($index + 1), $attachments); + } + } + } + + /** + * Serialize to store array + */ + public function toStore(): array { + return $this->data; + } + + /** + * Hydrate from store array + */ + public function fromStore(array $data): static { + $this->data = $data; + return $this; + } +} diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php new file mode 100644 index 0000000..e4b0326 --- /dev/null +++ b/lib/Providers/Provider.php @@ -0,0 +1,246 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use KTXF\Mail\Provider\ProviderBaseInterface; +use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; +use KTXF\Mail\Provider\ProviderServiceMutateInterface; +use KTXF\Mail\Provider\ProviderServiceTestInterface; +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Provider\ResourceServiceMutateInterface; +use KTXM\ProviderImapMail\Service\Discovery; +use KTXM\ProviderImapMail\Service\Remote\RemoteService; +use KTXM\ProviderImapMail\Stores\ServiceStore; + +/** + * IMAP Mail Provider + * + * Registers IMAP as a mail provider and handles service lifecycle: + * list / fetch / create / modify / destroy / discover / test. + */ +class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface +{ + public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; + + protected const PROVIDER_IDENTIFIER = 'imap'; + protected const PROVIDER_LABEL = 'IMAP Mail Provider'; + protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol'; + protected const PROVIDER_ICON = 'mdi-email'; + + protected array $providerAbilities = [ + self::CAPABILITY_SERVICE_LIST => true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + self::CAPABILITY_SERVICE_CREATE => true, + self::CAPABILITY_SERVICE_MODIFY => true, + self::CAPABILITY_SERVICE_DESTROY => true, + self::CAPABILITY_SERVICE_TEST => true, + ]; + + public function __construct( + private readonly ServiceStore $serviceStore, + ) {} + + // ── ProviderBaseInterface ───────────────────────────────────────────────── + + public function jsonSerialize(): array + { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, + self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, + ]; + } + + public function jsonDeserialize(array|string $data): static + { + return $this; + } + + public function type(): string + { + return self::TYPE_MAIL; + } + + public function identifier(): string + { + return self::PROVIDER_IDENTIFIER; + } + + public function label(): string + { + return self::PROVIDER_LABEL; + } + + public function description(): string + { + return self::PROVIDER_DESCRIPTION; + } + + public function icon(): string + { + return self::PROVIDER_ICON; + } + + public function capable(string $value): bool + { + return !empty($this->providerAbilities[$value]); + } + + public function capabilities(): array + { + return $this->providerAbilities; + } + + // ── ProviderServiceMutateInterface ──────────────────────────────────────── + + public function serviceList(string $tenantId, string $userId, array $filter = []): array + { + $list = $this->serviceStore->list($tenantId, $userId, $filter); + $result = []; + foreach ($list as $entry) { + $service = new Service(); + $service->fromStore($entry); + $result[$service->identifier()] = $service; + } + return $result; + } + + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service + { + return $this->serviceStore->fetch($tenantId, $userId, $identifier); + } + + public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service + { + /** @var Service[] $services */ + $services = $this->serviceList($tenantId, $userId); + foreach ($services as $service) { + if ($service->hasAddress($address)) { + return $service; + } + } + return null; + } + + public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array + { + return $this->serviceStore->extant($tenantId, $userId, $identifiers); + } + + public function serviceFresh(): ResourceServiceMutateInterface + { + return new Service(); + } + + public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); + } + + $created = $this->serviceStore->create($tenantId, $userId, $service); + return (string) $created->identifier(); + } + + public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string + { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); + } + + $updated = $this->serviceStore->modify($tenantId, $userId, $service); + return (string) $updated->identifier(); + } + + public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool + { + if (!($service instanceof Service)) { + return false; + } + + return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); + } + + // ── ProviderServiceDiscoverInterface ────────────────────────────────────── + + public function serviceDiscover( + string $tenantId, + string $userId, + string $identity, + ?string $location = null, + ?string $secret = null, + ): ?ResourceServiceLocationInterface { + $discovery = new Discovery(); + // TODO: Make SSL verification configurable per-tenant + $verifySSL = true; + return $discovery->discover($identity, $location, $secret, $verifySSL); + } + + // ── ProviderServiceTestInterface ────────────────────────────────────────── + + public function serviceTest(ServiceBaseInterface $service, array $options = []): array + { + $startTime = microtime(true); + + try { + if (!($service instanceof Service)) { + throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); + } + + // Attempt to authenticate and list mailboxes as a connectivity check + $wrapper = RemoteService::freshClient($service); + $mailboxes = $wrapper->mailboxes(); + + $latency = (int) round((microtime(true) - $startTime) * 1000); + + return [ + 'success' => true, + 'message' => 'IMAP connection successful' + . ' (Mailboxes: ' . count($mailboxes) . ')' + . ' (Latency: ' . $latency . ' ms)', + ]; + } catch (\Throwable $e) { + $latency = (int) round((microtime(true) - $startTime) * 1000); + + $location = ($service instanceof Service) ? $service->getLocation() : null; + $target = $location + ? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort() + : 'unknown host'; + + // stream_socket_client errors are suppressed with @ in gricob — recover them + $phpError = error_get_last(); + $detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? ''); + + if ($detail === '' && $location !== null) { + $host = $location->getHost(); + if ($host !== '' && gethostbyname($host) === $host) { + $detail = "hostname '{$host}' could not be resolved"; + } else { + $detail = 'connection refused or timed out — check port and encryption settings'; + } + } elseif ($detail === '') { + $detail = 'no details — check host, port, and encryption settings'; + } + + return [ + 'success' => false, + 'message' => sprintf( + 'Connection to %s failed (%s): %s', + $target, + (new \ReflectionClass($e))->getShortName(), + $detail, + ), + ]; + } + } +} diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php new file mode 100644 index 0000000..f3d0010 --- /dev/null +++ b/lib/Providers/Service.php @@ -0,0 +1,515 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use Generator; +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionMutableInterface; +use KTXF\Mail\Object\Address; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Mail\Service\ServiceCollectionMutableInterface; +use KTXF\Mail\Service\ServiceConfigurableInterface; +use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Resource\Provider\ResourceServiceIdentityInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\ProviderImapMail\Providers\ServiceIdentityBasic; +use KTXM\ProviderImapMail\Providers\ServiceLocation; +use KTXM\ProviderImapMail\Service\Remote\RemoteMailService; +use KTXM\ProviderImapMail\Service\Remote\RemoteService; + +/** + * IMAP Mail Service + * + * Represents a single IMAP account configuration and acts as the primary + * entry-point for all mail operations (collections + entities). + * + * The RemoteMailService is initialised lazily on first use so that the object + * can be constructed cheaply for serialisation/deserialisation tasks. + */ +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface +{ + public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; + + private const PROVIDER_IDENTIFIER = 'imap'; + + private ?string $serviceTenantId = null; + private ?string $serviceUserId = null; + private ?string $serviceIdentifier = null; + private ?string $serviceLabel = null; + private bool $serviceEnabled = false; + private string $primaryAddress = ''; + private array $secondaryAddresses = []; + private ?ServiceLocation $location = null; + private ?ServiceIdentityBasic $identity = null; + private array $auxiliary = []; + + private array $serviceAbilities = [ + self::CAPABILITY_COLLECTION_LIST => true, + self::CAPABILITY_COLLECTION_LIST_FILTER => [], + self::CAPABILITY_COLLECTION_LIST_SORT => [], + self::CAPABILITY_COLLECTION_EXTANT => true, + self::CAPABILITY_COLLECTION_FETCH => true, + self::CAPABILITY_COLLECTION_CREATE => true, + self::CAPABILITY_COLLECTION_UPDATE => true, + self::CAPABILITY_COLLECTION_DELETE => true, + self::CAPABILITY_ENTITY_LIST => true, + self::CAPABILITY_ENTITY_LIST_FILTER => [ + 'seen' => 'b:0:1:1', + 'flagged' => 'b:0:1:1', + self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1', + self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1', + self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', + self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', + ], + self::CAPABILITY_ENTITY_LIST_SORT => [], + self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']], + self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_FETCH => true, + ]; + + private RemoteMailService $mailService; + + public function __construct() {} + + // ── Lazy initialisation ─────────────────────────────────────────────────── + + private function initialize(): void + { + if (!isset($this->mailService)) { + $wrapper = RemoteService::freshClient($this); + $this->mailService = RemoteService::mailService($this, $wrapper); + } + } + + // ── Store (MongoDB persistence) ─────────────────────────────────────────── + + public function toStore(): array + { + return array_filter([ + 'tid' => $this->serviceTenantId, + 'uid' => $this->serviceUserId, + 'sid' => $this->serviceIdentifier, + 'label' => $this->serviceLabel, + 'enabled' => $this->serviceEnabled, + 'primaryAddress' => $this->primaryAddress, + 'secondaryAddresses'=> $this->secondaryAddresses, + 'location' => $this->location?->toStore(), + 'identity' => $this->identity?->toStore(), + 'auxiliary' => $this->auxiliary, + ], fn($v) => $v !== null); + } + + public function fromStore(array $data): static + { + $this->serviceTenantId = $data['tid'] ?? null; + $this->serviceUserId = $data['uid'] ?? null; + $this->serviceIdentifier = $data['sid'] ?? null; + $this->serviceLabel = $data['label'] ?? ''; + $this->serviceEnabled = $data['enabled'] ?? false; + + if (isset($data['primaryAddress'])) { + $this->primaryAddress = $data['primaryAddress']; + } + if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { + $this->secondaryAddresses = $data['secondaryAddresses']; + } + if (isset($data['location'])) { + $this->location = (new ServiceLocation())->fromStore($data['location']); + } + if (isset($data['identity'])) { + $this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']); + } + if (isset($data['auxiliary']) && is_array($data['auxiliary'])) { + $this->auxiliary = $data['auxiliary']; + } + + return $this; + } + + // ── JSON ────────────────────────────────────────────────────────────────── + + public function jsonSerialize(): array + { + return array_filter([ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, + self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, + self::JSON_PROPERTY_LABEL => $this->serviceLabel, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, + self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, + self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), + self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), + self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, + ], fn($v) => $v !== null); + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } + + if (isset($data[self::JSON_PROPERTY_LABEL])) { + $this->setLabel($data[self::JSON_PROPERTY_LABEL]); + } + if (isset($data[self::JSON_PROPERTY_ENABLED])) { + $this->setEnabled($data[self::JSON_PROPERTY_ENABLED]); + } + if (isset($data[self::JSON_PROPERTY_LOCATION])) { + $this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION])); + } + if (isset($data[self::JSON_PROPERTY_IDENTITY])) { + $this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY])); + } + if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) { + $this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])); + } + if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) { + $this->setSecondaryAddresses(array_map( + fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr), + $data[self::JSON_PROPERTY_SECONDARY_ADDRESSES] + )); + } + if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) { + $this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]); + } + + return $this; + } + + // ── ServiceBaseInterface ────────────────────────────────────────────────── + + public function capable(string $value): bool + { + return isset($this->serviceAbilities[$value]); + } + + public function capabilities(): array + { + return $this->serviceAbilities; + } + + public function provider(): string + { + return self::PROVIDER_IDENTIFIER; + } + + public function identifier(): string|int + { + return $this->serviceIdentifier; + } + + // ── ServiceMutableInterface ─────────────────────────────────────────────── + + public function getLabel(): ?string + { + return $this->serviceLabel; + } + + public function setLabel(string $label): static + { + $this->serviceLabel = $label; + return $this; + } + + public function getEnabled(): bool + { + return $this->serviceEnabled; + } + + public function setEnabled(bool $enabled): static + { + $this->serviceEnabled = $enabled; + return $this; + } + + public function getPrimaryAddress(): AddressInterface + { + return new Address($this->primaryAddress); + } + + public function setPrimaryAddress(AddressInterface $value): static + { + $this->primaryAddress = $value->getAddress(); + return $this; + } + + public function getSecondaryAddresses(): array + { + return $this->secondaryAddresses; + } + + public function setSecondaryAddresses(array $addresses): static + { + $this->secondaryAddresses = $addresses; + return $this; + } + + public function hasAddress(string $address): bool + { + $address = strtolower(trim($address)); + + if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) { + return true; + } + foreach ($this->secondaryAddresses as $secondary) { + $secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary; + if (strtolower($secondaryAddr) === $address) { + return true; + } + } + + return false; + } + + // ── ServiceConfigurableInterface ────────────────────────────────────────── + + public function getLocation(): ServiceLocation + { + return $this->location; + } + + public function setLocation(ResourceServiceLocationInterface $location): static + { + $this->location = $location; + return $this; + } + + public function freshLocation(?string $type = null, array $data = []): ServiceLocation + { + $loc = new ServiceLocation(); + $loc->jsonDeserialize($data); + return $loc; + } + + public function getIdentity(): ServiceIdentityBasic + { + return $this->identity; + } + + public function setIdentity(ResourceServiceIdentityInterface $identity): static + { + $this->identity = $identity; + return $this; + } + + public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic + { + $id = new ServiceIdentityBasic(); + $id->jsonDeserialize($data); + return $id; + } + + public function getDebug(): bool + { + return ($this->auxiliary['debug'] ?? false) === true; + } + + public function setDebug(bool $debug): static + { + $this->auxiliary['debug'] = $debug; + return $this; + } + + public function getAuxiliary(): array + { + return $this->auxiliary; + } + + public function setAuxiliary(array $auxiliary): static + { + $this->auxiliary = $auxiliary; + return $this; + } + + // ── Collection operations ───────────────────────────────────────────────── + + public function collectionList(string|int|null $location, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array + { + $this->initialize(); + return $this->mailService->collectionList(); + } + + public function collectionListFilter(): Filter + { + return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); + } + + public function collectionListSort(): Sort + { + return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); + } + + public function collectionExtant(string|int ...$identifiers): array + { + $this->initialize(); + + $existing = $this->mailService->collectionList(); + $extant = []; + foreach ($identifiers as $id) { + $extant[(string) $id] = isset($existing[(string) $id]); + } + return $extant; + } + + public function collectionFetch(string|int $identifier): ?CollectionBaseInterface + { + $this->initialize(); + return $this->mailService->collectionFetch((string) $identifier); + } + + public function collectionFresh(): CollectionMutableInterface + { + return new CollectionResource(); + } + + public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface + { + $this->initialize(); + + // Resolve the full name: if a parent location is given, prepend it + $label = $collection->getProperties()->getLabel() ?? ''; + if ($location !== null && $location !== '') { + // Determine the hierarchy delimiter from an existing mailbox, default to '/' + $existing = $this->mailService->collectionList(); + $delimiter = '/'; + foreach ($existing as $c) { + $props = $c->getProperties(); + if ($props instanceof CollectionProperties) { + $d = $props->getDelimiter(); + if ($d !== null && $d !== '') { + $delimiter = $d; + break; + } + } + } + $label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter); + } + + return $this->mailService->collectionCreate($label); + } + + public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface + { + $this->initialize(); + + // In IMAP, "update" = rename to the new label + $newName = $collection->getProperties()->getLabel() ?? (string) $identifier; + return $this->mailService->collectionRename((string) $identifier, $newName); + } + + public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool + { + $this->initialize(); + return $this->mailService->collectionDestroy((string) $identifier); + } + + public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface + { + $this->initialize(); + + // IMAP RENAME effectively moves+renames the mailbox + $existing = $this->mailService->collectionFetch((string) $identifier); + $label = $existing?->getProperties()->getLabel() ?? basename((string) $identifier); + $newName = $targetLocation !== null ? rtrim((string) $targetLocation, '/') . '/' . $label : $label; + + return $this->mailService->collectionRename((string) $identifier, $newName); + } + + // ── Entity operations ───────────────────────────────────────────────────── + + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array + { + $this->initialize(); + + $uids = $this->mailService->entityList((string) $collection, $filter, $range); + if (empty($uids)) { + return []; + } + + return $this->mailService->entityFetch((string) $collection, ...$uids); + } + + public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator + { + $this->initialize(); + + $uids = $this->mailService->entityList((string) $collection, $filter, $range); + if (empty($uids)) { + return; + } + + yield from $this->mailService->entityFetchStream((string) $collection, ...$uids); + } + + public function entityListFilter(): Filter + { + return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); + } + + public function entityListSort(): Sort + { + return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); + } + + public function entityListRange(RangeType $type): IRange + { + return match ($type) { + RangeType::TALLY => new RangeTally(), + default => new Range(), + }; + } + + /** + * Delta sync is not supported for IMAP (no CONDSTORE/QRESYNC initially). + * Returns an empty Delta so callers detect the absence of changes gracefully. + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta + { + return new Delta(signature: $signature); + } + + public function entityExtant(string|int $collection, string|int ...$identifiers): array + { + $this->initialize(); + + $allUids = $this->mailService->entityList((string) $collection); + $uidSet = array_flip($allUids); // int[] → [uid => index] + $extant = []; + foreach ($identifiers as $id) { + $extant[$id] = isset($uidSet[(int) $id]); + } + return $extant; + } + + public function entityFetch(string|int $collection, string|int ...$identifiers): array + { + $this->initialize(); + + $uids = array_map('intval', $identifiers); + return $this->mailService->entityFetch((string) $collection, ...$uids); + } +} diff --git a/lib/Providers/ServiceIdentityBasic.php b/lib/Providers/ServiceIdentityBasic.php new file mode 100644 index 0000000..737f073 --- /dev/null +++ b/lib/Providers/ServiceIdentityBasic.php @@ -0,0 +1,74 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use KTXF\Resource\Provider\ResourceServiceIdentityBasic; + +/** + * IMAP Service Basic Identity + * + * Username / password (or token) authentication. + */ +class ServiceIdentityBasic implements ResourceServiceIdentityBasic +{ + public function __construct( + private string $identity = '', + private string $secret = '', + ) {} + + public function toStore(): array + { + return [ + 'type' => self::TYPE_BASIC, + 'identity' => $this->identity, + 'secret' => $this->secret, + ]; + } + + public function fromStore(array $data): static + { + return new static( + identity: $data['identity'] ?? '', + secret: $data['secret'] ?? '', + ); + } + + public function jsonSerialize(): array + { + return [ + 'type' => self::TYPE_BASIC, + 'identity' => $this->identity, + // Secret intentionally omitted from serialisation + ]; + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->identity = $data['identity'] ?? ''; + $this->secret = $data['secret'] ?? ''; + + return $this; + } + + public function type(): string + { + return self::TYPE_BASIC; + } + + public function getIdentity(): string { return $this->identity; } + public function setIdentity(string $v): void { $this->identity = $v; } + + public function getSecret(): string { return $this->secret; } + public function setSecret(string $v): void { $this->secret = $v; } +} diff --git a/lib/Providers/ServiceLocation.php b/lib/Providers/ServiceLocation.php new file mode 100644 index 0000000..731f9d5 --- /dev/null +++ b/lib/Providers/ServiceLocation.php @@ -0,0 +1,134 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +use Gricob\IMAP\Configuration; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; + +/** + * IMAP Service Location + * + * Connection details for an IMAP server (host / port / encryption). + */ +class ServiceLocation implements ResourceServiceLocationInterface +{ + public function __construct( + private string $host = '', + private int $port = 993, + private string $encryption = 'ssl', // ssl | tls | starttls | none + private bool $verifyPeer = true, + private bool $verifyPeerName = true, + private bool $allowSelfSigned = false, + ) {} + + // ── Serialisation ──────────────────────────────────────────────────────── + + public function toStore(): array + { + return $this->jsonSerialize(); + } + + public function fromStore(array $data): static + { + return $this->jsonDeserialize($data); + } + + public function jsonSerialize(): array + { + return array_filter([ + 'type' => self::TYPE_URI, + 'host' => $this->host, + 'port' => $this->port, + 'encryption' => $this->encryption, + 'verifyPeer' => $this->verifyPeer, + 'verifyPeerName' => $this->verifyPeerName, + 'allowSelfSigned' => $this->allowSelfSigned, + ], fn($v) => $v !== null && $v !== ''); + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->host = $data['host'] ?? ''; + $this->port = (int)($data['port'] ?? 993); + $this->encryption = $data['encryption'] ?? 'ssl'; + $this->verifyPeer = $data['verifyPeer'] ?? true; + $this->verifyPeerName = $data['verifyPeerName'] ?? true; + $this->allowSelfSigned = $data['allowSelfSigned'] ?? false; + + return $this; + } + + // ── ResourceServiceLocationInterface ───────────────────────────────────── + + public function type(): string + { + return self::TYPE_URI; + } + + public function location(): string + { + return $this->encryption . '://' . $this->host . ':' . $this->port; + } + + // ── Accessors ──────────────────────────────────────────────────────────── + + public function getHost(): string { return $this->host; } + public function setHost(string $v): void { $this->host = $v; } + + public function getPort(): int { return $this->port; } + public function setPort(int $v): void { $this->port = $v; } + + public function getEncryption(): string { return $this->encryption; } + public function setEncryption(string $v): void { $this->encryption = $v; } + + public function getVerifyPeer(): bool { return $this->verifyPeer; } + public function setVerifyPeer(bool $v): void { $this->verifyPeer = $v; } + + public function getVerifyPeerName(): bool { return $this->verifyPeerName; } + public function setVerifyPeerName(bool $v): void { $this->verifyPeerName = $v; } + + public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; } + public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; } + + // ── gricob helper ──────────────────────────────────────────────────────── + + /** + * Build a Gricob IMAP Configuration from this location. + * + * gricob passes the transport directly to stream_socket_client: + * 'ssl' → ssl://host:port (implicit TLS, port 993) + * 'tcp' → tcp://host:port (plain TCP; STARTTLS negotiation is not + * supported by gricob, so starttls/none both + * use plain TCP) + */ + public function toConfiguration(): Configuration + { + // Map our encryption label to a stream_socket_client transport. + // gricob has no STARTTLS negotiation, so starttls falls back to tcp. + $transport = match ($this->encryption) { + 'ssl', 'tls' => 'ssl', + default => 'tcp', // starttls, none + }; + + return new Configuration( + transport: $transport, + host: $this->host, + port: $this->port, + verifyPeer: $this->verifyPeer, + verifyPeerName: $this->verifyPeerName, + allowSelfSigned: $this->allowSelfSigned, + useUid: true, + ); + } +} diff --git a/lib/Providers/ServiceSettings.php b/lib/Providers/ServiceSettings.php new file mode 100644 index 0000000..6e75eb7 --- /dev/null +++ b/lib/Providers/ServiceSettings.php @@ -0,0 +1,83 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Providers; + +enum ServiceMode: string +{ + case Live = 'live'; + case Cached = 'cached'; +} + +enum CacheSyncStrategy: string +{ + case Manual = 'manual'; + case Interval = 'interval'; + case Push = 'push'; +} + +/** + * Per-service operational settings for the IMAP provider. + * + * - debug: Attach a PSR-3 logger to the IMAP client when true + * - mode: live (all reads/writes hit IMAP) | cached (reads from MongoDB) + * - cacheSync: How the cache is kept fresh (only relevant in cached mode) + */ +class ServiceSettings +{ + public function __construct( + private bool $debug = false, + private ServiceMode $mode = ServiceMode::Live, + private CacheSyncStrategy $cacheSync = CacheSyncStrategy::Interval, + ) {} + + // ── Serialisation ──────────────────────────────────────────────────────── + + public function toStore(): array + { + return [ + 'debug' => $this->debug, + 'mode' => $this->mode->value, + 'cacheSync' => $this->cacheSync->value, + ]; + } + + public function fromStore(array $data): static + { + $this->debug = $data['debug'] ?? false; + $this->mode = ServiceMode::from($data['mode'] ?? ServiceMode::Live->value); + $this->cacheSync = CacheSyncStrategy::from($data['cacheSync'] ?? CacheSyncStrategy::Interval->value); + + return $this; + } + + public function jsonSerialize(): array + { + return $this->toStore(); + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + return $this->fromStore($data); + } + + // ── Accessors ──────────────────────────────────────────────────────────── + + public function getDebug(): bool { return $this->debug; } + public function setDebug(bool $v): static { $this->debug = $v; return $this; } + + public function getMode(): ServiceMode { return $this->mode; } + public function setMode(ServiceMode $v): static { $this->mode = $v; return $this; } + + public function getCacheSync(): CacheSyncStrategy { return $this->cacheSync; } + public function setCacheSync(CacheSyncStrategy $v): static { $this->cacheSync = $v; return $this; } +} diff --git a/lib/Service/Cache/CacheMailService.php b/lib/Service/Cache/CacheMailService.php new file mode 100644 index 0000000..08aa72e --- /dev/null +++ b/lib/Service/Cache/CacheMailService.php @@ -0,0 +1,115 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Cache; + +use KTXM\ProviderImapMail\Providers\CollectionResource; +use KTXM\ProviderImapMail\Providers\EntityResource; +use KTXM\ProviderImapMail\Stores\MessageStore; + +/** + * Cache Mail Service + * + * Provides read-only access to locally cached IMAP data stored in MongoDB by + * CacheService. Call CacheService::syncMailboxes() / syncMessages() to keep + * the cache up to date before reading from here. + */ +class CacheMailService +{ + public function __construct( + private readonly MessageStore $messageStore, + private readonly string $provider, + private readonly string|int $service, + ) {} + + // ── Collection (mailbox) reads ──────────────────────────────────────────── + + /** + * Return all cached mailboxes for this service. + * + * @return CollectionResource[] keyed by mailbox name + */ + public function collectionList(): array + { + $docs = $this->messageStore->listMailboxes((string) $this->service); + $result = []; + foreach ($docs as $doc) { + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromStore($doc); + $result[$resource->identifier()] = $resource; + } + return $result; + } + + /** + * Fetch a single cached mailbox by name. + */ + public function collectionFetch(string $name): ?CollectionResource + { + $doc = $this->messageStore->fetchMailbox((string) $this->service, $name); + if ($doc === null) { + return null; + } + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromStore($doc); + return $resource; + } + + // ── Entity (message) reads ──────────────────────────────────────────────── + + /** + * Return all cached UIDs for a mailbox. + * + * @return int[] + */ + public function entityList(string $collection): array + { + return $this->messageStore->listUids((string) $this->service, $collection); + } + + /** + * Fetch one or more cached messages by UID. + * + * @param int ...$uids + * @return EntityResource[] keyed by UID + */ + public function entityFetch(string $collection, int ...$uids): array + { + if (empty($uids)) { + return []; + } + + $docs = $this->messageStore->fetchMessages( + (string) $this->service, + $collection, + array_values($uids), + ); + $result = []; + foreach ($docs as $uid => $doc) { + $resource = new EntityResource($this->provider, $this->service); + $resource->fromStore($doc); + $result[$uid] = $resource; + } + return $result; + } + + /** + * Fetch a single cached message. + */ + public function entityFetchOne(string $collection, int $uid): ?EntityResource + { + $doc = $this->messageStore->fetchMessage((string) $this->service, $collection, $uid); + if ($doc === null) { + return null; + } + $resource = new EntityResource($this->provider, $this->service); + $resource->fromStore($doc); + return $resource; + } +} diff --git a/lib/Service/Cache/CacheService.php b/lib/Service/Cache/CacheService.php new file mode 100644 index 0000000..16b2ece --- /dev/null +++ b/lib/Service/Cache/CacheService.php @@ -0,0 +1,193 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Cache; + +use KTXM\ProviderImapMail\Providers\CollectionResource; +use KTXM\ProviderImapMail\Providers\EntityResource; +use KTXM\ProviderImapMail\Service\Remote\RemoteMailService; +use KTXM\ProviderImapMail\Stores\MessageStore; + +/** + * Cache Service — IMAP Sync Orchestrator + * + * Keeps the local MongoDB cache in sync with a remote IMAP server. + * + * Strategy (UID-based): + * 1. Fetch the complete remote UID set. + * 2. Compare against cached UIDs. + * 3. Fetch and store new UIDs; remove stale UIDs from the cache. + * + * This is always a full-range UID comparison. A CONDSTORE/HIGHESTMODSEQ + * strategy can be layered on later for servers that support RFC 7162. + */ +class CacheService +{ + public function __construct( + private readonly RemoteMailService $remoteMailService, + private readonly MessageStore $messageStore, + private readonly string $provider, + private readonly string|int $service, + ) {} + + // ── Mailbox sync ────────────────────────────────────────────────────────── + + /** + * Synchronise the mailbox list for this service. + * + * Inserts/updates every selectable mailbox returned by the remote server + * and removes cached mailboxes that no longer exist remotely. + * + * @return array{added: string[], removed: string[]} + */ + public function syncMailboxes(): array + { + $remoteCollections = $this->remoteMailService->collectionList(); + + $added = []; + $removed = []; + + // Upsert every remote mailbox + foreach ($remoteCollections as $name => $resource) { + $doc = array_merge( + $resource->toStore(), + ['sid' => (string) $this->service], + ); + $this->messageStore->upsertMailbox($doc); + $added[] = $name; + } + + // Remove cached mailboxes that no longer exist on the server + $cached = $this->messageStore->listMailboxes((string) $this->service); + foreach ($cached as $cachedDoc) { + $name = $cachedDoc['name'] ?? ($cachedDoc['identifier'] ?? null); + if ($name === null) { + continue; + } + if (!isset($remoteCollections[$name])) { + $this->messageStore->deleteMailbox((string) $this->service, $name); + $removed[] = $name; + } + } + + return ['added' => $added, 'removed' => $removed]; + } + + // ── Message sync ────────────────────────────────────────────────────────── + + /** + * Synchronise all messages in one mailbox. + * + * Fetches new UIDs from the remote server, stores them in MongoDB, and + * removes UIDs from the cache that no longer exist on the server. + * + * @return array{added: int[], removed: int[]} + */ + public function syncMessages(string $mailbox): array + { + $remoteUids = $this->remoteMailService->entityList($mailbox); + $cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox); + + $remoteSet = array_fill_keys($remoteUids, true); + $cachedSet = array_fill_keys($cachedUids, true); + + $newUids = array_keys(array_diff_key($remoteSet, $cachedSet)); + $removedUids = array_keys(array_diff_key($cachedSet, $remoteSet)); + + // Stream-fetch new messages one at a time and store each + foreach ($this->remoteMailService->entitySyncStream($mailbox, $newUids) as $uid => $resource) { + /** @var EntityResource $resource */ + $doc = array_merge( + $resource->toStore(), + [ + 'sid' => (string) $this->service, + 'mailbox' => $mailbox, + 'uid' => $uid, + ], + ); + $this->messageStore->upsertMessage($doc); + } + + // Purge stale UIDs from cache + if (!empty($removedUids)) { + $this->messageStore->deleteMessages((string) $this->service, $mailbox, $removedUids); + } + + return [ + 'added' => $newUids, + 'removed' => $removedUids, + ]; + } + + /** + * Perform a full sync: mailboxes first, then all messages in each selectable mailbox. + * + * @return array{mailboxes: array, messages: array} + */ + public function syncAll(): array + { + $mailboxResult = $this->syncMailboxes(); + $messagesResults = []; + + foreach ($this->remoteMailService->collectionList() as $name => $collection) { + try { + $messagesResults[$name] = $this->syncMessages($name); + } catch (\Throwable $e) { + $messagesResults[$name] = ['error' => $e->getMessage()]; + } + } + + return [ + 'mailboxes' => $mailboxResult, + 'messages' => $messagesResults, + ]; + } + + // ── Partial helpers ─────────────────────────────────────────────────────── + + /** + * Sync only the flags of already-cached messages in a mailbox. + * + * This is cheaper than a full message sync: it fetches FLAGS only from + * the remote server and updates cached documents in place. + */ + public function syncFlags(string $mailbox): void + { + $cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox); + + if (empty($cachedUids)) { + return; + } + + // Fetch only FLAGS + UID from remote + $items = ['FLAGS', 'UID']; + + foreach ($this->remoteMailService->entitySyncStream($mailbox, $cachedUids, $items) as $uid => $resource) { + /** @var EntityResource $resource */ + $existing = $this->messageStore->fetchMessage((string) $this->service, $mailbox, $uid); + if ($existing === null) { + continue; + } + + // Merge updated flags into the cached document + $existingProperties = $existing['properties'] ?? []; + $updatedProperties = $resource->toStore()['properties'] ?? []; + + // Only overwrite flag-related fields + foreach (['read', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) { + if (isset($updatedProperties[$field])) { + $existingProperties[$field] = $updatedProperties[$field]; + } + } + + $existing['properties'] = $existingProperties; + $this->messageStore->upsertMessage($existing); + } + } +} diff --git a/lib/Service/Discovery.php b/lib/Service/Discovery.php new file mode 100644 index 0000000..c9691f0 --- /dev/null +++ b/lib/Service/Discovery.php @@ -0,0 +1,284 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service; + +use KTXM\ProviderImapMail\Providers\ServiceLocation; + +/** + * IMAP Service Discovery + * + * Implements RFC 6186 service discovery via DNS SRV records: + * 1. _imaps._tcp. — implicit-TLS IMAP (port 993) + * 2. _imap._tcp. — STARTTLS / plain IMAP (port 143) + * + * Falls back to probing the common default hostnames when SRV fails. + */ +class Discovery +{ + private const DEFAULT_PORT_IMAPS = 993; + private const DEFAULT_PORT_IMAP = 143; + private const CONNECT_TIMEOUT = 5; + + /** + * Discover an IMAP service location from an e-mail address or domain name. + * + * Returns the best (highest-priority) reachable location, or null. + * + * @param string $identity E-mail address or bare domain + * @param string|null $location Optional explicit hostname to test directly + * @param string|null $secret Ignored (IMAP discovery is connection-based) + * @param bool $verifySSL Whether to verify TLS certificates + * @return ServiceLocation|null + */ + public function discover( + string $identity, + ?string $location = null, + ?string $secret = null, + bool $verifySSL = true, + ): ?ServiceLocation { + $all = $this->discoverAll($identity, $location, $secret, $verifySSL); + return $all[0] ?? null; + } + + /** + * Discover ALL reachable IMAP service locations for an e-mail address or domain. + * + * Probes standard ports/encryptions and returns every location that accepted + * a TCP connection, in priority order: + * ssl:993 > ssl:143 > starttls:143 > none:143 + * + * @param string $identity E-mail address or bare domain + * @param string|null $location Optional explicit hostname to test directly + * @param string|null $secret Ignored (IMAP discovery is connection-based) + * @param bool $verifySSL Whether to verify TLS certificates + * @return ServiceLocation[] All reachable locations (may be empty) + */ + public function discoverAll( + string $identity, + ?string $location = null, + ?string $secret = null, + bool $verifySSL = true, + ): array { + // If an explicit host was given, probe it on both standard ports. + if ($location !== null && $location !== '') { + $host = $this->extractHost($location); + if ($host !== null) { + return $this->probeHostAll($host, $verifySSL); + } + return []; + } + + $domain = $this->extractDomain($identity); + if ($domain === null) { + return []; + } + + $results = []; + + // 1. RFC 6186 — _imaps._tcp SRV (implicit TLS, port 993) + $srv = $this->srvLookup("_imaps._tcp.{$domain}"); + if ($srv !== null) { + $loc = $this->testPort($srv['host'], $srv['port'], 'ssl', $verifySSL); + if ($loc !== null) { + $results[] = $loc; + } + } + + // 2. RFC 6186 — _imap._tcp SRV (STARTTLS / plain, port 143) + $srv = $this->srvLookup("_imap._tcp.{$domain}"); + if ($srv !== null) { + foreach (['starttls', 'none'] as $enc) { + $loc = $this->testPort($srv['host'], $srv['port'], $enc, $verifySSL); + if ($loc !== null) { + $results[] = $loc; + } + } + } + + // 3. Fallback — common IMAP host naming conventions + $candidates = [ + "mail.{$domain}", + "imap.{$domain}", + $domain, + ]; + $seen = []; + foreach ($results as $r) { + $seen[$r->getHost()] = true; + } + foreach ($candidates as $host) { + if (isset($seen[$host])) { + continue; + } + $locs = $this->probeHostAll($host, $verifySSL); + foreach ($locs as $loc) { + $results[] = $loc; + } + if ($locs !== []) { + $seen[$host] = true; + } + } + + return $results; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Probe a host on IMAPS (993) and plain IMAP (143), returning the first + * reachable ServiceLocation. + */ + private function probeHost(string $host, bool $verifySSL): ?ServiceLocation + { + return $this->probeHostAll($host, $verifySSL)[0] ?? null; + } + + /** + * Probe a host on all standard IMAP ports and encryptions, returning every + * reachable ServiceLocation in priority order: + * ssl:993 > ssl:143 > starttls:143 > none:143 + * + * @return ServiceLocation[] + */ + private function probeHostAll(string $host, bool $verifySSL): array + { + $results = []; + $probes = [ + [self::DEFAULT_PORT_IMAPS, 'ssl'], + [self::DEFAULT_PORT_IMAP, 'ssl'], + [self::DEFAULT_PORT_IMAP, 'starttls'], + [self::DEFAULT_PORT_IMAP, 'none'], + ]; + foreach ($probes as [$port, $enc]) { + $loc = $this->testPort($host, $port, $enc, $verifySSL); + if ($loc !== null) { + $results[] = $loc; + } + } + return $results; + } + + /** + * Try to open a TCP connection to $host:$port within the timeout. + * + * Returns a ServiceLocation on success, null on failure. + */ + private function testPort(string $host, int $port, string $encryption, bool $verifySSL): ?ServiceLocation + { + $transport = match ($encryption) { + 'ssl' => 'ssl', + 'starttls' => 'tcp', + default => 'tcp', + }; + + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => $verifySSL, + 'verify_peer_name' => $verifySSL, + 'allow_self_signed' => !$verifySSL, + ], + ]); + + try { + $socket = @stream_socket_client( + "{$transport}://{$host}:{$port}", + $errno, + $errstr, + self::CONNECT_TIMEOUT, + STREAM_CLIENT_CONNECT, + $context, + ); + + if ($socket === false) { + return null; + } + + fclose($socket); + + $loc = new ServiceLocation(); + $loc->jsonDeserialize([ + 'host' => $host, + 'port' => $port, + 'encryption' => $encryption, + 'verifyPeer' => $verifySSL, + ]); + return $loc; + } catch (\Throwable) { + return null; + } + } + + /** + * Look up a DNS SRV record and return the host/port, or null. + * + * @return array{host: string, port: int}|null + */ + private function srvLookup(string $srvName): ?array + { + try { + $records = @dns_get_record($srvName, DNS_SRV); + + if ($records === false || empty($records)) { + return null; + } + + // Sort by priority (lowest first), then weight (highest first) + usort($records, static function (array $a, array $b): int { + $prio = ($a['pri'] ?? 0) <=> ($b['pri'] ?? 0); + return $prio !== 0 ? $prio : ($b['weight'] ?? 0) <=> ($a['weight'] ?? 0); + }); + + $record = $records[0]; + + if (!isset($record['target'], $record['port'])) { + return null; + } + + // RFC: target '.' means service unavailable + $target = rtrim((string) $record['target'], '.'); + if ($target === '' || $target === '.') { + return null; + } + + return ['host' => $target, 'port' => (int) $record['port']]; + } catch (\Throwable) { + return null; + } + } + + /** + * Extract the bare domain from an e-mail address. + * + * Returns null when the input cannot be resolved to a domain. + */ + private function extractDomain(string $identity): ?string + { + $identity = trim($identity); + + if (str_contains($identity, '@')) { + [, $domain] = explode('@', $identity, 2); + return strtolower(trim($domain)) ?: null; + } + + // Treat as a bare domain / URL + return $this->extractHost($identity); + } + + /** + * Strip scheme and path from a host-or-URL string. + */ + private function extractHost(string $value): ?string + { + $value = trim($value); + $value = preg_replace('#^[a-zA-Z][a-zA-Z0-9+\-.]*://#', '', $value); + $value = explode('/', $value)[0]; + $value = strtolower($value); + return $value !== '' ? $value : null; + } +} diff --git a/lib/Service/Remote/Command/BodyCriteria.php b/lib/Service/Remote/Command/BodyCriteria.php new file mode 100644 index 0000000..4b747ec --- /dev/null +++ b/lib/Service/Remote/Command/BodyCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP BODY search criteria. + */ +final readonly class BodyCriteria implements Criteria +{ + public function __construct(private string $value) {} + + public function __toString(): string + { + return 'BODY "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; + } +} diff --git a/lib/Service/Remote/Command/CopyCommand.php b/lib/Service/Remote/Command/CopyCommand.php new file mode 100644 index 0000000..f3d6853 --- /dev/null +++ b/lib/Service/Remote/Command/CopyCommand.php @@ -0,0 +1,38 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; +use Gricob\IMAP\Protocol\Command\Argument\QuotedString; +use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; + +/** + * Raw UID COPY command. + * + * gricob does not expose message copying; this thin wrapper fills the gap. + * Accepts a set of UIDs formatted as a comma-separated sequence set. + * + * Example: UID COPY 1,3,7 "INBOX.Archive" + */ +final readonly class CopyCommand extends Command +{ + /** + * @param int[] $uids Source message UIDs + * @param string $destination Target mailbox name + */ + public function __construct(array $uids, string $destination) + { + parent::__construct( + 'UID COPY', + new SequenceSet(...$uids), + new QuotedString($destination), + ); + } +} diff --git a/lib/Service/Remote/Command/DeleteCommand.php b/lib/Service/Remote/Command/DeleteCommand.php new file mode 100644 index 0000000..66aa951 --- /dev/null +++ b/lib/Service/Remote/Command/DeleteCommand.php @@ -0,0 +1,28 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; +use Gricob\IMAP\Protocol\Command\Argument\QuotedString; + +/** + * Raw IMAP DELETE command for a mailbox. + * + * gricob does not expose mailbox deletion; this thin wrapper fills the gap. + * + * Example: DELETE "INBOX.Trash" + */ +final readonly class DeleteCommand extends Command +{ + public function __construct(string $mailbox) + { + parent::__construct('DELETE', new QuotedString($mailbox)); + } +} diff --git a/lib/Service/Remote/Command/FlaggedCriteria.php b/lib/Service/Remote/Command/FlaggedCriteria.php new file mode 100644 index 0000000..40a6bc0 --- /dev/null +++ b/lib/Service/Remote/Command/FlaggedCriteria.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP FLAGGED search criteria. + */ +final readonly class FlaggedCriteria implements Criteria +{ + public function __toString(): string + { + return 'FLAGGED'; + } +} diff --git a/lib/Service/Remote/Command/FromCriteria.php b/lib/Service/Remote/Command/FromCriteria.php new file mode 100644 index 0000000..401b27e --- /dev/null +++ b/lib/Service/Remote/Command/FromCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP FROM search criteria. + */ +final readonly class FromCriteria implements Criteria +{ + public function __construct(private string $value) {} + + public function __toString(): string + { + return 'FROM "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; + } +} diff --git a/lib/Service/Remote/Command/LargerCriteria.php b/lib/Service/Remote/Command/LargerCriteria.php new file mode 100644 index 0000000..5d5f1f6 --- /dev/null +++ b/lib/Service/Remote/Command/LargerCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP LARGER search criteria (messages larger than n octets). + */ +final readonly class LargerCriteria implements Criteria +{ + public function __construct(private int $size) {} + + public function __toString(): string + { + return 'LARGER ' . $this->size; + } +} diff --git a/lib/Service/Remote/Command/RenameCommand.php b/lib/Service/Remote/Command/RenameCommand.php new file mode 100644 index 0000000..a0ec31b --- /dev/null +++ b/lib/Service/Remote/Command/RenameCommand.php @@ -0,0 +1,28 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; +use Gricob\IMAP\Protocol\Command\Argument\QuotedString; + +/** + * Raw IMAP RENAME command. + * + * gricob does not expose mailbox renaming; this thin wrapper fills the gap. + * + * Example: RENAME "OldName" "NewName" + */ +final readonly class RenameCommand extends Command +{ + public function __construct(string $oldName, string $newName) + { + parent::__construct('RENAME', new QuotedString($oldName), new QuotedString($newName)); + } +} diff --git a/lib/Service/Remote/Command/SeenCriteria.php b/lib/Service/Remote/Command/SeenCriteria.php new file mode 100644 index 0000000..5760b29 --- /dev/null +++ b/lib/Service/Remote/Command/SeenCriteria.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP SEEN search criteria. + */ +final readonly class SeenCriteria implements Criteria +{ + public function __toString(): string + { + return 'SEEN'; + } +} diff --git a/lib/Service/Remote/Command/SmallerCriteria.php b/lib/Service/Remote/Command/SmallerCriteria.php new file mode 100644 index 0000000..396cd50 --- /dev/null +++ b/lib/Service/Remote/Command/SmallerCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP SMALLER search criteria (messages smaller than n octets). + */ +final readonly class SmallerCriteria implements Criteria +{ + public function __construct(private int $size) {} + + public function __toString(): string + { + return 'SMALLER ' . $this->size; + } +} diff --git a/lib/Service/Remote/Command/StartTlsCommand.php b/lib/Service/Remote/Command/StartTlsCommand.php new file mode 100644 index 0000000..4706936 --- /dev/null +++ b/lib/Service/Remote/Command/StartTlsCommand.php @@ -0,0 +1,27 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; + +/** + * STARTTLS command (RFC 3501 §6.2.1). + * + * Instructs the server to begin TLS negotiation on the current connection. + * After the server responds OK, the client must call upgradeTls() on the + * underlying SocketConnection to complete the handshake. + */ +final readonly class StartTlsCommand extends Command +{ + public function __construct() + { + parent::__construct('STARTTLS'); + } +} diff --git a/lib/Service/Remote/Command/StoreCommand.php b/lib/Service/Remote/Command/StoreCommand.php new file mode 100644 index 0000000..a569997 --- /dev/null +++ b/lib/Service/Remote/Command/StoreCommand.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; +use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; +use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; + +/** + * Bulk UID STORE command for flag mutations. + * + * A thin ergonomic wrapper around gricob's FetchCommand that accepts an array + * of UIDs and a pre-built Flags argument so callers don't have to construct + * SequenceSet directly. + * + * Example: UID STORE 1,3,7 +FLAGS.SILENT (\Seen) + */ +final readonly class StoreCommand extends Command +{ + /** + * @param int[] $uids UIDs to operate on + * @param Flags $flags e.g. new Flags(['\Seen'], '+') + */ + public function __construct(array $uids, Flags $flags) + { + parent::__construct( + 'UID STORE', + new SequenceSet(...$uids), + $flags, + ); + } +} diff --git a/lib/Service/Remote/Command/StreamFetchCommand.php b/lib/Service/Remote/Command/StreamFetchCommand.php new file mode 100644 index 0000000..e466598 --- /dev/null +++ b/lib/Service/Remote/Command/StreamFetchCommand.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Command; +use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList; +use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; + +/** + * Streaming single-message fetch command. + * + * Wraps gricob's UID FETCH for one or more UIDs with a configurable item list. + * Used inside ImapClientWrapper::streamMessages() (one UID per call) and + * ImapClientWrapper::fetchMessages() (variadic UIDs for bulk prefetch). + * + * Example: UID FETCH 42 (FLAGS ENVELOPE INTERNALDATE BODYSTRUCTURE BODY[]) + */ +final readonly class StreamFetchCommand extends Command +{ + /** + * @param int[] $uids One or more UIDs; formatted as "1,3,7" by SequenceSet + * @param string[] $items IMAP fetch data items (e.g. 'FLAGS', 'ENVELOPE', 'BODY[]') + */ + public function __construct(array $uids, array $items) + { + parent::__construct( + 'UID FETCH', + new SequenceSet(...$uids), + new ParenthesizedList($items), + ); + } +} diff --git a/lib/Service/Remote/Command/SubjectCriteria.php b/lib/Service/Remote/Command/SubjectCriteria.php new file mode 100644 index 0000000..fa818c5 --- /dev/null +++ b/lib/Service/Remote/Command/SubjectCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP SUBJECT search criteria. + */ +final readonly class SubjectCriteria implements Criteria +{ + public function __construct(private string $value) {} + + public function __toString(): string + { + return 'SUBJECT "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; + } +} diff --git a/lib/Service/Remote/Command/ToCriteria.php b/lib/Service/Remote/Command/ToCriteria.php new file mode 100644 index 0000000..79ff386 --- /dev/null +++ b/lib/Service/Remote/Command/ToCriteria.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP TO search criteria. + */ +final readonly class ToCriteria implements Criteria +{ + public function __construct(private string $value) {} + + public function __toString(): string + { + return 'TO "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; + } +} diff --git a/lib/Service/Remote/Command/UnflaggedCriteria.php b/lib/Service/Remote/Command/UnflaggedCriteria.php new file mode 100644 index 0000000..1ffdf7b --- /dev/null +++ b/lib/Service/Remote/Command/UnflaggedCriteria.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP UNFLAGGED search criteria. + */ +final readonly class UnflaggedCriteria implements Criteria +{ + public function __toString(): string + { + return 'UNFLAGGED'; + } +} diff --git a/lib/Service/Remote/Command/UnseenCriteria.php b/lib/Service/Remote/Command/UnseenCriteria.php new file mode 100644 index 0000000..4bde5ad --- /dev/null +++ b/lib/Service/Remote/Command/UnseenCriteria.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote\Command; + +use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; + +/** + * IMAP UNSEEN search criteria. + * + * gricob does not include an UNSEEN criteria; this thin class fills the gap. + * Used with SearchCommand to count unread messages via SEARCH UNSEEN. + */ +final readonly class UnseenCriteria implements Criteria +{ + public function __toString(): string + { + return 'UNSEEN'; + } +} diff --git a/lib/Service/Remote/ImapClientWrapper.php b/lib/Service/Remote/ImapClientWrapper.php new file mode 100644 index 0000000..6df87de --- /dev/null +++ b/lib/Service/Remote/ImapClientWrapper.php @@ -0,0 +1,470 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote; + +use Generator; +use DateTimeInterface; +use Gricob\IMAP\Client; +use Gricob\IMAP\Mailbox; +use Gricob\IMAP\Mime\Part\Body; +use Gricob\IMAP\Mime\Part\Disposition; +use Gricob\IMAP\Mime\Part\LazyBody; +use Gricob\IMAP\Mime\Part\MultiPart; +use Gricob\IMAP\Mime\Part\Part; +use Gricob\IMAP\Mime\Part\SinglePart; +use Gricob\IMAP\Protocol\Command\SearchCommand; +use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; +use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; +use Gricob\IMAP\Protocol\Command\ExpungeCommand; +use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; +use Gricob\IMAP\Protocol\Response\Line\Data\SearchData; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart as BodySinglePart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart as BodyMultiPart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part as BodyPart; +use KTXM\ProviderImapMail\Service\Remote\Command\CopyCommand; +use KTXM\ProviderImapMail\Service\Remote\Command\DeleteCommand; +use KTXM\ProviderImapMail\Service\Remote\Command\RenameCommand; +use KTXM\ProviderImapMail\Service\Remote\Command\StreamFetchCommand; +use KTXM\ProviderImapMail\Service\Remote\Command\StoreCommand as ModuleStoreCommand; +use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria; + +/** + * Wraps a gricob IMAP Client to provide a stable, higher-level interface. + * + * Goals + * ----- + * - Hide raw gricob types from the rest of the service layer + * - Fill gricob's gaps (DELETE, RENAME, UID COPY) with thin command wrappers + * - Provide memory-efficient streaming via a Generator for cache sync + * - Make the wrapper easy to mock in tests + */ +class ImapClientWrapper +{ + public function __construct(private readonly Client $client) {} + + // ── Escape hatch ───────────────────────────────────────────────────────── + + /** Access the underlying gricob Client directly for operations not covered here. */ + public function raw(): Client + { + return $this->client; + } + + // ── Message Fetch ───────────────────────────────────────────────────────── + + /** + * Stream messages one UID at a time to avoid loading entire mailboxes into + * memory. Ideal for cache-sync operations on large mailboxes. + * + * @param string[] $items IMAP fetch data items ('FLAGS', 'ENVELOPE', 'INTERNALDATE', 'BODYSTRUCTURE', 'BODY[]', …) + * @return Generator Yields uid => FetchData + */ + public function streamMessages(string $mailbox, array $uids, array $items): Generator + { + $this->client->select($mailbox); + + $response = $this->client->send(new StreamFetchCommand($uids, $items)); + foreach ($response->getData(FetchData::class) as $fetchData) { + yield ($fetchData->uid ?? $fetchData->id) => $fetchData; + } + } + + /** + * Stream-fetch messages for specific UIDs using the Client's sendStreaming + * path. Responses are processed one at a time as they arrive off the + * socket — no buffering of the full server reply. + * + * Prefer this over streamMessages() for large UID sets where memory + * pressure matters. streamMessages() collects all FetchData objects into + * a Response first; this method yields each one as it is received. + * + * @param int[] $uids + * @param string[] $items IMAP fetch data items + * @return Generator Yields uid => FetchData + */ + public function fetchMultiple(string $mailbox, array $uids, array $items): Generator + { + if (empty($uids)) { + return; + } + + $this->client->select($mailbox); + yield from $this->client->streamByUids($uids, $items); + } + + /** + * Bulk-fetch a known small batch of messages in a single IMAP round-trip. + * + * @param int[] $uids + * @param string[] $items + * @return FetchData[] + */ + public function fetchMessages(string $mailbox, array $uids, array $items): array + { + if (empty($uids)) { + return []; + } + + $this->client->select($mailbox); + $response = $this->client->send(new StreamFetchCommand($uids, $items)); + + return $response->getData(FetchData::class); + } + + // ── Flag Mutation ───────────────────────────────────────────────────────── + + /** + * Apply a flag store operation to multiple messages in a single round-trip. + * + * @param int[] $uids + * @param string $action '+' | '-' | '' (replace) + * @param string[] $flags e.g. ['\Seen', '\Answered'] + */ + public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void + { + if (empty($uids)) { + return; + } + + $this->client->select($mailbox); + $this->client->send(new ModuleStoreCommand($uids, new Flags($flags, $action))); + } + + // ── Message Copy ────────────────────────────────────────────────────────── + + /** + * Copy multiple messages to a destination mailbox in a single round-trip. + * + * @param int[] $uids + */ + public function copyMessages(string $mailbox, array $uids, string $destination): void + { + if (empty($uids)) { + return; + } + + $this->client->select($mailbox); + $this->client->send(new CopyCommand($uids, $destination)); + } + + // ── Mailbox Commands ────────────────────────────────────────────────────── + + /** Delete a mailbox (gricob gap — uses raw DELETE command). */ + public function deleteMailbox(string $mailbox): void + { + $this->client->send(new DeleteCommand($mailbox)); + } + + /** Rename a mailbox (gricob gap — uses raw RENAME command). */ + public function renameMailbox(string $oldName, string $newName): void + { + $this->client->send(new RenameCommand($oldName, $newName)); + } + + /** + * List all mailboxes (via IMAP LIST command). + * + * @return Mailbox[] + */ + public function mailboxes(): array + { + return $this->client->mailboxes(); + } + + /** + * Create a new mailbox. + */ + public function createMailbox(string $name): void + { + $this->client->createMailbox($name); + } + + /** + * Append a raw RFC822 message to a mailbox. + * + * @param string[] $flags e.g. ['\\Seen'] + * @return int the UID assigned to the new message + */ + public function append( + string $rawMessage, + string $mailbox = 'INBOX', + array $flags = [], + ?DateTimeInterface $internalDate = null, + ): int { + return $this->client->append( + $rawMessage, + $mailbox, + !empty($flags) ? $flags : null, + $internalDate, + ); + } + + /** + * Mark multiple messages as \\Deleted then EXPUNGE the mailbox. + * + * Performs a UID STORE +FLAGS.SILENT (\\Deleted) on all UIDs in a single + * round-trip, then issues a plain EXPUNGE. + * + * @param int[] $uids + */ + public function deleteMessages(string $mailbox, array $uids): void + { + if (empty($uids)) { + return; + } + + $this->storeFlags($mailbox, $uids, '+', ['\\Deleted']); + $this->client->send(new ExpungeCommand()); + } + + // ── Search ──────────────────────────────────────────────────────────────── + + /** + * Return all UIDs in the currently-selected mailbox matching UNSEEN. + * + * Sends UID SEARCH UNSEEN directly since gricob's Search builder has no + * unseen() method. Results are UIDs (useUid=true on Configuration). + * + * @return int[] + */ + public function searchUnseen(string $mailbox): array + { + $this->client->select($mailbox); + + $response = $this->client->send( + new SearchCommand( + $this->client->configuration->useUid, + new UnseenCriteria(), + ) + ); + + $ids = []; + foreach ($response->getData(SearchData::class) as $searchData) { + array_push($ids, ...$searchData->numbers); + } + return $ids; + } + + /** + * Return all UIDs in the selected mailbox. + * + * @return int[] + */ + public function searchAll(string $mailbox): array + { + $mailbox = $this->client->select($mailbox); + // search()->get() with no criteria uses ALL; returns LazyMessage[] + // where id() is the UID when useUid=true + $messages = $this->client->search()->get(); + return array_map(fn ($m) => $m->id(), $messages); + } + + /** + * Return UIDs in the selected mailbox matching the given criteria. + * + * Sends a UID SEARCH command with the provided Criteria instances. + * Pass an empty array to match ALL messages. + * + * @param \Gricob\IMAP\Protocol\Command\Argument\Search\Criteria[] $criteria + * @return int[] + */ + public function searchMessages(string $mailbox, array $criteria): array + { + $this->client->select($mailbox); + + $response = $this->client->send( + new SearchCommand( + $this->client->configuration->useUid, + ...$criteria, + ) + ); + + $ids = []; + foreach ($response->getData(SearchData::class) as $searchData) { + array_push($ids, ...$searchData->numbers); + } + return $ids; + } + + /** + * Fetch the MIME body Part tree for a single message. + * + * The returned Part tree uses LazyBody instances that defer actual + * BODY[section] fetches until decodedBody() is called. Call + * Part::findPartByMimeType('text/html') and + * Part::findPartByMimeType('text/plain') on the result. + * + * Note: the mailbox must already be selected (fetchMessages() does this). + */ + public function fetchBodyParts(int $uid): Part + { + return $this->client->fetchBody($uid); + } + + /** + * Build the MIME body Part tree from an already-fetched FetchData object. + * + * Optionally accepts a pre-loaded sections map (section => raw text) to + * avoid any secondary IMAP fetches. Falls back to LazyBody for sections + * not present in the map. + * + * @param array $sections pre-fetched section bodies keyed by section path + */ + public function fetchBodyPartsFromData(FetchData $fetchData, array $sections = []): ?Part + { + if ($fetchData->bodyStructure === null || $fetchData->bodyStructure->part === null) { + return null; + } + + $uid = $fetchData->uid ?? $fetchData->id; + + return $this->buildPartsFromStructure($uid, '0', $fetchData->bodyStructure->part, $sections); + } + + /** + * Two-phase batch fetch: metadata+BODYSTRUCTURE for all UIDs in one + * command, then all text/* body sections for all UIDs in a second command. + * + * Yields uid => [FetchData $meta, ?Part $body] with no per-message or + * per-section secondary fetches. + * + * @param int[] $uids + * @return \Generator + */ + public function fetchMessagesWithBody(string $mailbox, array $uids): \Generator + { + $this->client->select($mailbox); + + // ── Phase 1: metadata + BODYSTRUCTURE for all UIDs in one round-trip ── + $metaItems = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID']; + $response = $this->client->send(new StreamFetchCommand($uids, $metaItems)); + + /** @var array $metaByUid */ + $metaByUid = []; + foreach ($response->getData(FetchData::class) as $fetchData) { + $uid = $fetchData->uid ?? $fetchData->id; + $metaByUid[$uid] = $fetchData; + } + + // ── Discover text section paths across all messages ─────────────────── + $allSections = []; // section-path => true (de-duplicated union) + foreach ($metaByUid as $fetchData) { + if ($fetchData->bodyStructure?->part !== null) { + foreach ($this->findTextSections($fetchData->bodyStructure->part) as $section) { + $allSections[$section] = true; + } + } + } + + // ── Phase 2: fetch all text sections for all UIDs in one round-trip ─── + // Some servers return NIL for an empty/missing body section instead of + // a literal, which gricob's parser cannot handle. If the batch fetch + // fails for any reason we leave $sectionsByUid empty so that individual + // parts fall back to LazyBody on first access. + /** @var array> $sectionsByUid uid => [section => text] */ + $sectionsByUid = []; + if (!empty($allSections)) { + try { + $sectionItems = array_map(fn ($s) => 'BODY[' . $s . ']', array_keys($allSections)); + array_unshift($sectionItems, 'UID'); + $bodyResponse = $this->client->send(new StreamFetchCommand($uids, $sectionItems)); + foreach ($bodyResponse->getData(FetchData::class) as $fetchData) { + $uid = $fetchData->uid ?? $fetchData->id; + $map = []; + foreach ($fetchData->bodySections as $bodySection) { + $map[$bodySection->section] = $bodySection->text; + } + $sectionsByUid[$uid] = $map; + } + } catch (\Throwable) { + // Parser could not handle a NIL body section — LazyBody will + // fetch individual sections on demand instead. + $sectionsByUid = []; + } + } + + // ── Yield merged result ─────────────────────────────────────────────── + foreach ($metaByUid as $uid => $fetchData) { + $sections = $sectionsByUid[$uid] ?? []; + yield $uid => [$fetchData, $this->fetchBodyPartsFromData($fetchData, $sections)]; + } + } + + /** + * Walk a BodyStructure tree and return the IMAP section paths of all + * text/* leaves (text/plain, text/html, text/calendar, …). + * + * Sections with size=0 are excluded: servers respond to such fetches with + * NIL instead of a literal and gricob's parser cannot handle that. + * + * @return string[] e.g. ['1', '2'] or ['1.1', '1.2'] + */ + private function findTextSections(BodyPart $part, string $section = '0'): array + { + if ($part instanceof BodySinglePart) { + $resolvedSection = $section === '0' ? '1' : $section; + return (strtolower($part->type) === 'text' && $part->size > 0) ? [$resolvedSection] : []; + } + + if ($part instanceof BodyMultiPart) { + $sections = []; + foreach ($part->parts as $index => $childPart) { + $childIndex = (string) ($index + 1); + $childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex; + array_push($sections, ...$this->findTextSections($childPart, $childSection)); + } + return $sections; + } + + return []; + } + + /** + * Recursively build a Mime Part tree from a BodyStructure part. + * + * @param array $sections pre-fetched section bodies (section path => raw text). + * Missing sections fall back to LazyBody. + */ + private function buildPartsFromStructure(int $uid, string $section, BodyPart $part, array $sections = []): Part + { + if ($part instanceof BodySinglePart) { + $resolvedSection = $section === '0' ? '1' : $section; + $body = isset($sections[$resolvedSection]) + ? new Body($sections[$resolvedSection]) + : new LazyBody($this->client, $uid, $resolvedSection); + return new SinglePart( + $part->type, + $part->subtype, + $part->attributes, + $body, + $part->attributes['charset'] ?? 'utf-8', + $part->encoding, + $part->disposition !== null + ? new Disposition( + $part->disposition->type, + $part->disposition->attributes['filename'] ?? null, + ) + : null, + ); + } + + if ($part instanceof BodyMultiPart) { + $childParts = []; + foreach ($part->parts as $index => $childPart) { + $childIndex = (string) ($index + 1); + $childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex; + $childParts[] = $this->buildPartsFromStructure($uid, $childSection, $childPart, $sections); + } + return new MultiPart($part->subtype, $part->attributes, $childParts); + } + + throw new \RuntimeException('Unexpected BodyStructure part type: ' . $part::class); + } +} diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php new file mode 100644 index 0000000..4b11481 --- /dev/null +++ b/lib/Service/Remote/RemoteMailService.php @@ -0,0 +1,375 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote; + +use DateTimeImmutable; +use Generator; +use Gricob\IMAP\Protocol\Command\Argument\Search\Before; +use Gricob\IMAP\Protocol\Command\Argument\Search\Since; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeAnchorType; +use KTXF\Resource\Range\RangeTally; +use KTXM\ProviderImapMail\Providers\CollectionResource; +use KTXM\ProviderImapMail\Providers\EntityResource; +use KTXM\ProviderImapMail\Service\Remote\Command\BodyCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\FlaggedCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\FromCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\LargerCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\SeenCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\SmallerCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\SubjectCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\ToCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\UnflaggedCriteria; +use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria; + +/** + * IMAP Remote Mail Service + * + * Provides collection (mailbox) and entity (message) operations against a live + * IMAP server via ImapClientWrapper. All methods are stateless — no caching or + * local storage happens here. + */ +class RemoteMailService +{ + /** + * Default IMAP FETCH data items used for message hydration. + * + * RFC 822 size, flags, arrival date, envelope headers, and the BODYSTRUCTURE + * MIME tree give us everything needed to build an EntityResource without + * downloading the full message body. + */ + private const DEFAULT_FETCH_ITEMS = [ + 'FLAGS', + 'ENVELOPE', + 'INTERNALDATE', + 'RFC822.SIZE', + 'BODYSTRUCTURE', + 'UID', + 'BODY[TEXT]' + ]; + + public function __construct( + private readonly ImapClientWrapper $client, + private readonly string $provider, + private readonly string|int $service, + ) {} + + // ── Collection (mailbox) operations ────────────────────────────────────── + + /** + * List all selectable mailboxes on the server. + * + * @return array keyed by mailbox name + */ + public function collectionList(): array + { + $result = []; + + foreach ($this->client->mailboxes() as $mailbox) { + if (!$mailbox->isSelectable()) { + continue; + } + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromImap($mailbox); + $result[$resource->identifier()] = $resource; + } + + return $result; + } + + /** + * Fetch a single mailbox by its full name. + * + * Returns null when no mailbox matching $name is found. + */ + public function collectionFetch(string $name): ?CollectionResource + { + foreach ($this->client->mailboxes() as $mailbox) { + if ($mailbox->name === $name) { + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromImap($mailbox); + return $resource; + } + } + return null; + } + + /** + * Create a new IMAP mailbox and return it. + * + * If the server-side LIST cannot confirm the new mailbox (e.g., immediate + * consistency), a lightweight stub resource is returned instead. + */ + public function collectionCreate(string $name): CollectionResource + { + $this->client->createMailbox($name); + + // Attempt to refetch the new mailbox from the server + $resource = $this->collectionFetch($name); + if ($resource !== null) { + return $resource; + } + + // Fallback: return a minimal resource with just the name set + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromStore(['identifier' => $name, 'collection' => null]); + return $resource; + } + + /** + * Rename a mailbox and return the updated resource. + */ + public function collectionRename(string $oldName, string $newName): CollectionResource + { + $this->client->renameMailbox($oldName, $newName); + + $resource = $this->collectionFetch($newName); + if ($resource !== null) { + return $resource; + } + + $resource = new CollectionResource($this->provider, $this->service); + $resource->fromStore(['identifier' => $newName, 'collection' => null]); + return $resource; + } + + /** + * Delete a mailbox by its full name. + */ + public function collectionDestroy(string $name): bool + { + $this->client->deleteMailbox($name); + return true; + } + + // ── Entity (message) operations ─────────────────────────────────────────── + + /** + * Return UIDs present in a mailbox, optionally filtered and paginated. + * + * UIDs are always returned descending (highest = newest first). + * When a RangeTally $range is supplied: + * - ABSOLUTE anchor: slice from position offset for tally items + * - RELATIVE anchor: find the UID whose value equals position, then + * return the next tally items (cursor-based paging) + * + * @return int[] + */ + public function entityList(string $collection, ?IFilter $filter = null, ?IRange $range = null): array + { + // ── Build IMAP SEARCH criteria from filter ──────────────────────────── + $criteria = []; + if ($filter !== null) { + foreach ($filter->conditions() as $condition) { + $attribute = $condition['attribute']; + $value = $condition['value']; + $criterion = match ($attribute) { + 'seen' => $value ? new SeenCriteria() : new UnseenCriteria(), + 'flagged' => $value ? new FlaggedCriteria() : new UnflaggedCriteria(), + 'from' => new FromCriteria($value), + 'to' => new ToCriteria($value), + 'subject' => new SubjectCriteria($value), + 'body' => new BodyCriteria($value), + 'before' => new Before(new DateTimeImmutable($value)), + 'after' => new Since(new DateTimeImmutable($value)), + 'min' => new LargerCriteria($value), + 'max' => new SmallerCriteria($value), + default => null, + }; + if ($criterion !== null) { + $criteria[] = $criterion; + } + } + } + + // ── Execute IMAP SEARCH (ALL when no criteria) ──────────────────────── + $uids = empty($criteria) + ? $this->client->searchAll($collection) + : $this->client->searchMessages($collection, $criteria); + + if (empty($uids)) { + return []; + } + + // ── Sort descending: highest UID (newest) first ─────────────────────── + rsort($uids); + + // ── Apply RangeTally pagination ─────────────────────────────────────── + if ($range instanceof RangeTally) { + $position = (int) $range->getPosition(); + $tally = $range->getTally(); + if ($range->getAnchor() === RangeAnchorType::RELATIVE) { + // Cursor-based: find the anchor UID then take the next slice + $index = array_search($position, $uids, true); + $start = $index !== false ? $index + 1 : 0; + } else { + // Absolute offset + $start = $position; + } + $uids = array_slice($uids, $start, $tally); + } + + return $uids; + } + + /** + * Fetch one or more messages by UID and return EntityResource objects. + * + * Uses client->fetchMultiple() which streams FetchData responses one at a + * time via sendStreaming — memory-efficient even for large UID sets. Body + * content is NOT pre-loaded; call fetchBody() on the returned resource + * when the decoded body is needed (lazy, one extra round-trip per message). + * + * @param int ...$uids + * @return EntityResource[] keyed by UID + */ + public function entityFetch(string $collection, int ...$uids): array + { + if (empty($uids)) { + return []; + } + + $result = []; + foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { + $resource = new EntityResource($this->provider, $this->service); + $resource->fromImap($fetchData, $collection, null); + $result[$uid] = $resource; + } + return $result; + } + + public function entityFetchStream(string $collection, int ...$uids): Generator + { + if (empty($uids)) { + return; + } + + foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { + $resource = new EntityResource($this->provider, $this->service); + $resource->fromImap($fetchData, $collection, null); + yield $uid => $resource; + } + } + + /** + * Stream messages one at a time as EntityResource objects. + * + * Yields uid (int) => EntityResource. Use this for large mailbox syncs to + * avoid holding thousands of objects in memory simultaneously. + * + * Pass a custom $items array to restrict the fetched data (e.g. ['FLAGS', 'UID'] + * for a flags-only sync). Defaults to DEFAULT_FETCH_ITEMS. + * + * @param int[] $uids + * @param string[] $items IMAP fetch data items + * @return \Generator + */ + public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): \Generator + { + foreach ($this->client->fetchMultiple($collection, $uids, $items) as $uid => $fetchData) { + $resource = new EntityResource($this->provider, $this->service); + $resource->fromImap($fetchData, $collection, null); + yield $uid => $resource; + } + } + + /** + * Append a raw RFC 822 message to a mailbox and return the assigned UID. + * + * @param string[] $flags optional initial flags, e.g. ['\\Seen'] + */ + public function entityCreate(string $collection, string $rawMessage, array $flags = []): int + { + return $this->client->append($rawMessage, $collection, $flags); + } + + /** + * Modify message flags for one or more messages. + * + * @param string $action '+' to add, '-' to remove, '' to replace + * @param string[] $flags e.g. ['\\Seen', '\\Flagged'] + * @param int ...$uids + */ + public function entityModify(string $collection, string $action, array $flags, int ...$uids): void + { + if (empty($uids)) { + return; + } + + $this->client->storeFlags($collection, array_values($uids), $action, $flags); + } + + /** + * Permanently delete one or more messages by UID. + */ + public function entityDestroy(string $collection, int ...$uids): void + { + if (empty($uids)) { + return; + } + + $this->client->deleteMessages($collection, array_values($uids)); + } + + /** + * Copy one or more messages to a destination mailbox. + */ + public function entityCopy(string $collection, string $destination, int ...$uids): void + { + if (empty($uids)) { + return; + } + + $this->client->copyMessages($collection, array_values($uids), $destination); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Compact a flat array of UIDs into an IMAP sequence-set string. + * + * Consecutive UIDs are collapsed into n:m ranges; non-consecutive UIDs are + * comma-separated. The input does not need to be sorted. + * + * Examples: + * [1, 2, 3, 5, 6, 10] → "1:3,5:6,10" + * [42] → "42" + * [7, 3, 4, 5] → "3:5,7" + * + * @param int[] $uids + */ + private function uidsToRangeSet(array $uids): string + { + if (empty($uids)) { + return ''; + } + + $uids = array_unique($uids); + sort($uids); + + $ranges = []; + $start = $end = $uids[0]; + + for ($i = 1, $count = count($uids); $i <= $count; $i++) { + $current = $uids[$i] ?? null; + if ($current !== null && $current === $end + 1) { + $end = $current; + } else { + $ranges[] = $start === $end ? (string) $start : $start . ':' . $end; + if ($current !== null) { + $start = $end = $current; + } + } + } + + return implode(',', $ranges); + } +} diff --git a/lib/Service/Remote/RemoteService.php b/lib/Service/Remote/RemoteService.php new file mode 100644 index 0000000..bcf105a --- /dev/null +++ b/lib/Service/Remote/RemoteService.php @@ -0,0 +1,63 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Service\Remote; + +use Gricob\IMAP\Client; +use KTXC\Logger\PlainFileLogger; +use KTXM\ProviderImapMail\Providers\Service; + +/** + * Static factory for IMAP remote service objects. + * + * - freshClient() → builds a imap client from service config + * - mailService() → constructs a RemoteMailService from the wrapper + */ +class RemoteService +{ + /** + * Build a fully-configured imap client from a Service's location and identity. + * + * Handles STARTTLS: connects on plain TCP, sends STARTTLS, upgrades to TLS, + * then authenticates — all before returning the wrapper. + */ + public static function freshClient(Service $service, string $logDir): ImapClientWrapper + { + $location = $service->getLocation(); + $identity = $service->getIdentity(); + + // Build a file logger when debug mode is enabled, otherwise pass null + $logger = null; + if ($service->getDebug()) { + $logger = new PlainFileLogger($logDir . '/imap', $service->identifier()); + } + + $client = Client::create($location->toConfiguration(), $logger); + $client->connect(); + + if ($location->getEncryption() === 'starttls') { + $client->startTls(); + } + + $client->logIn($identity->getIdentity(), $identity->getSecret()); + + return new ImapClientWrapper($client); + } + + /** + * Build a RemoteMailService from a Service and a pre-authenticated wrapper. + * + * The provider identifier and service ID are taken directly from the Service + * object so the caller does not have to repeat them. + */ + public static function mailService(Service $service, ImapClientWrapper $wrapper): RemoteMailService + { + return new RemoteMailService($wrapper, $service->provider(), $service->identifier()); + } +} diff --git a/lib/Stores/MessageStore.php b/lib/Stores/MessageStore.php new file mode 100644 index 0000000..de0489d --- /dev/null +++ b/lib/Stores/MessageStore.php @@ -0,0 +1,224 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Stores; + +use KTXC\Db\DataStore; + +/** + * MongoDB cache for IMAP messages and mailboxes. + * + * Collections: + * provider_imap_mail_messages — one document per (sid, mailbox, uid) + * provider_imap_mail_mailboxes — one document per (sid, name) + * + * Documents are stored **pre-formatted** in the same internal shape used by + * EntityResource / MessageProperties and CollectionResource / CollectionProperties + * so the read path does zero IMAP parsing. + */ +class MessageStore +{ + protected const MESSAGES_COLLECTION = 'provider_imap_mail_messages'; + protected const MAILBOXES_COLLECTION = 'provider_imap_mail_mailboxes'; + + public function __construct( + protected readonly DataStore $store, + ) { + $this->ensureIndexes(); + } + + // ── Index creation ─────────────────────────────────────────────────────── + + private function ensureIndexes(): void + { + $messages = $this->store->selectCollection(self::MESSAGES_COLLECTION); + $mailboxes = $this->store->selectCollection(self::MAILBOXES_COLLECTION); + + $messages->createIndex(['sid' => 1, 'mailbox' => 1, 'uid' => 1], ['unique' => true]); + $mailboxes->createIndex(['sid' => 1, 'name' => 1], ['unique' => true]); + } + + // ── Messages ───────────────────────────────────────────────────────────── + + /** + * Insert or replace a cached message document. + * + * @param array $data Result of EntityResource::toStore() — must include + * sid, mailbox, uid at the top level. + */ + public function upsertMessage(array $data): void + { + $filter = [ + 'sid' => $data['sid'], + 'mailbox' => $data['mailbox'], + 'uid' => (int)$data['uid'], + ]; + $data['syncedAt'] = (new \DateTime())->format(\DateTimeInterface::ATOM); + + $this->store->selectCollection(self::MESSAGES_COLLECTION)->updateOne( + $filter, + ['$set' => $data], + ['upsert' => true], + ); + } + + /** + * Retrieve a single cached message by service ID, mailbox and UID. + */ + public function fetchMessage(string $serviceId, string $mailbox, int $uid): ?array + { + $doc = $this->store->selectCollection(self::MESSAGES_COLLECTION)->findOne([ + 'sid' => $serviceId, + 'mailbox' => $mailbox, + 'uid' => $uid, + ]); + + return $doc ? (array)$doc : null; + } + + /** + * Return all cached UIDs for a mailbox/service combination. + * + * @return int[] + */ + public function listUids(string $serviceId, string $mailbox): array + { + $cursor = $this->store->selectCollection(self::MESSAGES_COLLECTION)->find( + ['sid' => $serviceId, 'mailbox' => $mailbox], + ['projection' => ['uid' => 1]], + ); + + $uids = []; + foreach ($cursor as $doc) { + $uids[] = (int)$doc['uid']; + } + + return $uids; + } + + /** + * Fetch multiple cached messages by UID. + * + * @param int[] $uids + * @return array[] + */ + public function fetchMessages(string $serviceId, string $mailbox, array $uids): array + { + $cursor = $this->store->selectCollection(self::MESSAGES_COLLECTION)->find([ + 'sid' => $serviceId, + 'mailbox' => $mailbox, + 'uid' => ['$in' => $uids], + ]); + + $result = []; + foreach ($cursor as $doc) { + $doc = (array)$doc; + $result[$doc['uid']] = $doc; + } + + return $result; + } + + /** + * Delete a cached message entry by UID. + */ + public function deleteMessage(string $serviceId, string $mailbox, int $uid): void + { + $this->store->selectCollection(self::MESSAGES_COLLECTION)->deleteOne([ + 'sid' => $serviceId, + 'mailbox' => $mailbox, + 'uid' => $uid, + ]); + } + + /** + * Delete multiple cached message entries. + * + * @param int[] $uids + */ + public function deleteMessages(string $serviceId, string $mailbox, array $uids): void + { + if (empty($uids)) { + return; + } + + $this->store->selectCollection(self::MESSAGES_COLLECTION)->deleteMany([ + 'sid' => $serviceId, + 'mailbox' => $mailbox, + 'uid' => ['$in' => $uids], + ]); + } + + // ── Mailboxes ──────────────────────────────────────────────────────────── + + /** + * Insert or replace a cached mailbox document. + * + * @param array $data Result of CollectionResource::toStore() — must include + * sid and name at the top level. + */ + public function upsertMailbox(array $data): void + { + $filter = [ + 'sid' => $data['sid'], + 'name' => $data['name'], + ]; + $data['syncedAt'] = (new \DateTime())->format(\DateTimeInterface::ATOM); + + $this->store->selectCollection(self::MAILBOXES_COLLECTION)->updateOne( + $filter, + ['$set' => $data], + ['upsert' => true], + ); + } + + /** + * Retrieve a cached mailbox by service ID and name. + */ + public function fetchMailbox(string $serviceId, string $name): ?array + { + $doc = $this->store->selectCollection(self::MAILBOXES_COLLECTION)->findOne([ + 'sid' => $serviceId, + 'name' => $name, + ]); + + return $doc ? (array)$doc : null; + } + + /** + * List all cached mailboxes for a service. + * + * @return array[] + */ + public function listMailboxes(string $serviceId): array + { + $cursor = $this->store->selectCollection(self::MAILBOXES_COLLECTION)->find( + ['sid' => $serviceId], + ); + + $result = []; + foreach ($cursor as $doc) { + $doc = (array)$doc; + $result[$doc['name']] = $doc; + } + + return $result; + } + + /** + * Delete a cached mailbox entry by service ID and name. + */ + public function deleteMailbox(string $serviceId, string $name): void + { + $this->store->selectCollection(self::MAILBOXES_COLLECTION)->deleteOne([ + 'sid' => $serviceId, + 'name' => $name, + ]); + } +} diff --git a/lib/Stores/ServiceStore.php b/lib/Stores/ServiceStore.php new file mode 100644 index 0000000..170e001 --- /dev/null +++ b/lib/Stores/ServiceStore.php @@ -0,0 +1,176 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImapMail\Stores; + +use KTXC\Db\DataStore; +use KTXF\Security\Crypto; +use KTXF\Utile\UUID; +use KTXM\ProviderImapMail\Providers\Service; + +/** + * IMAP Service Store + * + * Persists IMAP service configurations (one MongoDB document per account / + * user pairing) in the collection `provider_imap_mail_services`. + */ +class ServiceStore +{ + protected const COLLECTION_NAME = 'provider_imap_mail_services'; + + public function __construct( + protected readonly DataStore $dataStore, + protected readonly Crypto $crypto, + ) {} + + // ── List ───────────────────────────────────────────────────────────────── + + /** + * List services for a tenant+user, optionally filtered to specific IDs. + * + * @param string[]|null $filter Service IDs to restrict results to + * @return array Keyed by service ID + */ + public function list(string $tenantId, string $userId, ?array $filter = null): array + { + $condition = ['tid' => $tenantId, 'uid' => $userId]; + + if ($filter !== null && !empty($filter)) { + $condition['sid'] = ['$in' => $filter]; + } + + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($condition); + + $list = []; + foreach ($cursor as $entry) { + if (isset($entry['identity']['secret'])) { + $entry['identity']['secret'] = $this->crypto->decrypt($entry['identity']['secret']); + } + $list[$entry['sid']] = $entry; + } + + return $list; + } + + // ── Extant ─────────────────────────────────────────────────────────────── + + /** + * Check which of the supplied service IDs exist for the given tenant/user. + * + * @param string[]|int[] $identifiers + * @return array + */ + public function extant(string $tenantId, string $userId, array $identifiers): array + { + if (empty($identifiers)) { + return []; + } + + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find( + [ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => ['$in' => array_map('strval', $identifiers)], + ], + ['projection' => ['sid' => 1]], + ); + + $existing = []; + foreach ($cursor as $doc) { + $existing[] = $doc['sid']; + } + + $result = []; + foreach ($identifiers as $id) { + $result[(string)$id] = in_array((string)$id, $existing, true); + } + + return $result; + } + + // ── Fetch ──────────────────────────────────────────────────────────────── + + public function fetch(string $tenantId, string $userId, string|int $serviceId): ?Service + { + $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ]); + + if (!$document) { + return null; + } + + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->decrypt($document['identity']['secret']); + } + + return (new Service())->fromStore($document); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + public function create(string $tenantId, string $userId, Service $service): Service + { + $document = $service->toStore(); + $document['tid'] = $tenantId; + $document['uid'] = $userId; + $document['sid'] = UUID::v4(); + $document['createdOn'] = new \MongoDB\BSON\UTCDateTime(); + $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); + + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); + } + + $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); + + return (new Service())->fromStore($document); + } + + // ── Modify ─────────────────────────────────────────────────────────────── + + public function modify(string $tenantId, string $userId, Service $service): Service + { + $serviceId = $service->identifier(); + if (empty($serviceId)) { + throw new \InvalidArgumentException('Service ID is required for update'); + } + + $document = $service->toStore(); + $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); + + if (isset($document['identity']['secret'])) { + $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); + } + + unset($document['sid'], $document['tid'], $document['uid'], $document['createdOn']); + + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'sid' => (string)$serviceId], + ['$set' => $document], + ); + + return (new Service())->fromStore($document); + } + + // ── Delete ─────────────────────────────────────────────────────────────── + + public function delete(string $tenantId, string $userId, string|int $serviceId): bool + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ]); + + return $result->getDeletedCount() > 0; + } +}