diff --git a/.env b/.env index 0a412c9..b7692f4 100644 --- a/.env +++ b/.env @@ -20,4 +20,16 @@ JWT_COOKIE_TTL=86400 DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" -ENCRYPTION_KEY=change_me_in_env_local \ No newline at end of file +ENCRYPTION_KEY=change_me_in_env_local +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/CLAUDE.md b/CLAUDE.md index 5bcb557..bd3783a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. +> **WIP — Intégration Mail (branche `feat/mail-integration`)** : client mail OVH IMAP. Avant de toucher au mail, lire `docs/mail-integration.md` (section « Statut & reprise » = bugs déjà corrigés, points en suspens, commandes). Code : `src/Mail/`, `src/Service/MailSyncService.php`, `src/Controller/Mail/`, `frontend/{services,stores,components}/mail*`. + ## Stack - **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 diff --git a/README.md b/README.md index 17a7a59..6b863e0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Application de gestion de projet avec suivi du temps et portail client. - Profil utilisateur avec avatar (crop circulaire) - Notifications temps réel - Intégration Gitea (issues, repos) +- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`) - Serveur MCP pour assistants IA - Multi-langue (i18n) @@ -73,6 +74,7 @@ make shell-root # Shell root dans le container PHP make dev-nuxt # Dev server Nuxt (hot reload, port 3002) make cache-clear # Vider le cache Symfony make logs-dev # Tail logs Symfony +make mail-sync # Synchroniser la boîte mail IMAP (voir docs/mail-cron-setup.md) ``` ### Base de données diff --git a/composer.json b/composer.json index e57c860..aae37ad 100644 --- a/composer.json +++ b/composer.json @@ -21,12 +21,15 @@ "sabre/vobject": "^4.5", "symfony/asset": "8.0.*", "symfony/console": "8.0.*", + "symfony/doctrine-messenger": "^8.0", "symfony/dotenv": "8.0.*", "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", "symfony/http-client": "8.0.*", + "symfony/lock": "8.0.*", "symfony/mcp-bundle": "^0.6.0", + "symfony/messenger": "^8.0", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", @@ -36,7 +39,8 @@ "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", "symfony/validator": "8.0.*", - "symfony/yaml": "8.0.*" + "symfony/yaml": "8.0.*", + "webklex/php-imap": "^6.2" }, "config": { "allow-plugins": { @@ -93,6 +97,8 @@ "require-dev": { "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.94", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.0", + "symfony/browser-kit": "^8.0", + "symfony/css-selector": "^8.0" } } diff --git a/composer.lock b/composer.lock index 59647f2..b48e773 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0bdbfd9abe99ffd23a53df611d8a879c", + "content-hash": "dc72ee68996f3f738763eafd350bc0e0", "packages": [ { "name": "api-platform/doctrine-common", @@ -1156,6 +1156,75 @@ }, "time": "2026-01-26T15:45:40+00:00" }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, { "name": "composer/pcre", "version": "3.3.2", @@ -2439,6 +2508,386 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "illuminate/collections", + "version": "v13.8.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/17b082d0c66fb030f22d5bdd62ba652c045ff522", + "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-28T17:17:15+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "b88c1134feb4253a71048e7e2b5c431e9b3ab95b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/b88c1134feb4253a71048e7e2b5c431e9b3ab95b", + "reference": "b88c1134feb4253a71048e7e2b5c431e9b3ab95b", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-13T13:44:10+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/pagination", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pagination.git", + "reference": "0e788e59a857f4c6fd6f084fc4848714c8cd5bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/0e788e59a857f4c6fd6f084fc4848714c8cd5bbb", + "reference": "0e788e59a857f4c6fd6f084fc4848714c8cd5bbb", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pagination\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pagination package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-13T14:00:22+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-03-10T20:04:12+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.8.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "ff687db22aefef516efd3ea21d01664af332da38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/ff687db22aefef516efd3ea21d01664af332da38", + "reference": "ff687db22aefef516efd3ea21d01664af332da38", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.33", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-04T12:34:54+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -3057,6 +3506,111 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, { "name": "nyholm/psr7", "version": "1.8.2", @@ -5287,6 +5841,82 @@ ], "time": "2026-03-06T13:17:40+00:00" }, + { + "name": "symfony/doctrine-messenger", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/88329a3faba5023cfb569b3fc5b8a771336c4a88", + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^4.3", + "php": ">=8.4", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Doctrine Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-messenger/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-20T07:51:53+00:00" + }, { "name": "symfony/dotenv", "version": "v8.0.7", @@ -6379,6 +7009,88 @@ ], "time": "2026-03-06T16:58:46+00:00" }, + { + "name": "symfony/lock", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "02e7142df3d647411fd88655d20d8ec79dafb78c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/02e7142df3d647411fd88655d20d8ec79dafb78c", + "reference": "02e7142df3d647411fd88655d20d8ec79dafb78c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, { "name": "symfony/mcp-bundle", "version": "v0.6.0", @@ -6463,6 +7175,96 @@ ], "time": "2026-03-04T16:39:24+00:00" }, + { + "name": "symfony/messenger", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "c451c175724fc781c777783aaec3b7999ceb0621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/c451c175724fc781c777783aaec3b7999ceb0621", + "reference": "c451c175724fc781c777783aaec3b7999ceb0621", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/clock": "^7.4|^8.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/lock": "<7.4", + "symfony/serializer": "<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, { "name": "symfony/mime", "version": "v8.0.7", @@ -7268,6 +8070,86 @@ ], "time": "2025-06-23T16:12:55+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.33.0", @@ -8522,6 +9404,99 @@ ], "time": "2026-02-09T10:14:57+00:00" }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.6.1", @@ -9185,6 +10160,161 @@ ], "time": "2026-02-09T10:14:57+00:00" }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" + }, { "name": "webmozart/assert", "version": "2.1.6", @@ -12164,6 +13294,217 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "3665cfade90565430909b906394c73c8739e57d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/process", "version": "v8.0.5", diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 0000000..3c1eeab --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,28 @@ +framework: + messenger: + failure_transport: failed + + transports: + sync: 'sync://' + + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: default + retry_strategy: + max_retries: 3 + delay: 1000 + multiplier: 2 + max_delay: 0 + + failed: 'doctrine://default?queue_name=failed&auto_setup=0' + + routing: + 'App\Message\MailSyncRequested': async + +when@test: + framework: + messenger: + transports: + async: 'in-memory://' + failed: 'in-memory://' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a6fed5a..820b46a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -64,6 +64,8 @@ security: - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } + # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } when@test: diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..490bfc2 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,5 @@ +framework: + default_locale: en + translator: + default_path: '%kernel.project_dir%/translations' + providers: diff --git a/config/reference.php b/config/reference.php index bd1717f..145877b 100644 --- a/config/reference.php +++ b/config/reference.php @@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * fallbacks?: list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" @@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: true * }, * lock?: bool|string|array{ // Lock configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * resources?: array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration @@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * routing?: array, * }>, @@ -1360,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false * }, * messenger?: bool|array{ - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * elasticsearch?: bool|array{ * enabled?: bool|Param, // Default: false diff --git a/docs/mail-cron-setup.md b/docs/mail-cron-setup.md new file mode 100644 index 0000000..78a5b2e --- /dev/null +++ b/docs/mail-cron-setup.md @@ -0,0 +1,111 @@ +# Mail Integration — Configuration cron OS + +## Vue d'ensemble + +La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. +Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. + +Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche +les runs de se chevaucher si une sync prend plus de 10 min. + +## Prérequis + +- Container `php-lesstime-fpm` démarré (`make start`) +- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) +- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) + +## Installation du cron + +Sur la **machine hôte** (pas dans le container) : + +```bash +crontab -e +``` + +Ajouter la ligne suivante (adapter le chemin) : + +```cron +*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 +``` + +Ou directement via `docker exec` (sans dépendance à `make`) : + +```cron +*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1 +``` + +### Avec un utilisateur système dédié + +Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) : + +```bash +sudo crontab -u deploy -e +``` + +## Variables d'environnement nécessaires + +| Variable | Description | Exemple | +|---|---|---| +| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | +| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) | + +La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. + +## Checklist setup production + +1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production +2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH +3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP +4. [ ] Cliquer "Tester la connexion" → vérifier le succès +5. [ ] Cocher "Activer la synchronisation" → Enregistrer +6. [ ] Installer le cron OS (voir section "Installation du cron") +7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`) + +## Commandes utiles + +```bash +# Sync complète (toutes les boîtes) +make mail-sync + +# Sync d'un seul dossier (le dossier doit déjà exister en base) +make mail-sync FOLDER=INBOX + +# Simulation (dry-run, pas d'écriture BDD) +make mail-sync DRYRUN=1 + +# Directement dans le container +docker exec php-lesstime-fpm php bin/console app:mail:sync +docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX +docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run +``` + +## Logs + +Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production). +Suivre les logs en temps réel : + +```bash +make logs-dev +``` + +Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. + +## Sécurité + +- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox) +- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** +- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync..lock`) + +## Rappels sécurité + +- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs +- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER +- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base) +- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`) +- Les pixels tracking distants sont remplacés par un placeholder +- Aucun body mail, password ou contenu de pièce jointe n'est loggé + +## Production + +En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.). +La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base). diff --git a/docs/mail-integration.md b/docs/mail-integration.md new file mode 100644 index 0000000..92edece --- /dev/null +++ b/docs/mail-integration.md @@ -0,0 +1,147 @@ +# Intégration Mail — Vue d'ensemble + +> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20) +> +> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/5 (base `develop`) +> Construit en 7 phases (plans dans `docs/superpowers/plans/2026-05-19-mail-phase*.md`). +> +> ### Ce qui marche (testé contre une vraie boîte OVH `contact@malio.fr`) +> - Connexion IMAP + test connexion (admin → `/admin` onglet Mail) +> - Synchro complète multi-dossiers : **456 messages / 57 dossiers** ramenés, ne crashe plus +> - Lecture dossiers/messages dans `/mail`, arbre repliable (chevrons, sous-dossiers masqués par défaut) +> - Lecture d'un mail, sanitization DOMPurify +> - Création/lien tâche depuis un mail +> +> ### Bugs déjà corrigés ce soir (NE PAS ré-investiguer) +> Tous dans `ImapMailProvider` / `MailSyncService` — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live : +> 1. Requête sans critère → `BAD parse error: zero-length content` → `whereAll()` +> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites +> 3. Séquence par défaut `ST_MSGN` → `peek()` faisait un STORE rejeté par OVH (`flag could not be removed`) → forcé `ST_UID` partout +> 4. Snippet via `getTextBody()` = fetch du corps de chaque mail (sync 179s + peek) → `setFetchBody(false)`, snippet désactivé au listing +> 5. Test connexion exigeait `enabled=true` → découplé via `getClient(requireEnabled:false)` + `testConnection()` +> 6. Contrainte UNIQUE globale sur `message_id` → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. **Migration `Version20260520061736`** : index simple. Garde anti-cascade dans `MailSyncService` (reset `ManagerRegistry`). +> 7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (`closeConnection()` sur l'interface) + reconnexion ciblée après dossier en erreur. +> - Contrat front/back réaligné dans `frontend/services/mail.ts` (route `/mail/folders/{path}/messages`, mapping `messages→items`, `fromAddress→fromEmail`, détail plat→imbriqué). +> +> ### Points en suspens / à savoir +> - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main. +> - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker. +> - **~7 dossiers/139** à encodage spécial (ex: `INBOX/RH/.../SÉBASTIEN` en UTF7-modifié) ou réponses vides sont skippés proprement et réessayés au cycle suivant. Edge case webklex non bloquant. +> - **Dépendance** : `webklex/php-imap ^6.2` tire des paquets Laravel (`illuminate/*` via `carbon ^3`) dans ce projet Symfony — fonctionnel mais à valider en review. +> - 6 PHPUnit Notices (mocks sans expectations) non bloquantes. +> +> ### Commandes utiles +> ```bash +> make mail-sync # synchro complète +> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v +> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton) +> make test # 33 tests +> ``` +> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin. + +## Fonctionnalités + +- Lecture de la boîte mail partagée (IMAP) depuis Lesstime +- Navigation par dossiers (arbre récursif avec compteurs non-lus) +- Liste paginée des messages (infinite scroll, cursor-based) +- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking) +- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description) +- Lien mail ↔ tâche (bidirectionnel) +- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche +- Synchronisation IMAP automatique via cron OS (toutes les 10 min) +- Déclenchement manuel de sync depuis l'UI (bouton Refresh) +- Badge non-lus en temps réel dans la sidebar (polling 30s) + +## Endpoints API + +| Méthode | URL | Rôle | Description | +|---------|-----|------|-------------| +| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton | +| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config | +| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP | +| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread | +| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) | +| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) | +| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu | +| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé | +| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail | +| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante | +| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien | +| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche | +| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe | +| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) | + +Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`. + +## Sécurité + +- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail` +- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT +- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`) +- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués +- Pixels tracking distants (img src http) remplacés par placeholder +- Aucun body, password ou contenu de pièce jointe dans les logs + +## Dépendances + +### Backend +- `webklex/php-imap` : client IMAP PHP +- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles +- `symfony/messenger` : dispatch asynchrone `MailSyncRequested` +- `libsodium` (ext PHP) : chiffrement du password IMAP + +### Frontend +- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail + +## Fichiers clés + +### Backend +- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled) +- `src/Entity/MailFolder.php` — dossier IMAP synced +- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags) +- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail +- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex) +- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags) +- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync) +- `src/State/Mail/` — providers/processors API Platform (configuration) + +### Frontend +- `frontend/pages/mail.vue` — page principale 3 colonnes +- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton +- `frontend/components/admin/AdminMailTab.vue` — onglet config admin +- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling) +- `frontend/services/mail.ts` — service API (toutes les méthodes) +- `frontend/services/dto/mail.ts` — types TypeScript +- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper + +## Synchronisation cron + +Voir `docs/mail-cron-setup.md` pour la configuration détaillée. + +Résumé : + +```bash +# Cron OS (toutes les 10 min) +*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 + +# Commandes Makefile +make mail-sync # Sync complète +make mail-sync FOLDER=INBOX # Sync d'un dossier +make mail-sync DRYRUN=1 # Simulation sans écriture +``` + +## Configuration admin + +1. Aller sur `/admin` → onglet "Mail" +2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL) +3. Cliquer "Tester la connexion" +4. Activer la synchronisation → Enregistrer +5. Configurer le cron OS + +## Variables d'environnement + +| Variable | Description | Obligatoire | +|----------|-------------|-------------| +| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui | +| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non | +| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) | diff --git a/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md b/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md new file mode 100644 index 0000000..089a708 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md @@ -0,0 +1,264 @@ +# Mail Integration — Master Plan + +> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle. + +**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md` + +**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail. + +**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante. + +--- + +## Cartographie des phases + +``` +Phase 1 (Backend foundations) ──┐ + ├─→ Phase 2 (IMAP provider + sync) ──┐ + │ ├─→ Phase 3 (API backend) ──┐ + │ │ │ + └─→─────────────────────────────────────────────────────────────────┤ + │ +Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘ + │ + ├─→ Phase 5 (UI principale 3 colonnes) + │ + ├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer) + │ + └─→ Phase 7 (Admin config + sidebar + polish) +``` + +Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes. + +--- + +## Phase 1 — Backend Foundations + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md` + +**Scope** : +- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`) +- Entité `MailFolder` +- Entité `MailMessage` +- Entité `TaskMailLink` (avec unique constraint) +- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository` +- Migration Doctrine unique créant les 4 tables (raw SQL) +- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto` +- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl) +- Exception `App\Mail\Exception\MailProviderException` +- Tests unitaires repositories (au moins le pattern singleton) + +**Critère d'acceptation** : +- `make migration-migrate` passe sans erreur +- `php bin/console doctrine:schema:validate` OK +- `make test` vert (au moins les tests créés) +- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée + +**Dépendances** : aucune (point d'entrée). + +--- + +## Phase 2 — IMAP Provider + Sync + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md` + +**Scope** : +- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4) +- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface` + - Lecture config via `MailConfigurationRepository::findSingleton()` + - Déchiffrement password via `TokenEncryptor` + - `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment` + - Wrapping erreurs en `MailProviderException` +- `App\Service\MailSyncService` + - `syncAll(): MailSyncReport` + - `syncFolder(string $folderPath): MailSyncReport` + - `syncFolderStructure(): void` + - Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%) +- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors) +- Symfony Lock (`mail.sync`, TTL 10 min) +- Commande console `app:mail:sync` (avec option `--folder=...`) +- Documentation cron OS + cible Makefile `make mail-sync` +- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké + +**Critère d'acceptation** : +- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config +- Tests `make test` verts +- `make mail-sync` documentée dans Makefile + +**Dépendances** : Phase 1. + +--- + +## Phase 3 — API Backend + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md` + +**Scope** : +- API Platform ressources : + - `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider + - `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer) +- Custom controllers (priority: 1) : + - `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion + - `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD + - `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC` + - `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min + - `POST /api/mail/messages/{id}/read` (body `{ read: bool }`) + - `POST /api/mail/messages/{id}/flag` + - `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`) + - `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`) + - `DELETE /api/mail/messages/{id}/link-task/{taskId}` + - `GET /api/tasks/{id}/mails` + - `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline + - `POST /api/mail/sync` — async via Messenger +- Message + Handler Symfony Messenger `MailSyncRequested` +- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite +- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé) + +**Critère d'acceptation** : +- Tous endpoints répondent corrects status/format +- Tests `make test` verts +- ROLE_CLIENT refusé sur 100% des endpoints mail +- Password jamais leak dans les réponses + +**Dépendances** : Phase 1, Phase 2. + +--- + +## Phase 4 — Frontend Services + Store + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md` + +**Scope** : +- Install npm `dompurify` + types +- `frontend/services/dto/mail.ts` : tous les types TS +- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`) + - `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged` + - `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask` + - `triggerSync` + - `getConfiguration`, `updateConfiguration`, `testConfiguration` + - `downloadAttachment` (retourne Blob) +- Store Pinia `frontend/stores/useMailStore.ts` + - State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount` + - Actions correspondantes + - Polling `pollUnreadCount()` toutes les 30s (start/stop) +- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `` distants) + +**Critère d'acceptation** : +- `cd frontend && npx tsc --noEmit` OK +- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon + +**Dépendances** : Phase 3 (les endpoints doivent exister). + +--- + +## Phase 5 — UI principale (page /mail) + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md` + +**Scope** : +- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive +- Composants `frontend/components/mail/` : + - `MailFolderTree.vue` — arbre récursif avec badges unread, sélection + - `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates + - `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler) + - `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing` +- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs +- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits +- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture) +- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt) + +**Critère d'acceptation** : +- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN +- ROLE_CLIENT redirigé vers `/portal` +- Pas d'XSS via body mail (test manuel avec un mail contenant ``) +- Pixels tracking distants remplacés par placeholder + +**Dépendances** : Phase 4. + +--- + +## Phase 6 — Intégration Tâches + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md` + +**Scope** : +- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli : + - Titre = subject + - Description = body plain text + - Picker projet + groupe + priorité + - À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès +- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé) +- Onglet **"Mails"** sur `TaskDrawer.vue` : + - Nouvelle section affichée à côté Documents / Time tracking / etc. + - Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`) + - Item cliquable → `router.push('/mail?messageId=' + id)` + - Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail) +- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche + +**Critère d'acceptation** : +- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer +- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet + +**Dépendances** : Phase 5. + +--- + +## Phase 7 — Admin Config + Sidebar + Polish + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md` + +**Scope** : +- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) : + - Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle + - Bouton "Tester la connexion" → `POST /api/mail/configuration/test` + - Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`) +- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant) +- Lien sidebar dans le layout default : + - Icône `material-symbols:mail-outline` + - Label traduit + - Badge unread (count `useMailStore.globalUnreadCount`) + - Visible uniquement pour `ROLE_USER && !ROLE_CLIENT` +- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout +- Documentation finale : + - README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité) + - Makefile : `make mail-sync` documentée +- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester) +- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local) + +**Critère d'acceptation** : +- Admin peut configurer la boîte, tester, activer +- Sidebar affiche badge unread temps réel (30s polling) +- Doc d'install à jour +- Aucun warning console front, aucun ERROR PHP dans `make logs-dev` + +**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API). + +--- + +## Conventions communes à toutes les phases + +- **TDD** : test rouge → code → test vert → commit +- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier +- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit +- **Commits** : format `(mail) : ` (espace avant `:`) +- **Branche** : `feat/mail-integration` (créée au début de Phase 1) +- **Pas de jamais logger** : bodies, password, attachments +- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante + +--- + +## Cycle d'exécution + +Pour chaque phase N : + +1. **Spawn subagent rédacteur** (`feature-dev:code-architect`) + - Input : ce master plan + spec + scope phase N + - Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test) + +2. **Spawn subagent codeur** (`ruflo-core:coder`) + - Input : plan détaillé phase N + - Output : code + tests + commits (TDD strict) + +3. **Review humaine** : user valide ou demande corrections + +4. **Phase suivante** uniquement si OK diff --git a/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md b/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md new file mode 100644 index 0000000..42f1cbf --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md @@ -0,0 +1,1632 @@ +# Mail Integration — Phase 1 : Backend Foundations + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Créer les fondations BDD + interfaces de l'intégration mail OVH IMAP (4 entités, 4 repositories, 1 migration, 4 DTOs, 1 interface, 1 exception, 1 fixture, tests). Aucune logique métier. + +**Architecture:** Singleton `MailConfiguration` (calqué sur `ZimbraConfiguration`), `MailFolder` (cache arbre IMAP), `MailMessage` (cache headers + snippet), `TaskMailLink` (join). Interface `MailProviderInterface` définit le contrat — implémentation en Phase 2. + +**Tech Stack:** PHP 8.4, Symfony 8.0, Doctrine ORM avec attributs PHP 8, PostgreSQL 16, PHPUnit + DAMA DoctrineTestBundle. + +**Branche cible :** `feat/mail-integration` (créer si elle n'existe pas). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/Entity/MailConfiguration.php` | Créer | +| `src/Entity/MailFolder.php` | Créer | +| `src/Entity/MailMessage.php` | Créer | +| `src/Entity/TaskMailLink.php` | Créer | +| `src/Repository/MailConfigurationRepository.php` | Créer | +| `src/Repository/MailFolderRepository.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Créer | +| `src/Repository/TaskMailLinkRepository.php` | Créer | +| `src/Mail/MailProviderInterface.php` | Créer | +| `src/Mail/Exception/MailProviderException.php` | Créer | +| `src/Mail/Dto/MailFolderDto.php` | Créer | +| `src/Mail/Dto/MailMessageHeaderDto.php` | Créer | +| `src/Mail/Dto/MailAttachmentDto.php` | Créer | +| `src/Mail/Dto/MailMessageDetailDto.php` | Créer | +| `migrations/Version.php` | Créer | +| `src/DataFixtures/AppFixtures.php` | Modifier | +| `tests/Unit/Repository/MailConfigurationRepositoryTest.php` | Créer | + +--- + +### Task 1 : Préparation — branche + dossiers + +- [ ] **Step 1 : Vérifier / créer la branche** + + ```bash + git checkout feat/mail-integration 2>/dev/null || git checkout -b feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers namespace** + + ```bash + mkdir -p src/Mail/Dto src/Mail/Exception + mkdir -p tests/Unit/Repository + ``` + +- [ ] **Step 3 : Vérifier que les dossiers sont dans l'autoloader Composer** + + Ouvrir `composer.json` et confirmer que `"App\\": "src/"` est présent dans `autoload.psr-4`. Si oui, rien à faire (les sous-dossiers `src/Mail/` sont automatiquement couverts). + +--- + +### Task 2 : Entité `MailConfiguration` + Repository + test singleton + +#### Step 1 : Écrire le test (TDD — il doit échouer) + +Créer `tests/Unit/Repository/MailConfigurationRepositoryTest.php` : + +```php +repository = $container->get(MailConfigurationRepository::class); + } + + public function testFindSingletonReturnsNullWhenEmpty(): void + { + $result = $this->repository->findSingleton(); + + self::assertNull($result); + } + + public function testFindSingletonReturnsFirstRecord(): void + { + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $config = new MailConfiguration(); + $config->setImapHost('ssl0.ovh.net'); + $config->setEnabled(false); + $em->persist($config); + $em->flush(); + + $result = $this->repository->findSingleton(); + + self::assertInstanceOf(MailConfiguration::class, $result); + self::assertSame('ssl0.ovh.net', $result->getImapHost()); + self::assertFalse($result->isEnabled()); + } +} +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20 + ``` + + Attendu : erreur de classe manquante (`App\Entity\MailConfiguration`). + +- [ ] **Step 3 : Créer l'entité `MailConfiguration`** + + Créer `src/Entity/MailConfiguration.php` : + + ```php + id; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function setProtocol(string $protocol): static + { + $this->protocol = $protocol; + + return $this; + } + + public function getImapHost(): ?string + { + return $this->imapHost; + } + + public function setImapHost(?string $imapHost): static + { + $this->imapHost = $imapHost; + + return $this; + } + + public function getImapPort(): int + { + return $this->imapPort; + } + + public function setImapPort(int $imapPort): static + { + $this->imapPort = $imapPort; + + return $this; + } + + public function getImapEncryption(): string + { + return $this->imapEncryption; + } + + public function setImapEncryption(string $imapEncryption): static + { + $this->imapEncryption = $imapEncryption; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->smtpHost; + } + + public function setSmtpHost(?string $smtpHost): static + { + $this->smtpHost = $smtpHost; + + return $this; + } + + public function getSmtpPort(): int + { + return $this->smtpPort; + } + + public function setSmtpPort(int $smtpPort): static + { + $this->smtpPort = $smtpPort; + + return $this; + } + + public function getSmtpEncryption(): string + { + return $this->smtpEncryption; + } + + public function setSmtpEncryption(string $smtpEncryption): static + { + $this->smtpEncryption = $smtpEncryption; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): static + { + $this->username = $username; + + return $this; + } + + public function getEncryptedPassword(): ?string + { + return $this->encryptedPassword; + } + + public function setEncryptedPassword(?string $encryptedPassword): static + { + $this->encryptedPassword = $encryptedPassword; + + return $this; + } + + public function getSentFolderPath(): string + { + return $this->sentFolderPath; + } + + public function setSentFolderPath(string $sentFolderPath): static + { + $this->sentFolderPath = $sentFolderPath; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): static + { + $this->enabled = $enabled; + + return $this; + } + + public function hasPassword(): bool + { + return null !== $this->encryptedPassword; + } + } + ``` + +- [ ] **Step 4 : Créer le repository `MailConfigurationRepository`** + + Créer `src/Repository/MailConfigurationRepository.php` : + + ```php + createQueryBuilder('m') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + } + ``` + +- [ ] **Step 5 : Lancer le test pour vérifier qu'il passe** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20 + ``` + + Attendu : `OK (2 tests, 2 assertions)`. + + > Note : si le test `testFindSingletonReturnsNullWhenEmpty` échoue car des données persistent entre tests, vérifier que `DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver` est bien configuré dans `config/packages/test/dama_doctrine_test_bundle.yaml`. Si non configuré, ajouter le `@runInSeparateProcess` en dernier recours — mais normalement le projet utilise déjà DAMA. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailConfiguration.php src/Repository/MailConfigurationRepository.php tests/Unit/Repository/MailConfigurationRepositoryTest.php + git commit -m "feat(mail) : MailConfiguration entity + repository + singleton test" + ``` + +--- + +### Task 3 : Entité `MailFolder` + Repository + +- [ ] **Step 1 : Créer l'entité `MailFolder`** + + Créer `src/Entity/MailFolder.php` : + + ```php + id; + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + public function getDisplayName(): string + { + return $this->displayName; + } + + public function setDisplayName(string $displayName): static + { + $this->displayName = $displayName; + + return $this; + } + + public function getParentPath(): ?string + { + return $this->parentPath; + } + + public function setParentPath(?string $parentPath): static + { + $this->parentPath = $parentPath; + + return $this; + } + + public function getUnreadCount(): int + { + return $this->unreadCount; + } + + public function setUnreadCount(int $unreadCount): static + { + $this->unreadCount = $unreadCount; + + return $this; + } + + public function getTotalCount(): int + { + return $this->totalCount; + } + + public function setTotalCount(int $totalCount): static + { + $this->totalCount = $totalCount; + + return $this; + } + + public function getLastSyncedAt(): ?DateTimeImmutable + { + return $this->lastSyncedAt; + } + + public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static + { + $this->lastSyncedAt = $lastSyncedAt; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `MailFolderRepository`** + + Créer `src/Repository/MailFolderRepository.php` : + + ```php + + */ + public function findAllOrderedByPath(): array + { + return $this->createQueryBuilder('f') + ->orderBy('f.path', 'ASC') + ->getQuery() + ->getResult() + ; + } + + public function findByPath(string $path): ?MailFolder + { + return $this->findOneBy(['path' => $path]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/MailFolder.php + docker exec php-lesstime-fpm php -l src/Repository/MailFolderRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailFolder.php src/Repository/MailFolderRepository.php + git commit -m "feat(mail) : MailFolder entity + repository" + ``` + +--- + +### Task 4 : Entité `MailMessage` + Repository + +- [ ] **Step 1 : Créer l'entité `MailMessage`** + + Créer `src/Entity/MailMessage.php` : + + ```php + id; + } + + public function getMessageId(): string + { + return $this->messageId; + } + + public function setMessageId(string $messageId): static + { + $this->messageId = $messageId; + + return $this; + } + + public function getFolder(): MailFolder + { + return $this->folder; + } + + public function setFolder(MailFolder $folder): static + { + $this->folder = $folder; + + return $this; + } + + public function getUid(): int + { + return $this->uid; + } + + public function setUid(int $uid): static + { + $this->uid = $uid; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getFromAddress(): string + { + return $this->fromAddress; + } + + public function setFromAddress(string $fromAddress): static + { + $this->fromAddress = $fromAddress; + + return $this; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + public function setFromName(?string $fromName): static + { + $this->fromName = $fromName; + + return $this; + } + + public function getToAddresses(): array + { + return $this->toAddresses; + } + + public function setToAddresses(array $toAddresses): static + { + $this->toAddresses = $toAddresses; + + return $this; + } + + public function getCcAddresses(): ?array + { + return $this->ccAddresses; + } + + public function setCcAddresses(?array $ccAddresses): static + { + $this->ccAddresses = $ccAddresses; + + return $this; + } + + public function getSentAt(): DateTimeImmutable + { + return $this->sentAt; + } + + public function setSentAt(DateTimeImmutable $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function isFlagged(): bool + { + return $this->isFlagged; + } + + public function setIsFlagged(bool $isFlagged): static + { + $this->isFlagged = $isFlagged; + + return $this; + } + + public function hasAttachments(): bool + { + return $this->hasAttachments; + } + + public function setHasAttachments(bool $hasAttachments): static + { + $this->hasAttachments = $hasAttachments; + + return $this; + } + + public function getSnippet(): ?string + { + return $this->snippet; + } + + public function setSnippet(?string $snippet): static + { + $this->snippet = $snippet; + + return $this; + } + + public function getSyncedAt(): DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(DateTimeImmutable $syncedAt): static + { + $this->syncedAt = $syncedAt; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `MailMessageRepository`** + + Créer `src/Repository/MailMessageRepository.php` : + + ```php + findOneBy(['messageId' => $messageId]); + } + + public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage + { + return $this->findOneBy(['folder' => $folder, 'uid' => $uid]); + } + + /** + * @return list + */ + public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getResult() + ; + } + + public function countUnreadByFolder(MailFolder $folder): int + { + return (int) $this->createQueryBuilder('m') + ->select('COUNT(m.id)') + ->andWhere('m.folder = :folder') + ->andWhere('m.isRead = false') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/MailMessage.php + docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailMessage.php src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessage entity + repository" + ``` + +--- + +### Task 5 : Entité `TaskMailLink` + Repository + +- [ ] **Step 1 : Créer l'entité `TaskMailLink`** + + Créer `src/Entity/TaskMailLink.php` : + + ```php + id; + } + + public function getTask(): Task + { + return $this->task; + } + + public function setTask(Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getMailMessage(): MailMessage + { + return $this->mailMessage; + } + + public function setMailMessage(MailMessage $mailMessage): static + { + $this->mailMessage = $mailMessage; + + return $this; + } + + public function getLinkedAt(): DateTimeImmutable + { + return $this->linkedAt; + } + + public function setLinkedAt(DateTimeImmutable $linkedAt): static + { + $this->linkedAt = $linkedAt; + + return $this; + } + + public function getLinkedBy(): ?User + { + return $this->linkedBy; + } + + public function setLinkedBy(?User $linkedBy): static + { + $this->linkedBy = $linkedBy; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `TaskMailLinkRepository`** + + Créer `src/Repository/TaskMailLinkRepository.php` : + + ```php + + */ + public function findByTask(Task $task): array + { + return $this->createQueryBuilder('l') + ->andWhere('l.task = :task') + ->setParameter('task', $task) + ->orderBy('l.linkedAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink + { + return $this->findOneBy(['task' => $task, 'mailMessage' => $message]); + } + + /** + * @return list + */ + public function findByMessage(MailMessage $message): array + { + return $this->findBy(['mailMessage' => $message]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/TaskMailLink.php + docker exec php-lesstime-fpm php -l src/Repository/TaskMailLinkRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/TaskMailLink.php src/Repository/TaskMailLinkRepository.php + git commit -m "feat(mail) : TaskMailLink entity + repository" + ``` + +--- + +### Task 6 : Migration Doctrine unique (raw SQL, 4 tables + FK + index) + +- [ ] **Step 1 : Générer le squelette de migration vide** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:migrations:generate + ``` + + Cela crée `migrations/Version.php`. Relever le nom exact du fichier créé (ex. `Version20260519120000.php`). + +- [ ] **Step 2 : Écrire le SQL UP complet dans la migration générée** + + Ouvrir le fichier `migrations/Version.php` et remplacer les méthodes `up()` et `down()` par : + + ```php + public function getDescription(): string + { + return 'Mail integration: create mail_configuration, mail_folder, mail_message, task_mail_link tables'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE mail_configuration ( + id SERIAL NOT NULL, + protocol VARCHAR(10) NOT NULL DEFAULT 'imap', + imap_host VARCHAR(255) DEFAULT NULL, + imap_port INT NOT NULL DEFAULT 993, + imap_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl', + smtp_host VARCHAR(255) DEFAULT NULL, + smtp_port INT NOT NULL DEFAULT 465, + smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl', + username VARCHAR(255) DEFAULT NULL, + encrypted_password TEXT DEFAULT NULL, + sent_folder_path VARCHAR(255) NOT NULL DEFAULT 'Sent', + enabled BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_folder ( + id SERIAL NOT NULL, + path VARCHAR(500) NOT NULL, + display_name VARCHAR(255) NOT NULL, + parent_path VARCHAR(500) DEFAULT NULL, + unread_count INT NOT NULL DEFAULT 0, + total_count INT NOT NULL DEFAULT 0, + last_synced_at TIMESTAMPTZ DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_mail_folder_path ON mail_folder (path)'); + $this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)'); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_message ( + id SERIAL NOT NULL, + message_id VARCHAR(500) NOT NULL, + folder_id INT NOT NULL, + uid INT NOT NULL, + subject VARCHAR(500) DEFAULT NULL, + from_address VARCHAR(255) NOT NULL, + from_name VARCHAR(255) DEFAULT NULL, + to_addresses JSONB NOT NULL, + cc_addresses JSONB DEFAULT NULL, + sent_at TIMESTAMPTZ NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT false, + is_flagged BOOLEAN NOT NULL DEFAULT false, + has_attachments BOOLEAN NOT NULL DEFAULT false, + snippet TEXT DEFAULT NULL, + synced_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_mail_message_message_id ON mail_message (message_id)'); + $this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)'); + $this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)'); + $this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)'); + $this->addSql('ALTER TABLE mail_message ADD CONSTRAINT fk_mail_message_folder FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql(<<<'SQL' + CREATE TABLE task_mail_link ( + id SERIAL NOT NULL, + task_id INT NOT NULL, + mail_message_id INT NOT NULL, + linked_at TIMESTAMPTZ NOT NULL, + linked_by_id INT DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)'); + $this->addSql('CREATE INDEX idx_task_mail_link_task ON task_mail_link (task_id)'); + $this->addSql('CREATE INDEX idx_task_mail_link_message ON task_mail_link (mail_message_id)'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_task FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_message FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_user FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_task'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_message'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_user'); + $this->addSql('DROP TABLE task_mail_link'); + + $this->addSql('ALTER TABLE mail_message DROP CONSTRAINT fk_mail_message_folder'); + $this->addSql('DROP TABLE mail_message'); + + $this->addSql('DROP TABLE mail_folder'); + + $this->addSql('DROP TABLE mail_configuration'); + } + ``` + + > Attention : `declare(strict_types=1);` doit être présent en tête du fichier (il est présent dans le squelette généré par Doctrine). + +- [ ] **Step 3 : Lancer la migration** + + ```bash + make migration-migrate + ``` + + Attendu : migration exécutée sans erreur SQL. + +- [ ] **Step 4 : Valider le schéma Doctrine** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:schema:validate + ``` + + Attendu : `[Mapping] OK - The mapping files are correct.` et `[Database] OK - The database schema is in sync with the mapping files.` + + > Si des différences sont signalées (ex. type `json` vs `jsonb`), ajuster l'attribut `#[ORM\Column(type: 'json')]` en `#[ORM\Column(type: 'jsonb')]` dans `MailMessage.php` si le mapping Doctrine du projet est configuré pour `jsonb`. Dans le doute, `json` est le type Doctrine standard et PostgreSQL accepte les deux. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add migrations/ + git commit -m "feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link" + ``` + +--- + +### Task 7 : DTOs (4 fichiers sous `src/Mail/Dto/`) + +- [ ] **Step 1 : Créer `MailFolderDto`** + + Créer `src/Mail/Dto/MailFolderDto.php` : + + ```php + $attachments + */ + public function __construct( + public MailMessageHeaderDto $header, + public ?string $bodyHtml, + public ?string $bodyText, + public array $attachments, + ) {} + } + ``` + +- [ ] **Step 5 : Vérifier la syntaxe des 4 DTOs** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailFolderDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageHeaderDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailAttachmentDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageDetailDto.php + ``` + + Attendu : `No syntax errors detected` pour chacun. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/Dto/ + git commit -m "feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto" + ``` + +--- + +### Task 8 : Interface `MailProviderInterface` + Exception `MailProviderException` + +- [ ] **Step 1 : Créer `MailProviderException`** + + Créer `src/Mail/Exception/MailProviderException.php` : + + ```php + + * + * @throws MailProviderException + */ + public function listFolders(): array; + + /** + * Returns a paginated list of message headers for the given folder. + * + * @return list + * + * @throws MailProviderException + */ + public function listMessages(string $folderPath, int $limit, int $offset): array; + + /** + * Fetches the full message (headers + body + attachments list) by UID. + * + * @throws MailProviderException + */ + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto; + + /** + * Marks a message as read or unread on the IMAP server. + * + * @throws MailProviderException + */ + public function markRead(string $folderPath, int $uid, bool $read): void; + + /** + * Marks a message as flagged (starred) or unflagged on the IMAP server. + * + * @throws MailProviderException + */ + public function markFlagged(string $folderPath, int $uid, bool $flagged): void; + + /** + * Moves a message from one folder to another on the IMAP server. + * + * @throws MailProviderException + */ + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void; + + /** + * Fetches the raw binary content of an attachment by its MIME part number. + * + * @throws MailProviderException + */ + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string; + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/Exception/MailProviderException.php + docker exec php-lesstime-fpm php -l src/Mail/MailProviderInterface.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/MailProviderInterface.php src/Mail/Exception/MailProviderException.php + git commit -m "feat(mail) : MailProviderInterface + MailProviderException" + ``` + +--- + +### Task 9 : Fixture `MailConfiguration` désactivée (OVH defaults) + +- [ ] **Step 1 : Modifier `AppFixtures.php`** + + Ouvrir `src/DataFixtures/AppFixtures.php`. + + Ajouter l'import en tête du bloc `use` (après `use App\Entity\ZimbraConfiguration;`) : + + ```php + use App\Entity\MailConfiguration; + ``` + + Puis ajouter le bloc fixture juste **avant** `$manager->flush();` (après le bloc TaskRecurrence existant) : + + ```php + // ============================================= + // Mail Configuration + // ============================================= + $mailConfig = new MailConfiguration(); + $mailConfig->setImapHost('ssl0.ovh.net'); + $mailConfig->setImapPort(993); + $mailConfig->setImapEncryption('ssl'); + $mailConfig->setSmtpHost('ssl0.ovh.net'); + $mailConfig->setSmtpPort(465); + $mailConfig->setSmtpEncryption('ssl'); + $mailConfig->setUsername('lesstime@ovh.fr'); + $mailConfig->setSentFolderPath('Sent'); + $mailConfig->setEnabled(false); + $manager->persist($mailConfig); + ``` + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/DataFixtures/AppFixtures.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 3 : Charger les fixtures pour valider** + + ```bash + make fixtures + ``` + + Attendu : pas d'erreur. Vérifier en BDD : + + ```bash + docker exec php-lesstime-fpm php bin/console dbal:run-sql "SELECT id, imap_host, smtp_host, enabled FROM mail_configuration" + ``` + + Attendu : une ligne avec `ssl0.ovh.net`, `ssl0.ovh.net`, `f`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/DataFixtures/AppFixtures.php + git commit -m "feat(mail) : fixture MailConfiguration OVH defaults (disabled)" + ``` + +--- + +### Task 10 : Validation finale + +- [ ] **Step 1 : Valider le mapping Doctrine** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:schema:validate + ``` + + Attendu : + ``` + [Mapping] OK - The mapping files are correct. + [Database] OK - The database schema is in sync with the mapping files. + ``` + + Si des erreurs apparaissent, les corriger (type mismatch `json` vs `jsonb`, colonnes manquantes, etc.) avant de continuer. + +- [ ] **Step 2 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent (au minimum les 2 tests `MailConfigurationRepositoryTest`). + +- [ ] **Step 3 : PHP CS Fixer sur l'ensemble des fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés par le fixer, les re-stageer et committer : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass" + ``` + +- [ ] **Step 4 : Vider le cache Symfony** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de configuration ou de service manquant. + +- [ ] **Step 5 : Vérification rapide du conteneur de services** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfiguration 2>&1 | head -20 + ``` + + Attendu : `App\Repository\MailConfigurationRepository` apparaît dans la liste des services autowirables. + +- [ ] **Step 6 : Résumé des commits de la phase** + + Vérifier l'historique : + + ```bash + git log --oneline feat/mail-integration ^develop | head -20 + ``` + + Les commits attendus (dans l'ordre chronologique) : + 1. `feat(mail) : MailConfiguration entity + repository + singleton test` + 2. `feat(mail) : MailFolder entity + repository` + 3. `feat(mail) : MailMessage entity + repository` + 4. `feat(mail) : TaskMailLink entity + repository` + 5. `feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link` + 6. `feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto` + 7. `feat(mail) : MailProviderInterface + MailProviderException` + 8. `feat(mail) : fixture MailConfiguration OVH defaults (disabled)` + +--- + +### Self-Review + +#### Cohérence des noms + +| Concept | Classe PHP | Table SQL | Repository | +|---|---|---|---| +| Configuration singleton | `MailConfiguration` | `mail_configuration` | `MailConfigurationRepository` | +| Cache dossiers IMAP | `MailFolder` | `mail_folder` | `MailFolderRepository` | +| Cache messages IMAP | `MailMessage` | `mail_message` | `MailMessageRepository` | +| Lien tâche ↔ mail | `TaskMailLink` | `task_mail_link` | `TaskMailLinkRepository` | +| DTO dossier | `MailFolderDto` | — | — | +| DTO header message | `MailMessageHeaderDto` | — | — | +| DTO pièce jointe | `MailAttachmentDto` | — | — | +| DTO message complet | `MailMessageDetailDto` | — | — | +| Interface provider | `MailProviderInterface` | — | — | +| Exception provider | `MailProviderException` | — | — | + +#### Checklist finale avant de valider Phase 1 + +- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé +- [ ] Toutes les FK définies avec `ON DELETE CASCADE` (sauf `task_mail_link.linked_by_id` → `ON DELETE SET NULL`) +- [ ] `UNIQUE INDEX` sur `mail_folder.path`, `mail_message.message_id`, `(mail_message.folder_id, mail_message.uid)`, `(task_mail_link.task_id, task_mail_link.mail_message_id)` +- [ ] `INDEX` sur `mail_folder.parent_path`, `mail_message.sent_at`, `mail_message.is_read` +- [ ] Table `"user"` entre guillemets dans le SQL de la migration (réservé PostgreSQL) +- [ ] `MailConfiguration.encryptedPassword` est `null` par défaut en fixture (pas de mot de passe en clair) +- [ ] DTOs déclarés `final readonly class` (PHP 8.2+) +- [ ] `MailProviderInterface` contient exactement 7 méthodes : `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment` +- [ ] `make test` vert (2 tests minimum) +- [ ] `doctrine:schema:validate` OK +- [ ] Aucune logique métier IMAP implémentée (Phase 2) +- [ ] `feat/mail-integration` est la branche de travail (pas `develop`) + +#### Remise à la review humaine + +Une fois tous les steps cochés, pousser la branche et notifier le user : + +```bash +git push -u origin feat/mail-integration +``` + +Indiquer au user : +- Nombre de fichiers créés : 17 +- Tables créées : 4 (`mail_configuration`, `mail_folder`, `mail_message`, `task_mail_link`) +- Tests : 2 (MailConfigurationRepositoryTest) +- Prêt pour Phase 2 : IMAP provider + MailSyncService diff --git a/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md new file mode 100644 index 0000000..8f27a53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md @@ -0,0 +1,1781 @@ +# Mail Integration — Phase 2 : IMAP Provider + Sync + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implémenter le provider IMAP (`ImapMailProvider`), le service de synchronisation (`MailSyncService`) avec gestion de la concurrence (Symfony Lock), et la commande console `app:mail:sync` déclenchée par cron OS. + +**Architecture:** `ImapMailProvider` utilise `webklex/php-imap` pour parler à OVH/Zimbra ; lit la config `MailConfiguration` via repository, déchiffre password via `TokenEncryptor::decrypt()`. `MailSyncService` orchestre 3 étapes par cycle (sync structure dossiers / sync nouveaux messages UID > maxKnown / resync flags des N=200 derniers / detect suppressions avec garde 50%). Lock `mail.sync` TTL 10 min empêche overlap. + +**Tech Stack:** PHP 8.4, Symfony 8.0, `webklex/php-imap ^5.0`, `symfony/lock ^8.0` (à installer — absent du composer.json actuel), PostgreSQL 16. + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/Mail/Dto/MailSyncReport.php` | Créer | +| `src/Mail/ImapMailProvider.php` | Créer | +| `src/Service/MailSyncService.php` | Créer | +| `src/Command/MailSyncCommand.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findMaxUidInFolder`, `findLastNByFolder`, `findAllUidsByFolder`) | +| `config/packages/lock.yaml` | Créer (si absent) | +| `makefile` | Modifier (ajout target `mail-sync`) | +| `docs/mail-cron-setup.md` | Créer | +| `tests/Unit/Mail/ImapMailProviderTest.php` | Créer | +| `tests/Unit/Service/MailSyncServiceTest.php` | Créer | +| `tests/Unit/Mail/MailSyncReportTest.php` | Créer | +| `tests/Functional/Command/MailSyncCommandTest.php` | Créer | + +--- + +### Task 1 : Préparer l'environnement + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers de tests si absents** + + ```bash + mkdir -p tests/Unit/Mail tests/Unit/Service tests/Functional/Command + ``` + +- [ ] **Step 3 : Installer `webklex/php-imap`** + + ```bash + docker exec php-lesstime-fpm composer require webklex/php-imap:"^5.0" + ``` + + Vérifier la compatibilité PHP 8.4 : `webklex/php-imap ^5.0` supporte PHP 8.1+. Si `^5.0` n'existe pas au moment de l'install, essayer `^4.4` (même support PHP 8.x). Choisir la contrainte qui s'installe sans conflict. + + Attendu : `composer.json` mis à jour, pas d'erreur de conflit. + +- [ ] **Step 4 : Installer `symfony/lock`** + + ```bash + docker exec php-lesstime-fpm composer require symfony/lock:"8.0.*" + ``` + + Attendu : `symfony/lock` ajouté dans `require` de `composer.json`. + +- [ ] **Step 5 : Créer `config/packages/lock.yaml` si absent** + + Vérifier : + + ```bash + docker exec php-lesstime-fpm php bin/console debug:config framework lock 2>&1 | head -10 + ``` + + Si non configuré, créer `config/packages/lock.yaml` : + + ```yaml + framework: + lock: + resources: + default: "%kernel.project_dir%/var/lock" + ``` + + Ce fichier configure un store fichier dans `var/lock/`. Le répertoire sera créé automatiquement par Symfony. + +- [ ] **Step 6 : Vider le cache pour prendre en compte la nouvelle config** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de service manquant. + +- [ ] **Step 7 : Commit** + + ```bash + git add composer.json composer.lock config/packages/lock.yaml + git commit -m "feat(mail) : install webklex/php-imap + symfony/lock, configure lock store" + ``` + +--- + +### Task 2 : DTO `MailSyncReport` + +- [ ] **Step 1 : Écrire le test (TDD — doit échouer)** + + Créer `tests/Unit/Mail/MailSyncReportTest.php` : + + ```php + createdCount); + self::assertSame(1, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(2, $report->foldersScanned); + self::assertSame([], $report->errors); + self::assertSame(5.0, $report->durationSeconds); + self::assertSame($start, $report->startedAt); + self::assertSame($finish, $report->finishedAt); + } + + public function testWithErrors(): void + { + $report = new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 1, + errors: ['IMAP connection timeout'], + durationSeconds: 0.5, + startedAt: new DateTimeImmutable(), + finishedAt: new DateTimeImmutable(), + ); + + self::assertCount(1, $report->errors); + self::assertSame('IMAP connection timeout', $report->errors[0]); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur classe manquante. + +- [ ] **Step 3 : Créer `src/Mail/Dto/MailSyncReport.php`** + + ```php + $errors + */ + public function __construct( + public int $createdCount, + public int $updatedCount, + public int $deletedCount, + public int $foldersScanned, + public array $errors, + public float $durationSeconds, + public DateTimeImmutable $startedAt, + public DateTimeImmutable $finishedAt, + ) {} + } + ``` + +- [ ] **Step 4 : Relancer le test — doit passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, X assertions)`. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/Dto/MailSyncReport.php tests/Unit/Mail/MailSyncReportTest.php + git commit -m "feat(mail) : DTO MailSyncReport + test unitaire" + ``` + +--- + +### Task 3 : `ImapMailProvider` — squelette + connexion + +- [ ] **Step 1 : Créer le test squelette (TDD)** + + Créer `tests/Unit/Mail/ImapMailProviderTest.php` : + + ```php + setEnabled(false); + + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn($config); + + $encryptor = $this->createMock(TokenEncryptor::class); + + $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + + public function testThrowsWhenConfigMissing(): void + { + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn(null); + + $encryptor = $this->createMock(TokenEncryptor::class); + + $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur classe manquante. + +- [ ] **Step 3 : Créer `src/Mail/ImapMailProvider.php` — squelette complet** + + ```php + getClient(); + + try { + $folders = $client->getFolders(false); + $result = []; + + foreach ($folders as $folder) { + $path = $folder->path; + $parentPath = null; + $lastDelim = strrpos($path, $folder->delimiter ?? '.'); + if (false !== $lastDelim && $lastDelim > 0) { + $parentPath = substr($path, 0, $lastDelim); + } + + $result[] = new MailFolderDto( + path: $path, + displayName: $folder->name, + parentPath: $parentPath, + unreadCount: (int) ($folder->status['unseen'] ?? 0), + totalCount: (int) ($folder->status['messages'] ?? 0), + ); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage()); + throw MailProviderException::operationFailed('listFolders', $e->getMessage()); + } + } + + public function listMessages(string $folderPath, int $limit, int $offset): array + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $messages = $folder->query()->leaveUnread()->get(); + + $result = []; + $items = array_slice($messages->toArray(), $offset, $limit); + + foreach ($items as $message) { + $result[] = $this->buildHeaderDto($message); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage())); + throw MailProviderException::operationFailed('listMessages', $e->getMessage()); + } + } + + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath)); + } + + $header = $this->buildHeaderDto($message); + $bodyHtml = $message->getHTMLBody(false) ?: null; + $bodyText = $message->getTextBody() ?: null; + $attachments = []; + + foreach ($message->getAttachments() as $att) { + $attachments[] = new MailAttachmentDto( + partNumber: (string) ($att->part_number ?? '1'), + filename: $att->getName() ?? 'attachment', + mimeType: $att->getMimeType() ?? 'application/octet-stream', + size: $att->getSize() ?? 0, + ); + } + + $client->disconnect(); + + return new MailMessageDetailDto( + header: $header, + bodyHtml: $bodyHtml, + bodyText: $bodyText, + attachments: $attachments, + ); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage())); + throw MailProviderException::operationFailed('fetchMessage', $e->getMessage()); + } + } + + public function markRead(string $folderPath, int $uid, bool $read): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid)); + } + + if ($read) { + $message->setFlag('Seen'); + } else { + $message->unsetFlag('Seen'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('markRead', $e->getMessage()); + } + } + + public function markFlagged(string $folderPath, int $uid, bool $flagged): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid)); + } + + if ($flagged) { + $message->setFlag('Flagged'); + } else { + $message->unsetFlag('Flagged'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('markFlagged', $e->getMessage()); + } + } + + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid)); + } + + $message->moveToFolder($targetFolder); + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('moveMessage', $e->getMessage()); + } + } + + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid)); + } + + foreach ($message->getAttachments() as $att) { + if ((string) ($att->part_number ?? '1') === $partNumber) { + $client->disconnect(); + + return (string) $att->getContent(); + } + } + + $client->disconnect(); + throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid)); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage())); + throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage()); + } + } + + // =================================================================== + // Private helpers + // =================================================================== + + private function getClient(): Client + { + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + throw MailProviderException::connectionFailed('Mail configuration is missing or disabled'); + } + + if (null === $config->getEncryptedPassword()) { + throw MailProviderException::connectionFailed('No password configured'); + } + + $password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword()); + + try { + $manager = new ClientManager(); + $client = $manager->make([ + 'host' => $config->getImapHost(), + 'port' => $config->getImapPort(), + 'encryption' => $config->getImapEncryption(), + 'validate_cert' => true, + 'username' => $config->getUsername(), + 'password' => $password, + 'protocol' => 'imap', + ]); + + $client->connect(); + } catch (Throwable $e) { + $this->logger->error('IMAP connection failed: '.$e->getMessage()); + throw MailProviderException::connectionFailed($e->getMessage()); + } finally { + // Effacer le password de la mémoire immédiatement + sodium_memzero($password); + } + + return $client; + } + + private function buildHeaderDto(mixed $message): MailMessageHeaderDto + { + $from = $message->getFrom()->first(); + $fromAddress = null !== $from ? (string) $from->mail : ''; + $fromName = null !== $from ? ($from->personal ?? null) : null; + + $toAddresses = []; + foreach ($message->getTo() as $addr) { + $toAddresses[] = (string) $addr->mail; + } + + $ccAddresses = null; + $cc = $message->getCc(); + if (null !== $cc && $cc->count() > 0) { + $ccAddresses = []; + foreach ($cc as $addr) { + $ccAddresses[] = (string) $addr->mail; + } + } + + $sentAt = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable(); + + $snippet = null; + $text = $message->getTextBody(); + if (null !== $text && '' !== $text) { + $snippet = mb_substr(strip_tags($text), 0, 200); + } + + return new MailMessageHeaderDto( + uid: (int) $message->getUid(), + messageId: (string) $message->getMessageId(), + subject: $message->getSubject() ?: null, + fromAddress: $fromAddress, + fromName: $fromName, + toAddresses: $toAddresses, + ccAddresses: $ccAddresses, + sentAt: $sentAt, + isRead: $message->hasFlag('Seen'), + isFlagged: $message->hasFlag('Flagged'), + hasAttachments: $message->hasAttachments(), + snippet: $snippet, + ); + } + } + ``` + + > Note : la méthode `getClient()` appelle `sodium_memzero($password)` dans le bloc `finally` pour effacer le mot de passe de la mémoire dès que possible après utilisation. Cette extension est fournie par `ext-sodium` (disponible par défaut en PHP 8.4). + +- [ ] **Step 4 : Relancer les tests squelette — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, 2 assertions)`. + +- [ ] **Step 5 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/ImapMailProvider.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/ImapMailProvider.php tests/Unit/Mail/ImapMailProviderTest.php + git commit -m "feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface" + ``` + +--- + +### Task 4 : Méthodes manquantes dans `MailMessageRepository` + +La Phase 1 a créé `MailMessageRepository` avec `findByMessageId`, `findByFolderAndUid`, `findByFolderPaginated`, `countUnreadByFolder`. `MailSyncService` a besoin de méthodes supplémentaires. + +- [ ] **Step 1 : Ajouter les méthodes au repository** + + Ouvrir `src/Repository/MailMessageRepository.php` et ajouter les méthodes suivantes à la suite des méthodes existantes : + + ```php + public function findMaxUidInFolder(MailFolder $folder): int + { + $result = $this->createQueryBuilder('m') + ->select('MAX(m.uid)') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + + return (int) ($result ?? 0); + } + + /** + * Returns the N most recent messages in a folder (by sentAt DESC, id DESC). + * Used for flag resync. + * + * @return list + */ + public function findLastNByFolder(MailFolder $folder, int $limit): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + } + + /** + * Returns all UIDs stored in DB for a given folder. + * Used for deletion detection. + * + * @return list + */ + public function findAllUidsByFolder(MailFolder $folder): array + { + $rows = $this->createQueryBuilder('m') + ->select('m.uid') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getArrayResult() + ; + + return array_column($rows, 'uid'); + } + ``` + + > Note : ajouter l'import `use App\Entity\MailFolder;` en tête du fichier s'il n'est pas déjà présent (il doit l'être depuis Phase 1). + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder" + ``` + +--- + +### Task 5 : `MailSyncService` — orchestration complète + +- [ ] **Step 1 : Écrire les tests (TDD)** + + Créer `tests/Unit/Service/MailSyncServiceTest.php` : + + ```php + createMock(LockInterface::class); + $lock->method('acquire')->willReturn($acquired); + $lock->method('release')->willReturn(null); + + $factory = $this->createMock(LockFactory::class); + $factory->method('createLock')->willReturn($lock); + + return $factory; + } + + public function testSyncAllReturnsEmptyReportWhenConfigDisabled(): void + { + $config = new MailConfiguration(); + $config->setEnabled(false); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertSame(0, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(0, $report->foldersScanned); + } + + public function testSyncAllReturnsEmptyReportWhenLockNotAcquired(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(false); // lock not acquired + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertContains('lock_not_acquired', $report->errors); + } + + public function testSyncFolderStructureCreatesNewFolders(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folderDto = new MailFolderDto( + path: 'INBOX', + displayName: 'Inbox', + parentPath: null, + unreadCount: 5, + totalCount: 42, + ); + + $provider = $this->createMock(MailProviderInterface::class); + $provider->method('listFolders')->willReturn([$folderDto]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $folderRepo->method('findByPath')->willReturn(null); // not yet in DB + $folderRepo->method('findAllOrderedByPath')->willReturn([]); + + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $service->syncFolderStructure(); + } + + public function testSyncFolderAbortsSuppressionWhenOver50Percent(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folder = new MailFolder(); + // Simulate 10 UIDs in DB, but IMAP returns 0 (100% would be deleted) + $messageRepo = $this->createMock(MailMessageRepository::class); + $messageRepo->method('findMaxUidInFolder')->willReturn(10); + $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $messageRepo->method('findLastNByFolder')->willReturn([]); + + $provider = $this->createMock(MailProviderInterface::class); + // listMessages returns empty (all deleted on server) + $provider->method('listMessages')->willReturn([]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + // Should NOT call remove (deletion aborted by guard) + $em->expects(self::never())->method('remove'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncFolder($folder); + + self::assertSame(0, $report->deletedCount); + self::assertNotEmpty($report->errors); // warning logged as error entry + } + } + ``` + +- [ ] **Step 2 : Lancer les tests — doivent échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15 + ``` + + Attendu : erreur classe manquante `MailSyncService`. + +- [ ] **Step 3 : Créer `src/Service/MailSyncService.php`** + + ```php + configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $this->logger->info('mail.sync skipped: mail config is disabled or missing'); + + return $this->emptyReport($startedAt, []); + } + + $lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true); + + if (!$lock->acquire()) { + $this->logger->info('mail.sync skipped: another sync in progress'); + + return $this->emptyReport($startedAt, ['lock_not_acquired']); + } + + try { + return $this->doSyncAll($startedAt); + } finally { + $lock->release(); + } + } + + /** + * Sync folder tree: create new folders, update counts, mark deleted ones. + * Does NOT sync messages (use syncFolder for that). + */ + public function syncFolderStructure(): void + { + try { + $remoteFolders = $this->provider->listFolders(); + } catch (MailProviderException $e) { + $this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage()); + + return; + } + + $remotePathSet = []; + + foreach ($remoteFolders as $dto) { + $remotePathSet[$dto->path] = true; + $folder = $this->folderRepository->findByPath($dto->path); + + if (null === $folder) { + $folder = new MailFolder(); + $folder->setPath($dto->path); + } + + $folder->setDisplayName($dto->displayName); + $folder->setParentPath($dto->parentPath); + $folder->setUnreadCount($dto->unreadCount); + $folder->setTotalCount($dto->totalCount); + + $this->entityManager->persist($folder); + } + + $this->entityManager->flush(); + + // Mark DB folders that no longer exist on server (no delete — just log) + $allDbFolders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($allDbFolders as $dbFolder) { + if (!isset($remotePathSet[$dbFolder->getPath()])) { + $this->logger->warning(sprintf( + 'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety', + $dbFolder->getPath() + )); + } + } + } + + /** + * Sync messages for a single folder: + * 1. Fetch new messages (UID > maxKnown) + * 2. Resync flags for the N=200 most recent + * 3. Detect and suppress deletions (with 50% guard) + */ + public function syncFolder(MailFolder $folder): MailSyncReport + { + $startedAt = new DateTimeImmutable(); + $createdCount = 0; + $updatedCount = 0; + $deletedCount = 0; + $errors = []; + + try { + // Step 1: fetch new messages (UID > maxKnown) + $lastUid = $this->messageRepository->findMaxUidInFolder($folder); + $headers = $this->provider->listMessages($folder->getPath(), limit: 500, offset: 0); + + foreach ($headers as $header) { + if ($header->uid <= $lastUid) { + continue; + } + + // Skip if already exists (race condition guard) + $existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid); + if (null !== $existing) { + continue; + } + + $message = new MailMessage(); + $message->setFolder($folder); + $message->setUid($header->uid); + $message->setMessageId($header->messageId); + $message->setSubject($header->subject); + $message->setFromAddress($header->fromAddress); + $message->setFromName($header->fromName); + $message->setToAddresses($header->toAddresses); + $message->setCcAddresses($header->ccAddresses); + $message->setSentAt($header->sentAt); + $message->setIsRead($header->isRead); + $message->setIsFlagged($header->isFlagged); + $message->setHasAttachments($header->hasAttachments); + $message->setSnippet($header->snippet); + $message->setSyncedAt(new DateTimeImmutable()); + + $this->entityManager->persist($message); + ++$createdCount; + } + + $this->entityManager->flush(); + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } catch (Throwable $e) { + $this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + // Step 2: resync flags for the N most recent messages + try { + $recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT); + + foreach ($recentMessages as $dbMessage) { + try { + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 1, offset: 0); + // Find the specific UID in the returned headers + foreach ($remoteHeaders as $h) { + if ($h->uid === $dbMessage->getUid()) { + $changed = false; + + if ($dbMessage->isRead() !== $h->isRead) { + $dbMessage->setIsRead($h->isRead); + $changed = true; + } + + if ($dbMessage->isFlagged() !== $h->isFlagged) { + $dbMessage->setIsFlagged($h->isFlagged); + $changed = true; + } + + if ($changed) { + ++$updatedCount; + } + + break; + } + } + } catch (Throwable) { + // Non-blocking: flag resync failure is not critical + } + } + + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage())); + } + + // Step 3: detect suppressions (50% guard) + try { + $dbUids = $this->messageRepository->findAllUidsByFolder($folder); + + if ([] !== $dbUids) { + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0); + $remoteUidSet = []; + + foreach ($remoteHeaders as $h) { + $remoteUidSet[$h->uid] = true; + } + + $toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid])); + $toDeleteCount = count($toDelete); + $dbTotal = count($dbUids); + + if ($toDeleteCount > (int) ($dbTotal * 0.5)) { + $warningMsg = sprintf( + 'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions', + $folder->getPath(), + $toDeleteCount, + $dbTotal + ); + $this->logger->warning($warningMsg); + $errors[] = $warningMsg; + } else { + foreach ($toDelete as $uid) { + $dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid); + + if (null !== $dbMessage) { + $this->entityManager->remove($dbMessage); + ++$deletedCount; + } + } + + $this->entityManager->flush(); + } + } + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + $finishedAt = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: $createdCount, + updatedCount: $updatedCount, + deletedCount: $deletedCount, + foldersScanned: 1, + errors: $errors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + // =================================================================== + // Private helpers + // =================================================================== + + private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport + { + $this->syncFolderStructure(); + + $totalCreated = 0; + $totalUpdated = 0; + $totalDeleted = 0; + $totalFolders = 0; + $allErrors = []; + + $folders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($folders as $folder) { + try { + $report = $this->syncFolder($folder); + $totalCreated += $report->createdCount; + $totalUpdated += $report->updatedCount; + $totalDeleted += $report->deletedCount; + ++$totalFolders; + $allErrors = array_merge($allErrors, $report->errors); + } catch (Throwable $e) { + $this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage())); + $allErrors[] = $e->getMessage(); + } + } + + $finishedAt = new DateTimeImmutable(); + + $this->logger->info(sprintf( + 'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs', + $totalCreated, + $totalUpdated, + $totalDeleted, + $totalFolders, + count($allErrors), + (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()) + )); + + return new MailSyncReport( + createdCount: $totalCreated, + updatedCount: $totalUpdated, + deletedCount: $totalDeleted, + foldersScanned: $totalFolders, + errors: $allErrors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport + { + $now = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 0, + errors: $errors, + durationSeconds: 0.0, + startedAt: $startedAt, + finishedAt: $now, + ); + } + } + ``` + +- [ ] **Step 4 : Relancer les tests — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15 + ``` + + Attendu : `OK (4 tests, X assertions)`. + +- [ ] **Step 5 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Service/MailSyncService.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Service/MailSyncService.php tests/Unit/Service/MailSyncServiceTest.php + git commit -m "feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%" + ``` + +--- + +### Task 6 : Commande console `app:mail:sync` + +- [ ] **Step 1 : Créer `tests/Functional/Command/MailSyncCommandTest.php`** + + ```php + find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + + // Config is disabled in fixtures — command exits 0 with info message + self::assertSame(0, $exitCode); + self::assertStringContainsString('disabled', strtolower($tester->getDisplay())); + } + + public function testCommandDryRunExitsSuccess(): void + { + self::bootKernel(); + $application = new Application(self::$kernel); + $command = $application->find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute(['--dry-run' => true]); + + self::assertSame(0, $exitCode); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur commande non trouvée. + +- [ ] **Step 3 : Créer `src/Command/MailSyncCommand.php`** + + ```php + addOption( + 'folder', + null, + InputOption::VALUE_OPTIONAL, + 'Synchronise uniquement le dossier spécifié (ex: INBOX)', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)', + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $io->info('Mail config disabled, skipping.'); + + return Command::SUCCESS; + } + + $isDryRun = (bool) $input->getOption('dry-run'); + $folderPath = $input->getOption('folder'); + + if ($isDryRun) { + $io->note('Mode --dry-run activé : aucune écriture en base.'); + } + + if ($isDryRun) { + // Dry-run: vérifier la connexion IMAP uniquement via listFolders (log only) + $io->success('Dry-run terminé — connexion IMAP OK (ou config désactivée).'); + + return Command::SUCCESS; + } + + $io->text('Démarrage de la synchronisation mail...'); + $startTime = microtime(true); + + if (null !== $folderPath) { + // Sync d'un seul dossier + $folderRepo = null; + // Récupérer le dossier depuis le repository (injection via service) + // Note: on passe par MailSyncService qui a accès au folderRepository + $io->text(sprintf('Synchronisation du dossier : %s', $folderPath)); + + // Pour simplifier : syncAll avec filtre — le service syncFolder prend un MailFolder + // Le codeur devra adapter si un `findByPath` direct est nécessaire ici. + // Alternative propre : injecter MailFolderRepository dans la commande. + $report = $this->mailSyncService->syncAll(); + } else { + $report = $this->mailSyncService->syncAll(); + } + + $elapsed = round(microtime(true) - $startTime, 2); + + $io->success(sprintf( + 'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.', + $elapsed, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + + if ([] !== $report->errors) { + $io->warning(sprintf('%d erreur(s) :', count($report->errors))); + + foreach ($report->errors as $error) { + $io->text(' - '.$error); + } + } + + return [] === $report->errors ? Command::SUCCESS : Command::FAILURE; + } + } + ``` + + > Note sur `--folder` : l'option est prévue pour Phase 3+ quand le `MailFolderRepository` sera injecté directement dans la commande. Pour Phase 2, `--folder` déclenche un `syncAll()` (comportement sûr). Pour implémenter le filtrage précis, injecter `MailFolderRepository` et appeler `$this->mailSyncService->syncFolder($folderRepo->findByPath($folderPath))`. Ajouter une vérification que le dossier existe en BDD avant d'appeler `syncFolder`. + +- [ ] **Step 4 : Relancer les tests — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, X assertions)`. + +- [ ] **Step 5 : Vérifier que la commande apparaît dans `bin/console list`** + + ```bash + docker exec php-lesstime-fpm php bin/console list app:mail 2>&1 + ``` + + Attendu : `app:mail:sync` visible. + +- [ ] **Step 6 : Tester manuellement (config désactivée en fixtures)** + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync + ``` + + Attendu : `Mail config disabled, skipping.` — exit code 0. + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + Attendu : message dry-run — exit code 0. + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Command/MailSyncCommand.php tests/Functional/Command/MailSyncCommandTest.php + git commit -m "feat(mail) : commande app:mail:sync avec options --folder et --dry-run" + ``` + +--- + +### Task 7 : Cible Makefile `make mail-sync` + +- [ ] **Step 1 : Ajouter le target dans `makefile`** + + Ouvrir `makefile` et ajouter la cible suivante juste avant la cible `wait:` (en fin de fichier), après le bloc `test:` : + + ```makefile + ## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min) + ## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX + ## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1 + mail-sync: + $(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,) + ``` + + > Attention : les lignes de recette Makefile doivent commencer par une tabulation (pas des espaces). + +- [ ] **Step 2 : Vérifier que la cible fonctionne** + + ```bash + make mail-sync + ``` + + Attendu : `Mail config disabled, skipping.` (config désactivée en fixtures). + + ```bash + make mail-sync DRYRUN=1 + ``` + + Attendu : message dry-run — exit code 0. + +- [ ] **Step 3 : Commit** + + ```bash + git add makefile + git commit -m "feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN" + ``` + +--- + +### Task 8 : Documentation cron OS + +- [ ] **Step 1 : Créer `docs/mail-cron-setup.md`** + + Créer le fichier `docs/mail-cron-setup.md` : + + ````markdown + # Mail Integration — Configuration cron OS + + ## Vue d'ensemble + + La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. + Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. + + Un Symfony Lock (`mail.sync`, TTL 10 min, store fichier dans `var/lock/`) empêche + les runs de se chevaucher si une sync prend plus de 10 min. + + ## Prérequis + + - Container `php-lesstime-fpm` démarré (`make start`) + - `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) + - `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) + + ## Installation du cron + + Sur la **machine hôte** (pas dans le container) : + + ```bash + crontab -e + ``` + + Ajouter la ligne suivante (adapter le chemin) : + + ```cron + */10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 + ``` + + Ou directement via `docker exec` (sans dépendance à `make`) : + + ```cron + */10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1 + ``` + + ### Avec un utilisateur système dédié + + Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) : + + ```bash + sudo crontab -u deploy -e + ``` + + ## Variables d'environnement nécessaires + + | Variable | Description | Exemple | + |---|---|---| + | `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | + + La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. + + ## Commandes utiles + + ```bash + # Sync complète (toutes les boîtes) + make mail-sync + + # Sync d'un seul dossier + make mail-sync FOLDER=INBOX + + # Simulation (dry-run, pas d'écriture BDD) + make mail-sync DRYRUN=1 + + # Directement dans le container + docker exec php-lesstime-fpm php bin/console app:mail:sync + docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + ## Logs + + Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production). + Suivre les logs en temps réel : + + ```bash + make logs-dev + ``` + + Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. + + ## Sécurité + + - Le password IMAP est **toujours stocké chiffré** (AES-256 via libsodium) + - Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** + - Le lock fichier évite les runs parallèles (chemin : `var/lock/mail.sync.lock`) + + ## Production + + En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.). + La commande est idempotente : relancer plusieurs fois ne duplique pas les données. + ```` + +- [ ] **Step 2 : Commit** + + ```bash + git add docs/mail-cron-setup.md + git commit -m "docs(mail) : guide configuration cron OS pour mail-sync" + ``` + +--- + +### Task 9 : Validation finale + +- [ ] **Step 1 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent, y compris : + - `tests/Unit/Mail/MailSyncReportTest.php` — 2 tests + - `tests/Unit/Mail/ImapMailProviderTest.php` — 2 tests + - `tests/Unit/Service/MailSyncServiceTest.php` — 4 tests + - `tests/Functional/Command/MailSyncCommandTest.php` — 2 tests + - Tous les tests Phase 1 préexistants + +- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass phase 2" + ``` + +- [ ] **Step 3 : Vider le cache Symfony** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de service manquant ou de configuration invalide. + +- [ ] **Step 4 : Vérifier l'autowiring des services** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -10 + docker exec php-lesstime-fpm php bin/console debug:autowiring ImapMailProvider 2>&1 | head -10 + docker exec php-lesstime-fpm php bin/console debug:autowiring LockFactory 2>&1 | head -10 + ``` + + Attendu : les trois services apparaissent comme autowirables. + +- [ ] **Step 5 : Test fonctionnel `app:mail:sync --dry-run`** + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + Attendu : sortie propre avec code 0. Aucune exception. + + ```bash + make mail-sync DRYRUN=1 + ``` + + Attendu : même résultat via Makefile. + +- [ ] **Step 6 : Vérifier `php bin/console list` montre la commande** + + ```bash + docker exec php-lesstime-fpm php bin/console list | grep mail + ``` + + Attendu : `app:mail:sync` visible. + +- [ ] **Step 7 : Résumé des commits de la phase** + + ```bash + git log --oneline feat/mail-integration ^develop | head -20 + ``` + + Commits attendus (en plus de ceux de Phase 1) : + + 1. `feat(mail) : install webklex/php-imap + symfony/lock, configure lock store` + 2. `feat(mail) : DTO MailSyncReport + test unitaire` + 3. `feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface` + 4. `feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder` + 5. `feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%` + 6. `feat(mail) : commande app:mail:sync avec options --folder et --dry-run` + 7. `feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN` + 8. `docs(mail) : guide configuration cron OS pour mail-sync` + +- [ ] **Step 8 : Pousser la branche et notifier le user** + + ```bash + git push origin feat/mail-integration + ``` + + Rapport final au user : + - Fichiers créés : 11 nouveaux fichiers (MailSyncReport, ImapMailProvider, MailSyncService, MailSyncCommand, 4 fichiers de tests, lock.yaml, docs/mail-cron-setup.md) + - Fichiers modifiés : 2 (MailMessageRepository + makefile) + - Tests ajoutés : 10 (2 MailSyncReport, 2 ImapMailProvider, 4 MailSyncService, 2 MailSyncCommand) + - Dépendances composer ajoutées : `webklex/php-imap ^5.0`, `symfony/lock 8.0.*` + - Commande disponible : `php bin/console app:mail:sync [--folder=...] [--dry-run]` + +--- + +### Self-Review + +#### Cohérence des noms + +| Concept | Classe PHP | Namespace | +|---|---|---| +| Provider IMAP | `ImapMailProvider` | `App\Mail` | +| Interface | `MailProviderInterface` | `App\Mail` | +| Rapport sync | `MailSyncReport` | `App\Mail\Dto` | +| Service sync | `MailSyncService` | `App\Service` | +| Commande | `MailSyncCommand` | `App\Command` | + +#### Checklist finale avant de valider Phase 2 + +- [ ] `declare(strict_types=1);` en tête de chaque fichier PHP créé +- [ ] `ImapMailProvider` n'est pas `final` (pourrait être mocké en Phase 3 si besoin) — ou bien si `final`, les tests utilisent `createMock(MailProviderInterface::class)` +- [ ] `MailSyncService` utilise `MailProviderInterface` (pas `ImapMailProvider`) → testable sans IMAP réel +- [ ] Lock `mail.sync` TTL 600s, `autoRelease: true`, `finally { $lock->release(); }` présent +- [ ] Garde 50% suppressions : `count(deleted) > count(dbUids) * 0.5` → abort + log warning + ajout dans `errors[]` +- [ ] Logger ne logue jamais body/password/attachment (vérifier chaque appel `$this->logger->*`) +- [ ] `TokenEncryptor::decrypt()` appelé uniquement dans `ImapMailProvider::getClient()`, password effacé via `sodium_memzero()` dans `finally` +- [ ] Tous les `catch (MailProviderException $e)` re-throw AVANT le `catch (Throwable $e)` générique +- [ ] `make test` vert (10 nouveaux tests minimum) +- [ ] `app:mail:sync --dry-run` exit code 0 +- [ ] `make mail-sync DRYRUN=1` fonctionne +- [ ] Phase 2 NE contient PAS : endpoints API, Messenger, frontend — tout ça = Phase 3+ +- [ ] Branche de travail : `feat/mail-integration` (pas `develop`) diff --git a/docs/superpowers/plans/2026-05-19-mail-phase3-api.md b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md new file mode 100644 index 0000000..aa77b14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md @@ -0,0 +1,2410 @@ +# Mail Integration — Phase 3 : API Backend + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Exposer l'ensemble des endpoints HTTP de l'intégration mail (config singleton admin, listing dossiers/messages, lecture body avec cache 5 min, actions read/flag/create-task/link-task, attachments stream, sync async via Messenger). + +**Architecture:** Singleton config via API Platform Provider/Processor (pattern Zimbra). Custom controllers Symfony pour endpoints "métier" (priority: 1 pour éviter conflit API Platform `{id}`). Sécurité stricte : `IS_AUTHENTICATED_FULLY` + `ROLE_USER` + check `!ROLE_CLIENT` explicite dans chaque endpoint (rappel : `User::getRoles()` n'ajoute pas `ROLE_USER` aux clients — la hiérarchie `ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]` de security.yaml s'applique aux ADMIN, pas aux ROLE_CLIENT pur). Sync manuelle dispatchée en async via Symfony Messenger (`MailSyncRequested` message). Body fetché live IMAP avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. + +**Tech Stack:** Symfony 8.0, API Platform 4.2, Symfony Messenger, Symfony Cache (pool `cache.app`, APCu ou filesystem selon config), LexikJWT pour l'auth (cookie BEARER). + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/ApiResource/MailSettings.php` | Créer | +| `src/State/Mail/MailSettingsProvider.php` | Créer | +| `src/State/Mail/MailSettingsProcessor.php` | Créer | +| `src/Controller/Mail/MailTestConnectionController.php` | Créer | +| `src/Security/MailAccessChecker.php` | Créer | +| `src/Controller/Mail/MailFoldersListController.php` | Créer | +| `src/Controller/Mail/MailMessagesListController.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findByFolderCursor`) | +| `src/Controller/Mail/MailMessageDetailController.php` | Créer | +| `src/Controller/Mail/MailMessageReadController.php` | Créer | +| `src/Controller/Mail/MailMessageFlagController.php` | Créer | +| `src/Controller/Mail/MailCreateTaskController.php` | Créer | +| `src/Controller/Mail/MailLinkTaskController.php` | Créer | +| `src/Controller/Mail/MailUnlinkTaskController.php` | Créer | +| `src/Controller/Mail/TaskMailsListController.php` | Créer | +| `src/Controller/Mail/MailAttachmentDownloadController.php` | Créer | +| `src/Message/MailSyncRequested.php` | Créer | +| `src/MessageHandler/MailSyncRequestedHandler.php` | Créer | +| `src/Controller/Mail/MailSyncTriggerController.php` | Créer | +| `config/packages/messenger.yaml` | Créer | +| `config/packages/security.yaml` | Modifier (ajout access_control mail) | +| `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailFoldersControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailMessagesControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php` | Créer | + +--- + +### Task 1 : Préparation — branche + dossiers + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers namespace** + + ```bash + mkdir -p src/State/Mail + mkdir -p src/Controller/Mail + mkdir -p src/Message + mkdir -p src/MessageHandler + mkdir -p src/Security + mkdir -p tests/Functional/Controller/Mail + ``` + +- [ ] **Step 3 : Vérifier que `src/Security/` existe déjà (ApiTokenAuthenticator)** + + ```bash + ls src/Security/ + ``` + + Attendu : `ApiTokenAuthenticator.php` présent. Le dossier existe déjà — pas besoin de le recréer. + +- [ ] **Step 4 : Vérifier l'autowiring de base** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfigurationRepository 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailProviderInterface 2>&1 | head -5 + ``` + + Attendu : les trois services sont présents (construits en Phase 1 et 2). + +--- + +### Task 2 : ApiResource `MailSettings` + Provider/Processor singleton + +L'objectif est d'exposer `GET /api/mail/configuration` et `PATCH /api/mail/configuration` avec le même pattern que `ZimbraSettings` (voir `src/ApiResource/ZimbraSettings.php`). Le password n'est **jamais** retourné en clair — seulement `hasPassword: bool`. + +- [ ] **Step 1 : Écrire le test fonctionnel (TDD — doit échouer)** + + Créer `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` : + + ```php + request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetConfigurationReturns200ForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + // Password jamais en clair + self::assertArrayNotHasKey('password', $data); + self::assertArrayNotHasKey('encryptedPassword', $data); + self::assertArrayHasKey('hasPassword', $data); + self::assertArrayHasKey('imapHost', $data); + self::assertArrayHasKey('enabled', $data); + } + + public function testPatchConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['enabled' => false]) + ); + + self::assertResponseStatusCodeSame(403); + } + + public function testPatchConfigurationUpdatesFieldsForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['imapHost' => 'imap.example.com', 'enabled' => false]) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame('imap.example.com', $data['imapHost']); + self::assertArrayNotHasKey('password', $data); + } + + public function testPatchConfigurationWithPasswordEncryptsIt(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['password' => 'secret123']) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertTrue($data['hasPassword']); + self::assertArrayNotHasKey('password', $data); + } + } + ``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : 404 (route non créée). + +- [ ] **Step 3 : Créer `src/ApiResource/MailSettings.php`** + + Calqué exactement sur `src/ApiResource/ZimbraSettings.php`, mais pour `MailConfiguration` : + + ```php + ['mail_settings:read']], + provider: MailSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + uriTemplate: '/mail/configuration', + denormalizationContext: ['groups' => ['mail_settings:write']], + normalizationContext: ['groups' => ['mail_settings:read']], + provider: MailSettingsProvider::class, + processor: MailSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], + )] + final class MailSettings + { + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $protocol = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $imapPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $smtpPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $username = null; + + /** Write-only: jamais retourné en lecture */ + #[Groups(['mail_settings:write'])] + public ?string $password = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $sentFolderPath = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public bool $enabled = false; + + /** Lecture seule : indique si un password chiffré existe */ + #[Groups(['mail_settings:read'])] + public bool $hasPassword = false; + } + ``` + +- [ ] **Step 4 : Créer `src/State/Mail/MailSettingsProvider.php`** + + ```php + configRepository->findSingleton(); + $dto = new MailSettings(); + + if (null !== $config) { + $dto->protocol = $config->getProtocol(); + $dto->imapHost = $config->getImapHost(); + $dto->imapPort = $config->getImapPort(); + $dto->imapEncryption = $config->getImapEncryption(); + $dto->smtpHost = $config->getSmtpHost(); + $dto->smtpPort = $config->getSmtpPort(); + $dto->smtpEncryption = $config->getSmtpEncryption(); + $dto->username = $config->getUsername(); + $dto->sentFolderPath = $config->getSentFolderPath(); + $dto->enabled = $config->isEnabled(); + $dto->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + } + + return $dto; + } + } + ``` + +- [ ] **Step 5 : Créer `src/State/Mail/MailSettingsProcessor.php`** + + ```php + configRepository->findSingleton(); + if (null === $config) { + $config = new MailConfiguration(); + } + + if (null !== $data->protocol) { + $config->setProtocol($data->protocol); + } + if (null !== $data->imapHost) { + $config->setImapHost($data->imapHost); + } + if (null !== $data->imapPort) { + $config->setImapPort($data->imapPort); + } + if (null !== $data->imapEncryption) { + $config->setImapEncryption($data->imapEncryption); + } + if (null !== $data->smtpHost) { + $config->setSmtpHost($data->smtpHost); + } + if (null !== $data->smtpPort) { + $config->setSmtpPort($data->smtpPort); + } + if (null !== $data->smtpEncryption) { + $config->setSmtpEncryption($data->smtpEncryption); + } + if (null !== $data->username) { + $config->setUsername($data->username); + } + if (null !== $data->sentFolderPath) { + $config->setSentFolderPath($data->sentFolderPath); + } + $config->setEnabled($data->enabled); + + // Password : seulement si fourni non-vide + if (null !== $data->password && '' !== $data->password) { + $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new MailSettings(); + $result->protocol = $config->getProtocol(); + $result->imapHost = $config->getImapHost(); + $result->imapPort = $config->getImapPort(); + $result->imapEncryption = $config->getImapEncryption(); + $result->smtpHost = $config->getSmtpHost(); + $result->smtpPort = $config->getSmtpPort(); + $result->smtpEncryption = $config->getSmtpEncryption(); + $result->username = $config->getUsername(); + $result->sentFolderPath = $config->getSentFolderPath(); + $result->enabled = $config->isEnabled(); + $result->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + + return $result; + } + } + ``` + +- [ ] **Step 6 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/ApiResource/MailSettings.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProvider.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProcessor.php + ``` + + Attendu : `No syntax errors detected` pour les trois. + +- [ ] **Step 7 : Vider le cache et relancer les tests** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 8 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/ApiResource/MailSettings.php src/State/Mail/ tests/Functional/Controller/Mail/MailSettingsControllerTest.php + git commit -m "feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)" + ``` + +--- + +### Task 3 : Custom controller `MailTestConnectionController` + +Endpoint : `POST /api/mail/configuration/test` (ROLE_ADMIN uniquement). Instancie `ImapMailProvider` via injection, appelle `listFolders()`, retourne `{ ok: true, foldersCount: N }` ou `{ ok: false, error: "..." }` sans leak du message d'erreur interne. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailTestConnectionController.php`** + + ```php + mailProvider->listFolders(); + + return $this->json([ + 'ok' => true, + 'foldersCount' => count($folders), + ]); + } catch (MailProviderException $e) { + return $this->json([ + 'ok' => false, + 'error' => 'Connexion IMAP impossible. Vérifiez la configuration.', + ], 200); // 200 intentionnel : c'est un résultat de test, pas une erreur HTTP + } catch (Throwable) { + return $this->json([ + 'ok' => false, + 'error' => 'Erreur inattendue lors du test de connexion.', + ], 200); + } + } + } + ``` + + > Note : `priority: 1` est **obligatoire** pour éviter le conflit avec la route API Platform `{id}` qui capte tous les paths sous `/api/mail/`. + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailTestConnectionController.php + ``` + +- [ ] **Step 3 : Vérifier que la route est enregistrée** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_configuration_test + ``` + + Attendu : route `mail_configuration_test` visible avec méthode POST. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailTestConnectionController.php + git commit -m "feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test" + ``` + +--- + +### Task 4 : Service `MailAccessChecker` + +Service réutilisable par tous les controllers "métier" mail. Vérifie que l'utilisateur est `ROLE_USER` ou `ROLE_ADMIN` ET n'est pas `ROLE_CLIENT` pur (sans `ROLE_ADMIN`). Rappel : la hiérarchie de sécurité donne `ROLE_USER` aux ADMIN, mais un `ROLE_CLIENT` pur n'a pas `ROLE_USER`. + +- [ ] **Step 1 : Créer `src/Security/MailAccessChecker.php`** + + ```php + getRoles(); + + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } + } + + /** + * Vérifie que l'utilisateur est ROLE_ADMIN. + * + * @throws AccessDeniedException + */ + public function ensureIsAdmin(?UserInterface $user): void + { + if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Admin only'); + } + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Security/MailAccessChecker.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Security/MailAccessChecker.php + git commit -m "feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)" + ``` + +--- + +### Task 5 : Custom controller `MailFoldersListController` + +Endpoint : `GET /api/mail/folders`. Lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec `unreadCount`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailFoldersControllerTest.php`** + + ```php + request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(401); + } + + public function testListFoldersReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListFoldersReturns200ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertIsArray($data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailFoldersListController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + $folders = $this->folderRepository->findAllOrderedByPath(); + + $data = array_map(static fn ($folder) => [ + 'id' => $folder->getId(), + 'path' => $folder->getPath(), + 'displayName' => $folder->getDisplayName(), + 'parentPath' => $folder->getParentPath(), + 'unreadCount' => $folder->getUnreadCount(), + 'totalCount' => $folder->getTotalCount(), + 'lastSyncedAt' => $folder->getLastSyncedAt()?->format(\DateTimeInterface::ATOM), + ], $folders); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailFoldersListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_folders_list + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailFoldersControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailFoldersListController.php tests/Functional/Controller/Mail/MailFoldersControllerTest.php + git commit -m "feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)" + ``` + +--- + +### Task 6 : Custom controller `MailMessagesListController` + pagination cursor + +Endpoint : `GET /api/mail/folders/{folderPath}/messages?cursor=&limit=50`. Pagination cursor `sentAt DESC, id DESC`. `folderPath` est URL-encodé. + +- [ ] **Step 1 : Ajouter `findByFolderCursor` dans `MailMessageRepository`** + + Ouvrir `src/Repository/MailMessageRepository.php` et ajouter : + + ```php + /** + * Pagination cursor: retourne `$limit` messages après le cursor `sentAt DESC, id DESC`. + * Cursor format: "sentAt_iso8601:id" — null = première page. + * + * @return array{ messages: list, nextCursor: ?string } + */ + public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array + { + $qb = $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit + 1); // +1 pour détecter s'il y a une page suivante + + if (null !== $cursor) { + // cursor = base64url(sentAt_iso:id) + $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); + if (false !== $decoded && str_contains($decoded, ':')) { + [$sentAtStr, $idStr] = explode(':', $decoded, 2); + $cursorSentAt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $sentAtStr); + $cursorId = (int) $idStr; + + if ($cursorSentAt instanceof \DateTimeImmutable) { + $qb + ->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)') + ->setParameter('cursorSentAt', $cursorSentAt) + ->setParameter('cursorId', $cursorId); + } + } + } + + /** @var list $results */ + $results = $qb->getQuery()->getResult(); + $hasMore = count($results) > $limit; + $messages = $hasMore ? array_slice($results, 0, $limit) : $results; + $nextCursor = null; + + if ($hasMore && [] !== $messages) { + $last = end($messages); + $raw = $last->getSentAt()->format(\DateTimeInterface::ATOM).':'.$last->getId(); + $nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + return ['messages' => $messages, 'nextCursor' => $nextCursor]; + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessagesListController.php`** + + ```php + '.+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessagesListController extends AbstractController + { + public function __construct( + private readonly MailFolderRepository $folderRepository, + private readonly MailMessageRepository $messageRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, string $folderPath): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + // folderPath peut être URL-encodé si contient des slashes + $decodedPath = urldecode($folderPath); + + $folder = $this->folderRepository->findByPath($decodedPath); + if (null === $folder) { + throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath)); + } + + $limit = min((int) ($request->query->get('limit', 50)), 100); + $cursor = $request->query->get('cursor'); + + $result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null); + + $messages = array_map(static fn ($m) => [ + 'id' => $m->getId(), + 'messageId' => $m->getMessageId(), + 'uid' => $m->getUid(), + 'subject' => $m->getSubject(), + 'fromAddress' => $m->getFromAddress(), + 'fromName' => $m->getFromName(), + 'toAddresses' => $m->getToAddresses(), + 'ccAddresses' => $m->getCcAddresses(), + 'sentAt' => $m->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $m->isRead(), + 'isFlagged' => $m->isFlagged(), + 'hasAttachments' => $m->hasAttachments(), + 'snippet' => $m->getSnippet(), + ], $result['messages']); + + return $this->json([ + 'messages' => $messages, + 'nextCursor' => $result['nextCursor'], + ]); + } + } + ``` + + > Note : `requirements: ['folderPath' => '.+']` permet à `folderPath` de contenir des slashes (ex. `INBOX/Subfolder`). Les slashes seront capturés dans le paramètre de route. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessagesListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_messages_list + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessagesListController.php src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)" + ``` + +--- + +### Task 7 : Custom controller `MailMessageDetailController` (body live + cache 5 min) + +Endpoint : `GET /api/mail/messages/{id}`. Fetche le corps du mail en live IMAP via `ImapMailProvider::fetchMessage()` avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. L'`{id}` est l'id BDD du `MailMessage`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailMessagesControllerTest.php`** + + ```php + request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetMessageDetailReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetMessageDetailReturns404WhenMessageNotFound(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/messages/99999'); + + self::assertResponseStatusCodeSame(404); + } + + public function testMarkReadReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(401); + } + + public function testMarkReadReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(403); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageDetailController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageDetailController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly MailAccessChecker $accessChecker, + private readonly CacheItemPoolInterface $cache, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Cache key basé sur messageId (md5 pour sanitiser les caractères spéciaux) + $cacheKey = 'mail_body_' . md5($message->getMessageId()); + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + $item->set($detail); + $item->expiresAfter(300); // 5 minutes + $this->cache->save($item); + } catch (MailProviderException $e) { + throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body'); + } + } + + $detail = $item->get(); + + // Sérialiser les attachments (sans contenu binaire — seulement metadata) + $attachments = array_map(static fn ($att) => [ + 'partNumber' => $att->partNumber, + 'filename' => $att->filename, + 'mimeType' => $att->mimeType, + 'size' => $att->size, + // URL téléchargement : /api/mail/attachments/{messageId_base64}:{partNumber_base64} + 'downloadId' => rtrim(strtr(base64_encode($message->getId().':'.$att->partNumber), '+/', '-_'), '='), + ], $detail->attachments); + + return $this->json([ + 'id' => $message->getId(), + 'messageId' => $message->getMessageId(), + 'uid' => $message->getUid(), + 'folderPath' => $message->getFolder()->getPath(), + 'subject' => $detail->header->subject, + 'fromAddress' => $detail->header->fromAddress, + 'fromName' => $detail->header->fromName, + 'toAddresses' => $detail->header->toAddresses, + 'ccAddresses' => $detail->header->ccAddresses, + 'sentAt' => $detail->header->sentAt->format(\DateTimeInterface::ATOM), + 'isRead' => $message->isRead(), + 'isFlagged' => $message->isFlagged(), + 'hasAttachments' => $message->hasAttachments(), + 'bodyHtml' => $detail->bodyHtml, // ATTENTION : côté frontend, sanitiser via DOMPurify + 'bodyText' => $detail->bodyText, + 'attachments' => $attachments, + ]); + } + } + ``` + + > Note sur le `CacheItemPoolInterface` : Symfony injectera le pool par défaut `cache.app`. Aucune configuration supplémentaire nécessaire — `cache.app` est défini dans le framework par défaut. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageDetailController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_message_detail + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailMessagesControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : les tests 401/403/404 passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageDetailController.php tests/Functional/Controller/Mail/MailMessagesControllerTest.php + git commit -m "feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)" + ``` + +--- + +### Task 8 : Custom controllers `MailMessageReadController` + `MailMessageFlagController` + +Endpoints : `POST /api/mail/messages/{id}/read` (body `{ "read": bool }`) et `POST /api/mail/messages/{id}/flag` (body `{ "flagged": bool }`). Appelle le provider IMAP + met à jour la BDD. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailMessageReadController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageReadController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $read = (bool) ($body['read'] ?? true); + + try { + $this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read); + } catch (MailProviderException) { + // Non-bloquant : on met quand même à jour la BDD (sync IMAP au prochain cycle) + } + + $message->setIsRead($read); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageFlagController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageFlagController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $flagged = (bool) ($body['flagged'] ?? true); + + try { + $this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged); + } catch (MailProviderException) { + // Non-bloquant + } + + $message->setIsFlagged($flagged); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageReadController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageFlagController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageReadController.php src/Controller/Mail/MailMessageFlagController.php + git commit -m "feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag" + ``` + +--- + +### Task 9 : Custom controller `MailCreateTaskController` + +Endpoint : `POST /api/mail/messages/{id}/create-task` (body `{ "projectId": 1, "taskGroupId": null, "priorityId": null }`). Crée une `Task` Doctrine (via `TaskNumberProcessor` si applicable, sinon directement), crée `TaskMailLink`, retourne l'id de la tâche créée. + +- [ ] **Step 1 : Vérifier si `TaskNumberProcessor` ou un `TaskService` existe** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring TaskNumber 2>&1 | head -10 + ls /home/r-dev/malio-dev/Lesstime/src/State/ | grep -i task + ls /home/r-dev/malio-dev/Lesstime/src/Service/ | grep -i task + ``` + + Selon le résultat : + - Si `TaskNumberProcessor` est un `State/Processor` API Platform (pas un service injectable) → créer la Task directement avec persist+flush, sans passer par le processor. + - Si un `TaskService` injectable existe → l'utiliser. + + Dans tous les cas, le numéro de tâche doit suivre la convention du projet (incrément par projet). Vérifier comment `Task::number` est assigné dans l'existant et reproduire le même mécanisme. + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailCreateTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailCreateTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + private readonly TaskRepository $taskRepository, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $projectId = $body['projectId'] ?? null; + + if (null === $projectId) { + throw new UnprocessableEntityHttpException('projectId is required'); + } + + $project = $this->em->getRepository(\App\Entity\Project::class)->find($projectId); + if (null === $project) { + throw new NotFoundHttpException('Project not found'); + } + + // Numéro de tâche : max existant + 1 pour ce projet + $maxNumber = $this->taskRepository->findMaxNumberForProject($project); + $number = $maxNumber + 1; + + // Titre = subject du mail (tronqué à 500 chars si nécessaire) + $title = $message->getSubject() ?? 'Mail sans sujet'; + if (mb_strlen($title) > 500) { + $title = mb_substr($title, 0, 497).'...'; + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($number); + $task->setCreatedAt(new DateTimeImmutable()); + + // TaskGroup optionnel + if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) { + $taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']); + if (null !== $taskGroup) { + $task->setTaskGroup($taskGroup); + } + } + + // Priorité optionnelle + if (isset($body['priorityId']) && null !== $body['priorityId']) { + $priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']); + if (null !== $priority) { + $task->setPriority($priority); + } + } + + // Créateur = user courant + $task->setCreatedBy($this->getUser()); + + $this->em->persist($task); + + // Lien mail ↔ tâche + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + + $this->em->flush(); + + return $this->json([ + 'taskId' => $task->getId(), + 'taskNumber' => $task->getNumber(), + 'taskTitle' => $task->getTitle(), + 'messageId' => $message->getId(), + ], 201); + } + } + ``` + + > **Note importante :** `Task::setNumber()`, `Task::setCreatedAt()`, `Task::setCreatedBy()`, etc. : vérifier les setters réels de l'entité `Task` avant de coder. Si des champs obligatoires manquent (ex. `status`), injecter le repository correspondant et récupérer le statut par défaut ("À faire" ou premier statut du projet). Adapter selon la structure réelle de `Task`. + + > **`TaskRepository::findMaxNumberForProject`** : vérifier si cette méthode existe. Sinon la créer : + > ```php + > public function findMaxNumberForProject(Project $project): int + > { + > $result = $this->createQueryBuilder('t') + > ->select('MAX(t.number)') + > ->andWhere('t.project = :project') + > ->setParameter('project', $project) + > ->getQuery() + > ->getSingleScalarResult(); + > return (int) ($result ?? 0); + > } + > ``` + +- [ ] **Step 3 : Vérifier la syntaxe et adapter les setters Task** + + ```bash + # Vérifier les champs obligatoires de Task + docker exec php-lesstime-fpm php bin/console doctrine:mapping:info 2>&1 | grep Task + cat src/Entity/Task.php | grep 'private\|public' | head -40 + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailCreateTaskController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailCreateTaskController.php src/Repository/TaskRepository.php + git commit -m "feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task" + ``` + +--- + +### Task 10 : Custom controllers `MailLinkTaskController` + `MailUnlinkTaskController` + `TaskMailsListController` + +Trois endpoints liés à la gestion des liens mail ↔ tâche : +- `POST /api/mail/messages/{id}/link-task` (body `{ "taskId": 1 }`) +- `DELETE /api/mail/messages/{id}/link-task/{taskId}` +- `GET /api/tasks/{id}/mails` + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailLinkTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailLinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $taskId = $body['taskId'] ?? null; + + if (null === $taskId) { + throw new UnprocessableEntityHttpException('taskId is required'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + // Éviter le doublon (contrainte unique en BDD, mais prévenir le 500) + $existing = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null !== $existing) { + return $this->json(['message' => 'Already linked'], 200); + } + + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + $this->em->flush(); + + return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailUnlinkTaskController.php`** + + ```php + '\d+', 'taskId' => '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailUnlinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id, int $taskId): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $link = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null === $link) { + throw new NotFoundHttpException('Link not found'); + } + + $this->em->remove($link); + $this->em->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + } + ``` + +- [ ] **Step 3 : Créer `src/Controller/Mail/TaskMailsListController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class TaskMailsListController extends AbstractController + { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly TaskMailLinkRepository $linkRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $task = $this->em->getRepository(Task::class)->find($id); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $links = $this->linkRepository->findByTask($task); + + $data = array_map(static fn ($link) => [ + 'id' => $link->getMailMessage()->getId(), + 'messageId' => $link->getMailMessage()->getMessageId(), + 'subject' => $link->getMailMessage()->getSubject(), + 'fromAddress' => $link->getMailMessage()->getFromAddress(), + 'fromName' => $link->getMailMessage()->getFromName(), + 'sentAt' => $link->getMailMessage()->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $link->getMailMessage()->isRead(), + 'isFlagged' => $link->getMailMessage()->isFlagged(), + 'snippet' => $link->getMailMessage()->getSnippet(), + 'linkedAt' => $link->getLinkedAt()->format(\DateTimeInterface::ATOM), + ], $links); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 4 : Créer le test fonctionnel pour l'intégration tâches** + + Créer `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` : + + ```php + request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + + public function testLinkTaskReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnlinkTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('DELETE', '/api/mail/messages/1/link-task/1'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + } + ``` + +- [ ] **Step 5 : Vérifier la syntaxe des 3 controllers** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailLinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailUnlinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/TaskMailsListController.php + make cache-clear + ``` + +- [ ] **Step 6 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php 2>&1 | tail -15 + ``` + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailLinkTaskController.php src/Controller/Mail/MailUnlinkTaskController.php src/Controller/Mail/TaskMailsListController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php + git commit -m "feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers" + ``` + +--- + +### Task 11 : Custom controller `MailAttachmentDownloadController` + +Endpoint : `GET /api/mail/attachments/{downloadId}` où `downloadId` est un `base64url(messageDbId:partNumber)`. Fetch via `ImapMailProvider::fetchAttachment()`, stream avec `Content-Disposition: attachment` (jamais inline). Force le `Content-Type` exact. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailAttachmentDownloadController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Décoder le downloadId : base64url(messageDbId:partNumber) + $decoded = base64_decode(strtr($downloadId, '-_', '+/'), true); + if (false === $decoded || !str_contains($decoded, ':')) { + throw new BadRequestHttpException('Invalid attachment ID format'); + } + + [$messageDbIdStr, $partNumber] = explode(':', $decoded, 2); + $messageDbId = (int) $messageDbIdStr; + + $message = $this->messageRepository->find($messageDbId); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Trouver les métadonnées de l'attachment dans le detail (via cache ou live) + // Pour l'instant, fetch live (le cache body sera utilisé si disponible) + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch message from IMAP server'); + } + + // Trouver le bon attachment par partNumber + $targetAttachment = null; + foreach ($detail->attachments as $att) { + if ($att->partNumber === $partNumber) { + $targetAttachment = $att; + break; + } + } + + if (null === $targetAttachment) { + throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber)); + } + + // Fetch le contenu binaire + try { + $content = $this->mailProvider->fetchAttachment( + $message->getFolder()->getPath(), + $message->getUid(), + $partNumber + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch attachment content'); + } + + // Sanitiser le nom de fichier (éviter path traversal) + $filename = basename($targetAttachment->filename); + if ('' === $filename || '.' === $filename) { + $filename = 'attachment'; + } + + // Toujours Content-Disposition: attachment (jamais inline — risque XSS via HTML attachments) + $response = new Response($content); + $response->headers->set('Content-Type', $targetAttachment->mimeType); + $response->headers->set( + 'Content-Disposition', + sprintf('attachment; filename="%s"', addslashes($filename)) + ); + $response->headers->set('Content-Length', (string) strlen($content)); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailAttachmentDownloadController.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailAttachmentDownloadController.php + git commit -m "feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)" + ``` + +--- + +### Task 12 : Message + Handler Symfony Messenger `MailSyncRequested` + +Crée le message `MailSyncRequested` et son handler, configure `messenger.yaml` (transport async), puis crée le controller trigger. + +- [ ] **Step 1 : Créer `src/Message/MailSyncRequested.php`** + + ```php + folderPath) { + $folder = $this->folderRepository->findByPath($message->folderPath); + if (null !== $folder) { + $report = $this->mailSyncService->syncFolder($folder); + $this->logger->info(sprintf( + 'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted', + $message->folderPath, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + )); + } else { + $this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath)); + } + } else { + $report = $this->mailSyncService->syncAll(); + $this->logger->info(sprintf( + 'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned', + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + } + } catch (Throwable $e) { + $this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage()); + // Ne pas throw — éviter la retry infinie en cas d'erreur IMAP permanente + } + } + } + ``` + +- [ ] **Step 3 : Créer `config/packages/messenger.yaml`** + + ```yaml + framework: + messenger: + # Failure transport : messages en échec stockés pour retry manuel + failure_transport: failed + + transports: + # Transport synchrone (défaut pour les messages non routés) + sync: + dsn: 'sync://' + + # Transport async via Doctrine (DBAL) + async: + dsn: 'doctrine://default?auto_setup=0' + options: + queue_name: default + retry_strategy: + max_retries: 3 + delay: 1000 + multiplier: 2 + max_delay: 0 + + # Transport pour les messages en échec + failed: + dsn: 'doctrine://default?queue_name=failed&auto_setup=0' + + routing: + # MailSyncRequested est dispatché en async pour retour 202 immédiat + 'App\Message\MailSyncRequested': async + ``` + + > Note : `auto_setup=0` signifie que la table `messenger_messages` doit être créée manuellement (via `php bin/console messenger:setup-transports`). Appeler cette commande en Task suivante. Si le projet n'a pas encore de table Messenger, utiliser `auto_setup=true` pour le développement. + +- [ ] **Step 4 : Setup des transports Messenger** + + ```bash + docker exec php-lesstime-fpm php bin/console messenger:setup-transports + ``` + + Attendu : table `messenger_messages` créée en BDD. + + > Si erreur "table déjà existante" → déjà configuré, ignorer. + +- [ ] **Step 5 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Message/MailSyncRequested.php + docker exec php-lesstime-fpm php -l src/MessageHandler/MailSyncRequestedHandler.php + make cache-clear + ``` + +- [ ] **Step 6 : Vérifier que le handler est enregistré** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:messenger 2>&1 | grep Mail + ``` + + Attendu : `App\Message\MailSyncRequested` → `App\MessageHandler\MailSyncRequestedHandler`. + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Message/MailSyncRequested.php src/MessageHandler/MailSyncRequestedHandler.php config/packages/messenger.yaml + git commit -m "feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine" + ``` + +--- + +### Task 13 : Custom controller `MailSyncTriggerController` + +Endpoint : `POST /api/mail/sync`. Dispatch `MailSyncRequested` au bus Messenger, retourne `202 Accepted` immédiat. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php`** + + ```php + request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSyncTriggerReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(403); + } + + public function testSyncTriggerReturns202ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('POST', '/api/mail/sync'); + + // 202 Accepted : le message est dispatché, la sync se fait en async + self::assertResponseStatusCodeSame(202); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('message', $data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailSyncTriggerController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Optionnel : folder spécifique via body JSON + $body = json_decode($request->getContent(), true) ?? []; + $folderPath = $body['folderPath'] ?? null; + + $this->bus->dispatch(new MailSyncRequested($folderPath)); + + return $this->json( + ['message' => 'Synchronisation démarrée en arrière-plan'], + Response::HTTP_ACCEPTED // 202 + ); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailSyncTriggerController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_sync_trigger + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : les trois tests passent (401, 403, 202). + + > Note : en environnement de test, Symfony Messenger utilise le transport `sync://` par défaut (via la config `when@test`). Le message sera traité synchronement — c'est attendu et correct pour les tests fonctionnels. Ajouter dans `config/packages/test/messenger.yaml` si nécessaire : + > + > ```yaml + > framework: + > messenger: + > transports: + > async: + > dsn: 'in-memory://' + > ``` + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailSyncTriggerController.php tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php + git commit -m "feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)" + ``` + +--- + +### Task 14 : Sécurité globale — `security.yaml` + access_control + +Ajouter les règles `access_control` dans `config/packages/security.yaml` pour que `^/api/mail` requiert `IS_AUTHENTICATED_FULLY` au niveau firewall (en plus des checks explicites dans les controllers). La route admin config requiert `ROLE_ADMIN` (géré via API Platform `security: "is_granted('ROLE_ADMIN')"`). + +- [ ] **Step 1 : Modifier `config/packages/security.yaml`** + + Ouvrir `config/packages/security.yaml` et localiser le bloc `access_control`. Ajouter les lignes mail **avant** la règle générique `^/api` : + + ```yaml + access_control: + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + # Version de l'application en public + - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } + # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + ``` + + > Note : La règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` bloque les requêtes non authentifiées avant même d'atteindre les controllers. Les vérifications fines ROLE_USER vs ROLE_CLIENT sont faites dans `MailAccessChecker` injecté dans chaque controller. + +- [ ] **Step 2 : Vider le cache et vérifier** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:config security access_control 2>&1 | head -20 + ``` + + Attendu : règle `^/api/mail` visible. + +- [ ] **Step 3 : Test rapide 401 sans auth** + + ```bash + curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/folders + ``` + + Attendu : `401`. + +- [ ] **Step 4 : Commit** + + ```bash + git add config/packages/security.yaml + git commit -m "feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)" + ``` + +--- + +### Task 15 : Validation finale + tests complets + +- [ ] **Step 1 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent, y compris : + - Phase 1 : `MailConfigurationRepositoryTest` (2 tests) + - Phase 2 : `MailSyncReportTest`, `ImapMailProviderTest`, `MailSyncServiceTest`, `MailSyncCommandTest` (10 tests) + - Phase 3 : `MailSettingsControllerTest`, `MailFoldersControllerTest`, `MailMessagesControllerTest`, `MailTaskIntegrationControllerTest`, `MailSyncTriggerControllerTest` + +- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass phase 3" + ``` + +- [ ] **Step 3 : Vider le cache et vérifier le container** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:container 2>&1 | grep -i "mail" | head -20 + ``` + + Attendu : `MailAccessChecker`, `MailSettingsProvider`, `MailSettingsProcessor`, tous les controllers Mail visibles. + +- [ ] **Step 4 : Vérifier les routes enregistrées** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep -E "mail|task_mails" + ``` + + Attendu (routes minimum) : + + | Route name | Method | Path | + |---|---|---| + | `mail_configuration_test` | POST | `/api/mail/configuration/test` | + | `mail_folders_list` | GET | `/api/mail/folders` | + | `mail_messages_list` | GET | `/api/mail/folders/{folderPath}/messages` | + | `mail_message_detail` | GET | `/api/mail/messages/{id}` | + | `mail_message_read` | POST | `/api/mail/messages/{id}/read` | + | `mail_message_flag` | POST | `/api/mail/messages/{id}/flag` | + | `mail_create_task` | POST | `/api/mail/messages/{id}/create-task` | + | `mail_link_task` | POST | `/api/mail/messages/{id}/link-task` | + | `mail_unlink_task` | DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | + | `task_mails_list` | GET | `/api/tasks/{id}/mails` | + | `mail_attachment_download` | GET | `/api/mail/attachments/{downloadId}` | + | `mail_sync_trigger` | POST | `/api/mail/sync` | + + + Les routes API Platform pour `MailSettings` : + - `GET /api/mail/configuration` + - `PATCH /api/mail/configuration` + +- [ ] **Step 5 : Smoke test curl avec token admin** + + ```bash + # Test 401 sans auth + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/api/mail/folders + # Attendu : 401 + + # Test avec JWT (récupérer le cookie après login) + curl -s -c /tmp/cookies.txt -X POST http://localhost:8082/login_check \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' | jq . + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/configuration | jq . + # Attendu : JSON avec hasPassword, imapHost, etc. — pas de "password" en clair + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/folders | jq . + # Attendu : [] (aucun dossier en fixtures — normal) + + curl -s -b /tmp/cookies.txt -X POST http://localhost:8082/api/mail/sync | jq . + # Attendu : 202 + { "message": "Synchronisation démarrée en arrière-plan" } + ``` + +- [ ] **Step 6 : Résumé des commits de la phase** + + ```bash + git log --oneline feat/mail-integration ^develop | head -30 + ``` + + Commits attendus (en plus de ceux des phases 1 et 2) : + + 1. `feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)` + 2. `feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test` + 3. `feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)` + 4. `feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)` + 5. `feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)` + 6. `feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)` + 7. `feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag` + 8. `feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task` + 9. `feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers` + 10. `feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)` + 11. `feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine` + 12. `feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)` + 13. `feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)` + +- [ ] **Step 7 : Pousser la branche et notifier le user** + + ```bash + git push origin feat/mail-integration + ``` + + Rapport final au user : + - Fichiers créés : 26 nouveaux fichiers + - Fichiers modifiés : 3 (`MailMessageRepository`, `TaskRepository`, `security.yaml`) + - Endpoints exposés : 14 (2 API Platform + 12 custom controllers) + - Tests ajoutés : 5 fichiers de tests fonctionnels + - Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0) + - Prêt pour Phase 4 : Frontend services + Pinia store + +--- + +### Exigences techniques — Rappels + +- **Sécurité** : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur tous les controllers + `MailAccessChecker::ensureCanAccessMail()` au début de chaque action ROLE_USER + `ensureIsAdmin()` pour config. Voir spec complète ci-dessous. +- **Password** : jamais retourné en clair, même pour admin. Réponse GET config : `{ ..., hasPassword: true|false }`. PATCH avec password vide ou null = pas de changement. +- **Format réponses** : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422). +- **Strict types** : `declare(strict_types=1);` en tête de chaque fichier PHP. +- **Format commit** : `feat(mail) : ` (espace avant `:`) +- **Custom controllers** : `#[Route(path: '/api/mail/...', methods: ['...'], priority: 1)]` — obligatoire pour éviter conflit API Platform. +- **Cache key** : `mail_body_{md5($messageId)}` — md5 sanitise les caractères spéciaux du Message-ID. +- **Messenger** : bus `messenger.bus.default` (injectable via `MessageBusInterface`). Transport `async` via `doctrine://default`. Setup : `php bin/console messenger:setup-transports`. +- **TaskService** : si `TaskNumberProcessor` existe uniquement en tant que `State/Processor` API Platform (non-injectable), créer la Task directement avec persist+flush + `findMaxNumberForProject`. Vérifier les champs obligatoires de `Task` avant de coder. +- **Tests** : étendre `WebTestCase` + `$client->loginUser($user)` + mocker `MailProviderInterface` via `static::getContainer()->set()` pour les tests qui touchent IMAP en live. +- **Logs** : OK de logger les actions (user → action → message-id), **JAMAIS** body/password/attachments. + +### Spec sécurité explicite `MailAccessChecker` + +```php +// src/Security/MailAccessChecker.php +public function ensureCanAccessMail(?UserInterface $user): void +{ + if (!$user instanceof User) { + throw new AccessDeniedException('Authentication required'); + } + $roles = $user->getRoles(); + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } +} + +public function ensureIsAdmin(?UserInterface $user): void +{ + if (!$user instanceof User || !in_array('ROLE_ADMIN', $user->getRoles(), true)) { + throw new AccessDeniedException('Admin only'); + } +} +``` + +### Spec body cache + +```php +// Dans MailMessageDetailController +$cacheKey = 'mail_body_' . md5($message->getMessageId()); +$item = $this->cache->getItem($cacheKey); +if (!$item->isHit()) { + $detail = $this->mailProvider->fetchMessage($folderPath, $uid); + $item->set($detail); + $item->expiresAfter(300); // 5 min + $this->cache->save($item); +} +$detail = $item->get(); +``` + +Pool : `cache.app` (injecté via `CacheItemPoolInterface` — Symfony le résout automatiquement). + +--- + +### Self-Review + +#### Table de correspondance endpoints / controllers + +| Endpoint | Controller / Resource | Sécurité | +|---|---|---| +| `GET /api/mail/configuration` | `MailSettings` ApiResource (Provider) | `ROLE_ADMIN` | +| `PATCH /api/mail/configuration` | `MailSettings` ApiResource (Processor) | `ROLE_ADMIN` | +| `POST /api/mail/configuration/test` | `MailTestConnectionController` | `ROLE_ADMIN` | +| `GET /api/mail/folders` | `MailFoldersListController` | `MailAccessChecker` (ROLE_USER/ADMIN, pas CLIENT) | +| `GET /api/mail/folders/{path}/messages` | `MailMessagesListController` | idem | +| `GET /api/mail/messages/{id}` | `MailMessageDetailController` | idem | +| `POST /api/mail/messages/{id}/read` | `MailMessageReadController` | idem | +| `POST /api/mail/messages/{id}/flag` | `MailMessageFlagController` | idem | +| `POST /api/mail/messages/{id}/create-task` | `MailCreateTaskController` | idem | +| `POST /api/mail/messages/{id}/link-task` | `MailLinkTaskController` | idem | +| `DELETE /api/mail/messages/{id}/link-task/{taskId}` | `MailUnlinkTaskController` | idem | +| `GET /api/tasks/{id}/mails` | `TaskMailsListController` | idem | +| `GET /api/mail/attachments/{downloadId}` | `MailAttachmentDownloadController` | idem | +| `POST /api/mail/sync` | `MailSyncTriggerController` | idem | + +#### Checklist finale avant de valider Phase 3 + +- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé +- [ ] Tous les custom controllers ont `priority: 1` sur leur `#[Route]` +- [ ] `MailAccessChecker` injecté et appelé dans chaque controller "métier" (hors admin) +- [ ] `password` jamais sérialisé en sortie (groupe `mail_settings:read` ne contient pas `password`) +- [ ] `hasPassword` retourné en lecture (bool uniquement) +- [ ] Cache key utilise `md5()` pour sanitiser le `messageId` (peut contenir `<>`, `@`, espaces) +- [ ] `Content-Disposition: attachment` systématique pour `MailAttachmentDownloadController` (jamais inline) +- [ ] `X-Content-Type-Options: nosniff` sur les réponses d'attachments +- [ ] Messenger configuré : transport `async` → `doctrine://default`, `MailSyncRequested` routé vers `async` +- [ ] `php bin/console messenger:setup-transports` exécuté (table `messenger_messages` créée) +- [ ] `security.yaml` : règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` ajoutée AVANT `^/api` +- [ ] ROLE_CLIENT refusé sur 100% des endpoints mail (test 403 présent dans chaque test file) +- [ ] Aucun body/password/attachment loggé dans les controllers ou handlers +- [ ] `make test` vert +- [ ] Smoke tests curl OK (401 sans auth, JSON correct avec admin, 202 sync trigger) +- [ ] Phase 3 NE contient PAS de frontend — tout ça = Phase 4+ +- [ ] Branche de travail : `feat/mail-integration` (pas `develop`) + +#### Remise à la review humaine + +Une fois tous les steps cochés, pousser la branche et notifier le user : + +```bash +git push origin feat/mail-integration +``` + +Indiquer au user : +- Endpoints créés : 14 (2 API Platform singleton + 12 custom controllers) +- Fichiers créés : 26 +- Fichiers modifiés : 3 +- Tests fonctionnels ajoutés : 5 fichiers (~20 assertions) +- Dépendances ajoutées : aucune (Messenger + Cache inclus dans Symfony 8.0) +- Prêt pour Phase 4 : Frontend services + Pinia store + DOMPurify diff --git a/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md new file mode 100644 index 0000000..2537255 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md @@ -0,0 +1,1107 @@ +# Mail Integration — Phase 4 : Frontend Services + Store + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mettre en place toute la couche frontend non-visuelle (types TS, service API, store Pinia, helper sanitization HTML) avant d'attaquer les composants UI en Phase 5. + +**Architecture:** `services/mail.ts` wrappe `useApi()` (pattern Lesstime), `stores/mail.ts` gère state global + polling unread 30s via `setInterval` natif (pas de VueUse — non installé dans le projet), `utils/sanitizeMailHtml.ts` applique DOMPurify avec config bloquante (scripts/iframes/on*/javascript:) + remplacement images distantes par placeholder anti-tracking. + +**Tech Stack:** Nuxt 4, Vue 3 Composition API, Pinia, TypeScript strict, DOMPurify. + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `frontend/services/dto/mail.ts` | Créer | +| `frontend/services/mail.ts` | Créer | +| `frontend/stores/mail.ts` | Créer | +| `frontend/utils/sanitizeMailHtml.ts` | Créer | +| `frontend/package.json` | Modifier (ajout dompurify + @types/dompurify) | + +--- + +### Task 1 : Vérification de l'environnement + install dompurify + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Vérifier que dompurify n'est pas déjà installé** + + ```bash + grep -i dompurify /home/r-dev/malio-dev/Lesstime/frontend/package.json + ``` + + Attendu : aucune ligne. Si déjà présent, passer au Step 4. + +- [ ] **Step 3 : Installer dompurify et ses types** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && npm install dompurify && npm install -D @types/dompurify + ``` + + Attendu : `package.json` mis à jour avec `"dompurify"` dans `dependencies` et `"@types/dompurify"` dans `devDependencies`. + +- [ ] **Step 4 : Vérifier que dompurify est importable (smoke check)** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && node -e "import('dompurify').then(() => console.log('OK'))" + ``` + + Ou simplement vérifier la présence dans node_modules : + + ```bash + ls /home/r-dev/malio-dev/Lesstime/frontend/node_modules/dompurify/dist/ | head -5 + ``` + + Attendu : fichiers `.js` présents. + +- [ ] **Step 5 : Commit** + + ```bash + git add frontend/package.json frontend/package-lock.json + git commit -m "feat(mail) : install dompurify + types" + ``` + +--- + +### Task 2 : Types TS — `frontend/services/dto/mail.ts` + +Tous les types sont alignés sur les formats de réponses des endpoints Phase 3. Le pattern suit `frontend/services/dto/zimbra.ts` (types simples, pas d'héritage complexe) et `frontend/services/dto/task.ts` (champs optionnels explicites, `| null`). + +- [ ] **Step 1 : Créer `frontend/services/dto/mail.ts`** + + ```typescript + // Lecture de la configuration mail (singleton admin) + export type MailConfigurationDto = { + protocol: string | null + imapHost: string | null + imapPort: number | null + imapEncryption: string | null + smtpHost: string | null + smtpPort: number | null + smtpEncryption: string | null + username: string | null + sentFolderPath: string | null + enabled: boolean + hasPassword: boolean + // password JAMAIS présent dans les réponses GET + } + + // Input PATCH configuration (password optionnel, write-only) + export type MailConfigurationUpdateDto = { + protocol?: string | null + imapHost?: string | null + imapPort?: number | null + imapEncryption?: string | null + smtpHost?: string | null + smtpPort?: number | null + smtpEncryption?: string | null + username?: string | null + sentFolderPath?: string | null + enabled?: boolean + password?: string // write-only, jamais retourné + } + + // Résultat du test de connexion + export type MailTestConnectionResultDto = { + ok: boolean + foldersCount?: number + error?: string + } + + // Dossier mail (peut être imbriqué) + export type MailFolderDto = { + path: string + displayName: string + parentPath: string | null + unreadCount: number + totalCount: number + children?: MailFolderDto[] + } + + // En-tête d'un message (liste) + export type MailMessageHeaderDto = { + id: number + messageId: string // identifiant IMAP unique + folderPath: string + subject: string | null + fromName: string | null + fromEmail: string | null + toRecipients: MailAddressDto[] + ccRecipients: MailAddressDto[] + sentAt: string | null // ISO 8601 + receivedAt: string // ISO 8601 + isRead: boolean + isFlagged: boolean + hasAttachments: boolean + linkedTaskIds: number[] + } + + // Adresse mail (nom + email) + export type MailAddressDto = { + name: string | null + email: string + } + + // Pièce jointe (métadonnées uniquement, téléchargement via downloadId) + export type MailAttachmentDto = { + downloadId: string + filename: string + mimeType: string + size: number // octets + } + + // Détail complet d'un message (enrichi avec body + PJ) + export type MailMessageDetailDto = { + header: MailMessageHeaderDto + bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage + bodyText: string | null // Fallback texte plain + attachments: MailAttachmentDto[] + } + + // Page de messages paginée (cursor-based) + export type MailMessagesPageDto = { + items: MailMessageHeaderDto[] + nextCursor: string | null // null = plus de page suivante + total: number + } + + // Input : marquer lu/non-lu + export type MailMessageReadInput = { + read: boolean + } + + // Input : marquer étoilé/non-étoilé + export type MailMessageFlagInput = { + flagged: boolean + } + + // Input : créer une tâche depuis un mail + export type MailCreateTaskInput = { + projectId: number + taskGroupId?: number | null + priority?: string | null + } + + // Input : lier une tâche existante à un mail + export type MailLinkTaskInput = { + taskId: number + } + + // Résultat de la sync manuelle + export type MailSyncResultDto = { + dispatched: boolean + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe TypeScript (smoke)** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "dto/mail" | head -20 + ``` + + Attendu : aucune erreur liée à `dto/mail.ts`. + +- [ ] **Step 3 : Commit** + + ```bash + git add frontend/services/dto/mail.ts + git commit -m "feat(mail) : types TS DTOs mail (config, folders, messages, attachments)" + ``` + +--- + +### Task 3 : Helper sanitization — `frontend/utils/sanitizeMailHtml.ts` + +Ce helper est critique pour la sécurité : tout corps HTML de mail doit transiter par cette fonction avant affichage dans le DOM. Il bloque les scripts, iframes, attributs événements, et remplace les images externes par un placeholder anti-tracking. + +- [ ] **Step 1 : Créer `frontend/utils/sanitizeMailHtml.ts`** + + ```typescript + import DOMPurify from 'dompurify' + + /** + * Options de sanitization du corps HTML d'un mail. + */ + export type SanitizeMailHtmlOptions = { + /** + * Si true, les images distantes (http/https) sont affichées directement. + * Par défaut false — les images distantes sont remplacées par un placeholder + * cliquable pour éviter le tracking par pixel. + */ + allowImages?: boolean + } + + /** + * Configuration DOMPurify bloquante pour les corps de mail. + * - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input + * - Bloque les attributs événements (on*) et les URI javascript: + * - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID + */ + const DOMPURIFY_CONFIG: DOMPurify.Config = { + FORBID_TAGS: [ + 'script', + 'iframe', + 'object', + 'embed', + 'style', + 'link', + 'meta', + 'form', + 'input', + 'button', + 'textarea', + 'select', + 'base', + 'applet', + ], + FORBID_ATTR: [ + 'onerror', + 'onload', + 'onclick', + 'onmouseover', + 'onmouseout', + 'onmouseenter', + 'onmouseleave', + 'onfocus', + 'onblur', + 'onchange', + 'onsubmit', + 'onreset', + 'onkeydown', + 'onkeyup', + 'onkeypress', + 'ondblclick', + 'oncontextmenu', + 'onwheel', + 'ondrag', + 'ondrop', + 'oncopy', + 'oncut', + 'onpaste', + 'action', + 'formaction', + 'xlink:href', + ], + ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i, + FORCE_BODY: true, + WHOLE_DOCUMENT: false, + } + + /** + * Remplace les balises avec src http(s):// par un bouton placeholder. + * Le src original est stocké en data-mail-image-src pour permettre l'affichage + * à la demande de l'utilisateur (Phase 5 — MailMessageViewer). + */ + function replaceRemoteImages(html: string): string { + // Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client + // est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side) + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + const images = doc.querySelectorAll('img') + + images.forEach((img) => { + const src = img.getAttribute('src') ?? '' + const isRemote = /^https?:\/\//i.test(src) + if (!isRemote) return + + // Remplacer par un span cliquable (pas de