Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase3-api.md

2411 lines
91 KiB
Markdown

# 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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MailSettingsControllerTest extends WebTestCase
{
public function testGetConfigurationReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use App\State\Mail\MailSettingsProcessor;
use App\State\Mail\MailSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/mail/configuration',
normalizationContext: ['groups' => ['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
<?php
declare(strict_types=1);
namespace App\State\Mail;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\MailSettings;
use App\Repository\MailConfigurationRepository;
final readonly class MailSettingsProvider implements ProviderInterface
{
public function __construct(
private MailConfigurationRepository $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MailSettings
{
$config = $this->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
<?php
declare(strict_types=1);
namespace App\State\Mail;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\MailSettings;
use App\Entity\MailConfiguration;
use App\Repository\MailConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class MailSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private MailConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MailSettings
{
assert($data instanceof MailSettings);
$config = $this->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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Throwable;
#[Route('/api/mail/configuration/test', name: 'mail_configuration_test', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_ADMIN')]
class MailTestConnectionController extends AbstractController
{
public function __construct(
private readonly MailProviderInterface $mailProvider,
) {}
public function __invoke(): JsonResponse
{
try {
$folders = $this->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
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\User\UserInterface;
final readonly class MailAccessChecker
{
public function __construct(
private AuthorizationCheckerInterface $authorizationChecker,
) {}
/**
* Vérifie que l'utilisateur courant peut accéder aux endpoints mail.
* Autorisé : ROLE_USER, ROLE_ADMIN.
* Refusé : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifié.
*
* @throws AccessDeniedException
*/
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');
}
}
/**
* 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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MailFoldersControllerTest extends WebTestCase
{
public function testListFoldersReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Repository\MailFolderRepository;
use App\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/folders', name: 'mail_folders_list', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailFoldersListController extends AbstractController
{
public function __construct(
private readonly MailFolderRepository $folderRepository,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(): JsonResponse
{
$this->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<MailMessage>, 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<MailMessage> $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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Repository\MailFolderRepository;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/folders/{folderPath}/messages', name: 'mail_messages_list', methods: ['GET'], priority: 1, requirements: ['folderPath' => '.+'])]
#[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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\User;
use App\Mail\MailProviderInterface;
use App\Mail\Dto\MailMessageDetailDto;
use App\Mail\Dto\MailMessageHeaderDto;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MailMessagesControllerTest extends WebTestCase
{
public function testGetMessageDetailReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}', name: 'mail_message_detail', methods: ['GET'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/read', name: 'mail_message_read', methods: ['POST'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/flag', name: 'mail_message_flag', methods: ['POST'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Entity\Task;
use App\Entity\TaskMailLink;
use App\Repository\MailMessageRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Security\MailAccessChecker;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/create-task', name: 'mail_create_task', methods: ['POST'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Entity\Task;
use App\Entity\TaskMailLink;
use App\Repository\MailMessageRepository;
use App\Repository\TaskMailLinkRepository;
use App\Security\MailAccessChecker;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/link-task', name: 'mail_link_task', methods: ['POST'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Entity\Task;
use App\Repository\MailMessageRepository;
use App\Repository\TaskMailLinkRepository;
use App\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/link-task/{taskId}', name: 'mail_unlink_task', methods: ['DELETE'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Entity\Task;
use App\Repository\TaskMailLinkRepository;
use App\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/tasks/{id}/mails', name: 'task_mails_list', methods: ['GET'], priority: 1, requirements: ['id' => '\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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MailTaskIntegrationControllerTest extends WebTestCase
{
public function testLinkTaskReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/attachments/{downloadId}', name: 'mail_attachment_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailAttachmentDownloadController extends AbstractController
{
public function __construct(
private readonly MailMessageRepository $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(string $downloadId): Response
{
$this->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
<?php
declare(strict_types=1);
namespace App\Message;
final readonly class MailSyncRequested
{
public function __construct(
/** Optionnel : si null, sync tous les dossiers. Sinon, sync uniquement ce dossier. */
public ?string $folderPath = null,
) {}
}
```
- [ ] **Step 2 : Créer `src/MessageHandler/MailSyncRequestedHandler.php`**
```php
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\MailSyncRequested;
use App\Repository\MailFolderRepository;
use App\Service\MailSyncService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler]
final readonly class MailSyncRequestedHandler
{
public function __construct(
private MailSyncService $mailSyncService,
private MailFolderRepository $folderRepository,
private LoggerInterface $logger,
) {}
public function __invoke(MailSyncRequested $message): void
{
try {
if (null !== $message->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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MailSyncTriggerControllerTest extends WebTestCase
{
public function testSyncTriggerReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Message\MailSyncRequested;
use App\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/sync', name: 'mail_sync_trigger', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailSyncTriggerController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $bus,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request): JsonResponse
{
$this->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) : <message>` (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