# 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
