From cbbc491d69c52dec42e63adfd0ac399087e64001 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:48:43 +0200 Subject: [PATCH] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill=C3=A9=20Ph?= =?UTF-8?q?ase=203=20=E2=80=94=20API=20endpoints,=20s=C3=A9curit=C3=A9=20R?= =?UTF-8?q?OLE=5FCLIENT,=20Messenger=20async=20(15=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-mail-phase3-api.md | 2410 +++++++++++++++++ 1 file changed, 2410 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase3-api.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase3-api.md b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md new file mode 100644 index 0000000..aa77b14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md @@ -0,0 +1,2410 @@ +# Mail Integration — Phase 3 : API Backend + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Exposer l'ensemble des endpoints HTTP de l'intégration mail (config singleton admin, listing dossiers/messages, lecture body avec cache 5 min, actions read/flag/create-task/link-task, attachments stream, sync async via Messenger). + +**Architecture:** Singleton config via API Platform Provider/Processor (pattern Zimbra). Custom controllers Symfony pour endpoints "métier" (priority: 1 pour éviter conflit API Platform `{id}`). Sécurité stricte : `IS_AUTHENTICATED_FULLY` + `ROLE_USER` + check `!ROLE_CLIENT` explicite dans chaque endpoint (rappel : `User::getRoles()` n'ajoute pas `ROLE_USER` aux clients — la hiérarchie `ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]` de security.yaml s'applique aux ADMIN, pas aux ROLE_CLIENT pur). Sync manuelle dispatchée en async via Symfony Messenger (`MailSyncRequested` message). Body fetché live IMAP avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. + +**Tech Stack:** Symfony 8.0, API Platform 4.2, Symfony Messenger, Symfony Cache (pool `cache.app`, APCu ou filesystem selon config), LexikJWT pour l'auth (cookie BEARER). + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/ApiResource/MailSettings.php` | Créer | +| `src/State/Mail/MailSettingsProvider.php` | Créer | +| `src/State/Mail/MailSettingsProcessor.php` | Créer | +| `src/Controller/Mail/MailTestConnectionController.php` | Créer | +| `src/Security/MailAccessChecker.php` | Créer | +| `src/Controller/Mail/MailFoldersListController.php` | Créer | +| `src/Controller/Mail/MailMessagesListController.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findByFolderCursor`) | +| `src/Controller/Mail/MailMessageDetailController.php` | Créer | +| `src/Controller/Mail/MailMessageReadController.php` | Créer | +| `src/Controller/Mail/MailMessageFlagController.php` | Créer | +| `src/Controller/Mail/MailCreateTaskController.php` | Créer | +| `src/Controller/Mail/MailLinkTaskController.php` | Créer | +| `src/Controller/Mail/MailUnlinkTaskController.php` | Créer | +| `src/Controller/Mail/TaskMailsListController.php` | Créer | +| `src/Controller/Mail/MailAttachmentDownloadController.php` | Créer | +| `src/Message/MailSyncRequested.php` | Créer | +| `src/MessageHandler/MailSyncRequestedHandler.php` | Créer | +| `src/Controller/Mail/MailSyncTriggerController.php` | Créer | +| `config/packages/messenger.yaml` | Créer | +| `config/packages/security.yaml` | Modifier (ajout access_control mail) | +| `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailFoldersControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailMessagesControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php` | Créer | + +--- + +### Task 1 : Préparation — branche + dossiers + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers namespace** + + ```bash + mkdir -p src/State/Mail + mkdir -p src/Controller/Mail + mkdir -p src/Message + mkdir -p src/MessageHandler + mkdir -p src/Security + mkdir -p tests/Functional/Controller/Mail + ``` + +- [ ] **Step 3 : Vérifier que `src/Security/` existe déjà (ApiTokenAuthenticator)** + + ```bash + ls src/Security/ + ``` + + Attendu : `ApiTokenAuthenticator.php` présent. Le dossier existe déjà — pas besoin de le recréer. + +- [ ] **Step 4 : Vérifier l'autowiring de base** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfigurationRepository 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailProviderInterface 2>&1 | head -5 + ``` + + Attendu : les trois services sont présents (construits en Phase 1 et 2). + +--- + +### Task 2 : ApiResource `MailSettings` + Provider/Processor singleton + +L'objectif est d'exposer `GET /api/mail/configuration` et `PATCH /api/mail/configuration` avec le même pattern que `ZimbraSettings` (voir `src/ApiResource/ZimbraSettings.php`). Le password n'est **jamais** retourné en clair — seulement `hasPassword: bool`. + +- [ ] **Step 1 : Écrire le test fonctionnel (TDD — doit échouer)** + + Créer `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` : + + ```php + request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetConfigurationReturns200ForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + // Password jamais en clair + self::assertArrayNotHasKey('password', $data); + self::assertArrayNotHasKey('encryptedPassword', $data); + self::assertArrayHasKey('hasPassword', $data); + self::assertArrayHasKey('imapHost', $data); + self::assertArrayHasKey('enabled', $data); + } + + public function testPatchConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['enabled' => false]) + ); + + self::assertResponseStatusCodeSame(403); + } + + public function testPatchConfigurationUpdatesFieldsForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['imapHost' => 'imap.example.com', 'enabled' => false]) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame('imap.example.com', $data['imapHost']); + self::assertArrayNotHasKey('password', $data); + } + + public function testPatchConfigurationWithPasswordEncryptsIt(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['password' => 'secret123']) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertTrue($data['hasPassword']); + self::assertArrayNotHasKey('password', $data); + } + } + ``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : 404 (route non créée). + +- [ ] **Step 3 : Créer `src/ApiResource/MailSettings.php`** + + Calqué exactement sur `src/ApiResource/ZimbraSettings.php`, mais pour `MailConfiguration` : + + ```php + ['mail_settings:read']], + provider: MailSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + uriTemplate: '/mail/configuration', + denormalizationContext: ['groups' => ['mail_settings:write']], + normalizationContext: ['groups' => ['mail_settings:read']], + provider: MailSettingsProvider::class, + processor: MailSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], + )] + final class MailSettings + { + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $protocol = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $imapPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $smtpPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $username = null; + + /** Write-only: jamais retourné en lecture */ + #[Groups(['mail_settings:write'])] + public ?string $password = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $sentFolderPath = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public bool $enabled = false; + + /** Lecture seule : indique si un password chiffré existe */ + #[Groups(['mail_settings:read'])] + public bool $hasPassword = false; + } + ``` + +- [ ] **Step 4 : Créer `src/State/Mail/MailSettingsProvider.php`** + + ```php + configRepository->findSingleton(); + $dto = new MailSettings(); + + if (null !== $config) { + $dto->protocol = $config->getProtocol(); + $dto->imapHost = $config->getImapHost(); + $dto->imapPort = $config->getImapPort(); + $dto->imapEncryption = $config->getImapEncryption(); + $dto->smtpHost = $config->getSmtpHost(); + $dto->smtpPort = $config->getSmtpPort(); + $dto->smtpEncryption = $config->getSmtpEncryption(); + $dto->username = $config->getUsername(); + $dto->sentFolderPath = $config->getSentFolderPath(); + $dto->enabled = $config->isEnabled(); + $dto->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + } + + return $dto; + } + } + ``` + +- [ ] **Step 5 : Créer `src/State/Mail/MailSettingsProcessor.php`** + + ```php + configRepository->findSingleton(); + if (null === $config) { + $config = new MailConfiguration(); + } + + if (null !== $data->protocol) { + $config->setProtocol($data->protocol); + } + if (null !== $data->imapHost) { + $config->setImapHost($data->imapHost); + } + if (null !== $data->imapPort) { + $config->setImapPort($data->imapPort); + } + if (null !== $data->imapEncryption) { + $config->setImapEncryption($data->imapEncryption); + } + if (null !== $data->smtpHost) { + $config->setSmtpHost($data->smtpHost); + } + if (null !== $data->smtpPort) { + $config->setSmtpPort($data->smtpPort); + } + if (null !== $data->smtpEncryption) { + $config->setSmtpEncryption($data->smtpEncryption); + } + if (null !== $data->username) { + $config->setUsername($data->username); + } + if (null !== $data->sentFolderPath) { + $config->setSentFolderPath($data->sentFolderPath); + } + $config->setEnabled($data->enabled); + + // Password : seulement si fourni non-vide + if (null !== $data->password && '' !== $data->password) { + $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new MailSettings(); + $result->protocol = $config->getProtocol(); + $result->imapHost = $config->getImapHost(); + $result->imapPort = $config->getImapPort(); + $result->imapEncryption = $config->getImapEncryption(); + $result->smtpHost = $config->getSmtpHost(); + $result->smtpPort = $config->getSmtpPort(); + $result->smtpEncryption = $config->getSmtpEncryption(); + $result->username = $config->getUsername(); + $result->sentFolderPath = $config->getSentFolderPath(); + $result->enabled = $config->isEnabled(); + $result->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + + return $result; + } + } + ``` + +- [ ] **Step 6 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/ApiResource/MailSettings.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProvider.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProcessor.php + ``` + + Attendu : `No syntax errors detected` pour les trois. + +- [ ] **Step 7 : Vider le cache et relancer les tests** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 8 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/ApiResource/MailSettings.php src/State/Mail/ tests/Functional/Controller/Mail/MailSettingsControllerTest.php + git commit -m "feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)" + ``` + +--- + +### Task 3 : Custom controller `MailTestConnectionController` + +Endpoint : `POST /api/mail/configuration/test` (ROLE_ADMIN uniquement). Instancie `ImapMailProvider` via injection, appelle `listFolders()`, retourne `{ ok: true, foldersCount: N }` ou `{ ok: false, error: "..." }` sans leak du message d'erreur interne. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailTestConnectionController.php`** + + ```php + mailProvider->listFolders(); + + return $this->json([ + 'ok' => true, + 'foldersCount' => count($folders), + ]); + } catch (MailProviderException $e) { + return $this->json([ + 'ok' => false, + 'error' => 'Connexion IMAP impossible. Vérifiez la configuration.', + ], 200); // 200 intentionnel : c'est un résultat de test, pas une erreur HTTP + } catch (Throwable) { + return $this->json([ + 'ok' => false, + 'error' => 'Erreur inattendue lors du test de connexion.', + ], 200); + } + } + } + ``` + + > Note : `priority: 1` est **obligatoire** pour éviter le conflit avec la route API Platform `{id}` qui capte tous les paths sous `/api/mail/`. + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailTestConnectionController.php + ``` + +- [ ] **Step 3 : Vérifier que la route est enregistrée** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_configuration_test + ``` + + Attendu : route `mail_configuration_test` visible avec méthode POST. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailTestConnectionController.php + git commit -m "feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test" + ``` + +--- + +### Task 4 : Service `MailAccessChecker` + +Service réutilisable par tous les controllers "métier" mail. Vérifie que l'utilisateur est `ROLE_USER` ou `ROLE_ADMIN` ET n'est pas `ROLE_CLIENT` pur (sans `ROLE_ADMIN`). Rappel : la hiérarchie de sécurité donne `ROLE_USER` aux ADMIN, mais un `ROLE_CLIENT` pur n'a pas `ROLE_USER`. + +- [ ] **Step 1 : Créer `src/Security/MailAccessChecker.php`** + + ```php + getRoles(); + + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } + } + + /** + * Vérifie que l'utilisateur est ROLE_ADMIN. + * + * @throws AccessDeniedException + */ + public function ensureIsAdmin(?UserInterface $user): void + { + if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Admin only'); + } + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Security/MailAccessChecker.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Security/MailAccessChecker.php + git commit -m "feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)" + ``` + +--- + +### Task 5 : Custom controller `MailFoldersListController` + +Endpoint : `GET /api/mail/folders`. Lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec `unreadCount`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailFoldersControllerTest.php`** + + ```php + request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(401); + } + + public function testListFoldersReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListFoldersReturns200ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertIsArray($data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailFoldersListController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + $folders = $this->folderRepository->findAllOrderedByPath(); + + $data = array_map(static fn ($folder) => [ + 'id' => $folder->getId(), + 'path' => $folder->getPath(), + 'displayName' => $folder->getDisplayName(), + 'parentPath' => $folder->getParentPath(), + 'unreadCount' => $folder->getUnreadCount(), + 'totalCount' => $folder->getTotalCount(), + 'lastSyncedAt' => $folder->getLastSyncedAt()?->format(\DateTimeInterface::ATOM), + ], $folders); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailFoldersListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_folders_list + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailFoldersControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailFoldersListController.php tests/Functional/Controller/Mail/MailFoldersControllerTest.php + git commit -m "feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)" + ``` + +--- + +### Task 6 : Custom controller `MailMessagesListController` + pagination cursor + +Endpoint : `GET /api/mail/folders/{folderPath}/messages?cursor=&limit=50`. Pagination cursor `sentAt DESC, id DESC`. `folderPath` est URL-encodé. + +- [ ] **Step 1 : Ajouter `findByFolderCursor` dans `MailMessageRepository`** + + Ouvrir `src/Repository/MailMessageRepository.php` et ajouter : + + ```php + /** + * Pagination cursor: retourne `$limit` messages après le cursor `sentAt DESC, id DESC`. + * Cursor format: "sentAt_iso8601:id" — null = première page. + * + * @return array{ messages: list, nextCursor: ?string } + */ + public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array + { + $qb = $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit + 1); // +1 pour détecter s'il y a une page suivante + + if (null !== $cursor) { + // cursor = base64url(sentAt_iso:id) + $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); + if (false !== $decoded && str_contains($decoded, ':')) { + [$sentAtStr, $idStr] = explode(':', $decoded, 2); + $cursorSentAt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $sentAtStr); + $cursorId = (int) $idStr; + + if ($cursorSentAt instanceof \DateTimeImmutable) { + $qb + ->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)') + ->setParameter('cursorSentAt', $cursorSentAt) + ->setParameter('cursorId', $cursorId); + } + } + } + + /** @var list $results */ + $results = $qb->getQuery()->getResult(); + $hasMore = count($results) > $limit; + $messages = $hasMore ? array_slice($results, 0, $limit) : $results; + $nextCursor = null; + + if ($hasMore && [] !== $messages) { + $last = end($messages); + $raw = $last->getSentAt()->format(\DateTimeInterface::ATOM).':'.$last->getId(); + $nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + return ['messages' => $messages, 'nextCursor' => $nextCursor]; + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessagesListController.php`** + + ```php + '.+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessagesListController extends AbstractController + { + public function __construct( + private readonly MailFolderRepository $folderRepository, + private readonly MailMessageRepository $messageRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, string $folderPath): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + // folderPath peut être URL-encodé si contient des slashes + $decodedPath = urldecode($folderPath); + + $folder = $this->folderRepository->findByPath($decodedPath); + if (null === $folder) { + throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath)); + } + + $limit = min((int) ($request->query->get('limit', 50)), 100); + $cursor = $request->query->get('cursor'); + + $result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null); + + $messages = array_map(static fn ($m) => [ + 'id' => $m->getId(), + 'messageId' => $m->getMessageId(), + 'uid' => $m->getUid(), + 'subject' => $m->getSubject(), + 'fromAddress' => $m->getFromAddress(), + 'fromName' => $m->getFromName(), + 'toAddresses' => $m->getToAddresses(), + 'ccAddresses' => $m->getCcAddresses(), + 'sentAt' => $m->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $m->isRead(), + 'isFlagged' => $m->isFlagged(), + 'hasAttachments' => $m->hasAttachments(), + 'snippet' => $m->getSnippet(), + ], $result['messages']); + + return $this->json([ + 'messages' => $messages, + 'nextCursor' => $result['nextCursor'], + ]); + } + } + ``` + + > Note : `requirements: ['folderPath' => '.+']` permet à `folderPath` de contenir des slashes (ex. `INBOX/Subfolder`). Les slashes seront capturés dans le paramètre de route. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessagesListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_messages_list + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessagesListController.php src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)" + ``` + +--- + +### Task 7 : Custom controller `MailMessageDetailController` (body live + cache 5 min) + +Endpoint : `GET /api/mail/messages/{id}`. Fetche le corps du mail en live IMAP via `ImapMailProvider::fetchMessage()` avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. L'`{id}` est l'id BDD du `MailMessage`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailMessagesControllerTest.php`** + + ```php + request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetMessageDetailReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetMessageDetailReturns404WhenMessageNotFound(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/messages/99999'); + + self::assertResponseStatusCodeSame(404); + } + + public function testMarkReadReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(401); + } + + public function testMarkReadReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(403); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageDetailController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageDetailController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly MailAccessChecker $accessChecker, + private readonly CacheItemPoolInterface $cache, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Cache key basé sur messageId (md5 pour sanitiser les caractères spéciaux) + $cacheKey = 'mail_body_' . md5($message->getMessageId()); + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + $item->set($detail); + $item->expiresAfter(300); // 5 minutes + $this->cache->save($item); + } catch (MailProviderException $e) { + throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body'); + } + } + + $detail = $item->get(); + + // Sérialiser les attachments (sans contenu binaire — seulement metadata) + $attachments = array_map(static fn ($att) => [ + 'partNumber' => $att->partNumber, + 'filename' => $att->filename, + 'mimeType' => $att->mimeType, + 'size' => $att->size, + // URL téléchargement : /api/mail/attachments/{messageId_base64}:{partNumber_base64} + 'downloadId' => rtrim(strtr(base64_encode($message->getId().':'.$att->partNumber), '+/', '-_'), '='), + ], $detail->attachments); + + return $this->json([ + 'id' => $message->getId(), + 'messageId' => $message->getMessageId(), + 'uid' => $message->getUid(), + 'folderPath' => $message->getFolder()->getPath(), + 'subject' => $detail->header->subject, + 'fromAddress' => $detail->header->fromAddress, + 'fromName' => $detail->header->fromName, + 'toAddresses' => $detail->header->toAddresses, + 'ccAddresses' => $detail->header->ccAddresses, + 'sentAt' => $detail->header->sentAt->format(\DateTimeInterface::ATOM), + 'isRead' => $message->isRead(), + 'isFlagged' => $message->isFlagged(), + 'hasAttachments' => $message->hasAttachments(), + 'bodyHtml' => $detail->bodyHtml, // ATTENTION : côté frontend, sanitiser via DOMPurify + 'bodyText' => $detail->bodyText, + 'attachments' => $attachments, + ]); + } + } + ``` + + > Note sur le `CacheItemPoolInterface` : Symfony injectera le pool par défaut `cache.app`. Aucune configuration supplémentaire nécessaire — `cache.app` est défini dans le framework par défaut. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageDetailController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_message_detail + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailMessagesControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : les tests 401/403/404 passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageDetailController.php tests/Functional/Controller/Mail/MailMessagesControllerTest.php + git commit -m "feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)" + ``` + +--- + +### Task 8 : Custom controllers `MailMessageReadController` + `MailMessageFlagController` + +Endpoints : `POST /api/mail/messages/{id}/read` (body `{ "read": bool }`) et `POST /api/mail/messages/{id}/flag` (body `{ "flagged": bool }`). Appelle le provider IMAP + met à jour la BDD. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailMessageReadController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageReadController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $read = (bool) ($body['read'] ?? true); + + try { + $this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read); + } catch (MailProviderException) { + // Non-bloquant : on met quand même à jour la BDD (sync IMAP au prochain cycle) + } + + $message->setIsRead($read); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageFlagController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageFlagController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $flagged = (bool) ($body['flagged'] ?? true); + + try { + $this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged); + } catch (MailProviderException) { + // Non-bloquant + } + + $message->setIsFlagged($flagged); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageReadController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageFlagController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageReadController.php src/Controller/Mail/MailMessageFlagController.php + git commit -m "feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag" + ``` + +--- + +### Task 9 : Custom controller `MailCreateTaskController` + +Endpoint : `POST /api/mail/messages/{id}/create-task` (body `{ "projectId": 1, "taskGroupId": null, "priorityId": null }`). Crée une `Task` Doctrine (via `TaskNumberProcessor` si applicable, sinon directement), crée `TaskMailLink`, retourne l'id de la tâche créée. + +- [ ] **Step 1 : Vérifier si `TaskNumberProcessor` ou un `TaskService` existe** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring TaskNumber 2>&1 | head -10 + ls /home/r-dev/malio-dev/Lesstime/src/State/ | grep -i task + ls /home/r-dev/malio-dev/Lesstime/src/Service/ | grep -i task + ``` + + Selon le résultat : + - Si `TaskNumberProcessor` est un `State/Processor` API Platform (pas un service injectable) → créer la Task directement avec persist+flush, sans passer par le processor. + - Si un `TaskService` injectable existe → l'utiliser. + + Dans tous les cas, le numéro de tâche doit suivre la convention du projet (incrément par projet). Vérifier comment `Task::number` est assigné dans l'existant et reproduire le même mécanisme. + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailCreateTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailCreateTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + private readonly TaskRepository $taskRepository, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $projectId = $body['projectId'] ?? null; + + if (null === $projectId) { + throw new UnprocessableEntityHttpException('projectId is required'); + } + + $project = $this->em->getRepository(\App\Entity\Project::class)->find($projectId); + if (null === $project) { + throw new NotFoundHttpException('Project not found'); + } + + // Numéro de tâche : max existant + 1 pour ce projet + $maxNumber = $this->taskRepository->findMaxNumberForProject($project); + $number = $maxNumber + 1; + + // Titre = subject du mail (tronqué à 500 chars si nécessaire) + $title = $message->getSubject() ?? 'Mail sans sujet'; + if (mb_strlen($title) > 500) { + $title = mb_substr($title, 0, 497).'...'; + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($number); + $task->setCreatedAt(new DateTimeImmutable()); + + // TaskGroup optionnel + if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) { + $taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']); + if (null !== $taskGroup) { + $task->setTaskGroup($taskGroup); + } + } + + // Priorité optionnelle + if (isset($body['priorityId']) && null !== $body['priorityId']) { + $priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']); + if (null !== $priority) { + $task->setPriority($priority); + } + } + + // Créateur = user courant + $task->setCreatedBy($this->getUser()); + + $this->em->persist($task); + + // Lien mail ↔ tâche + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + + $this->em->flush(); + + return $this->json([ + 'taskId' => $task->getId(), + 'taskNumber' => $task->getNumber(), + 'taskTitle' => $task->getTitle(), + 'messageId' => $message->getId(), + ], 201); + } + } + ``` + + > **Note importante :** `Task::setNumber()`, `Task::setCreatedAt()`, `Task::setCreatedBy()`, etc. : vérifier les setters réels de l'entité `Task` avant de coder. Si des champs obligatoires manquent (ex. `status`), injecter le repository correspondant et récupérer le statut par défaut ("À faire" ou premier statut du projet). Adapter selon la structure réelle de `Task`. + + > **`TaskRepository::findMaxNumberForProject`** : vérifier si cette méthode existe. Sinon la créer : + > ```php + > public function findMaxNumberForProject(Project $project): int + > { + > $result = $this->createQueryBuilder('t') + > ->select('MAX(t.number)') + > ->andWhere('t.project = :project') + > ->setParameter('project', $project) + > ->getQuery() + > ->getSingleScalarResult(); + > return (int) ($result ?? 0); + > } + > ``` + +- [ ] **Step 3 : Vérifier la syntaxe et adapter les setters Task** + + ```bash + # Vérifier les champs obligatoires de Task + docker exec php-lesstime-fpm php bin/console doctrine:mapping:info 2>&1 | grep Task + cat src/Entity/Task.php | grep 'private\|public' | head -40 + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailCreateTaskController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailCreateTaskController.php src/Repository/TaskRepository.php + git commit -m "feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task" + ``` + +--- + +### Task 10 : Custom controllers `MailLinkTaskController` + `MailUnlinkTaskController` + `TaskMailsListController` + +Trois endpoints liés à la gestion des liens mail ↔ tâche : +- `POST /api/mail/messages/{id}/link-task` (body `{ "taskId": 1 }`) +- `DELETE /api/mail/messages/{id}/link-task/{taskId}` +- `GET /api/tasks/{id}/mails` + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailLinkTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailLinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $taskId = $body['taskId'] ?? null; + + if (null === $taskId) { + throw new UnprocessableEntityHttpException('taskId is required'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + // Éviter le doublon (contrainte unique en BDD, mais prévenir le 500) + $existing = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null !== $existing) { + return $this->json(['message' => 'Already linked'], 200); + } + + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + $this->em->flush(); + + return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailUnlinkTaskController.php`** + + ```php + '\d+', 'taskId' => '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailUnlinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id, int $taskId): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $link = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null === $link) { + throw new NotFoundHttpException('Link not found'); + } + + $this->em->remove($link); + $this->em->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + } + ``` + +- [ ] **Step 3 : Créer `src/Controller/Mail/TaskMailsListController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class TaskMailsListController extends AbstractController + { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly TaskMailLinkRepository $linkRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $task = $this->em->getRepository(Task::class)->find($id); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $links = $this->linkRepository->findByTask($task); + + $data = array_map(static fn ($link) => [ + 'id' => $link->getMailMessage()->getId(), + 'messageId' => $link->getMailMessage()->getMessageId(), + 'subject' => $link->getMailMessage()->getSubject(), + 'fromAddress' => $link->getMailMessage()->getFromAddress(), + 'fromName' => $link->getMailMessage()->getFromName(), + 'sentAt' => $link->getMailMessage()->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $link->getMailMessage()->isRead(), + 'isFlagged' => $link->getMailMessage()->isFlagged(), + 'snippet' => $link->getMailMessage()->getSnippet(), + 'linkedAt' => $link->getLinkedAt()->format(\DateTimeInterface::ATOM), + ], $links); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 4 : Créer le test fonctionnel pour l'intégration tâches** + + Créer `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` : + + ```php + request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + + public function testLinkTaskReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnlinkTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('DELETE', '/api/mail/messages/1/link-task/1'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + } + ``` + +- [ ] **Step 5 : Vérifier la syntaxe des 3 controllers** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailLinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailUnlinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/TaskMailsListController.php + make cache-clear + ``` + +- [ ] **Step 6 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php 2>&1 | tail -15 + ``` + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailLinkTaskController.php src/Controller/Mail/MailUnlinkTaskController.php src/Controller/Mail/TaskMailsListController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php + git commit -m "feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers" + ``` + +--- + +### Task 11 : Custom controller `MailAttachmentDownloadController` + +Endpoint : `GET /api/mail/attachments/{downloadId}` où `downloadId` est un `base64url(messageDbId:partNumber)`. Fetch via `ImapMailProvider::fetchAttachment()`, stream avec `Content-Disposition: attachment` (jamais inline). Force le `Content-Type` exact. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailAttachmentDownloadController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Décoder le downloadId : base64url(messageDbId:partNumber) + $decoded = base64_decode(strtr($downloadId, '-_', '+/'), true); + if (false === $decoded || !str_contains($decoded, ':')) { + throw new BadRequestHttpException('Invalid attachment ID format'); + } + + [$messageDbIdStr, $partNumber] = explode(':', $decoded, 2); + $messageDbId = (int) $messageDbIdStr; + + $message = $this->messageRepository->find($messageDbId); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Trouver les métadonnées de l'attachment dans le detail (via cache ou live) + // Pour l'instant, fetch live (le cache body sera utilisé si disponible) + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch message from IMAP server'); + } + + // Trouver le bon attachment par partNumber + $targetAttachment = null; + foreach ($detail->attachments as $att) { + if ($att->partNumber === $partNumber) { + $targetAttachment = $att; + break; + } + } + + if (null === $targetAttachment) { + throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber)); + } + + // Fetch le contenu binaire + try { + $content = $this->mailProvider->fetchAttachment( + $message->getFolder()->getPath(), + $message->getUid(), + $partNumber + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch attachment content'); + } + + // Sanitiser le nom de fichier (éviter path traversal) + $filename = basename($targetAttachment->filename); + if ('' === $filename || '.' === $filename) { + $filename = 'attachment'; + } + + // Toujours Content-Disposition: attachment (jamais inline — risque XSS via HTML attachments) + $response = new Response($content); + $response->headers->set('Content-Type', $targetAttachment->mimeType); + $response->headers->set( + 'Content-Disposition', + sprintf('attachment; filename="%s"', addslashes($filename)) + ); + $response->headers->set('Content-Length', (string) strlen($content)); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailAttachmentDownloadController.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailAttachmentDownloadController.php + git commit -m "feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)" + ``` + +--- + +### Task 12 : Message + Handler Symfony Messenger `MailSyncRequested` + +Crée le message `MailSyncRequested` et son handler, configure `messenger.yaml` (transport async), puis crée le controller trigger. + +- [ ] **Step 1 : Créer `src/Message/MailSyncRequested.php`** + + ```php + folderPath) { + $folder = $this->folderRepository->findByPath($message->folderPath); + if (null !== $folder) { + $report = $this->mailSyncService->syncFolder($folder); + $this->logger->info(sprintf( + 'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted', + $message->folderPath, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + )); + } else { + $this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath)); + } + } else { + $report = $this->mailSyncService->syncAll(); + $this->logger->info(sprintf( + 'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned', + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + } + } catch (Throwable $e) { + $this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage()); + // Ne pas throw — éviter la retry infinie en cas d'erreur IMAP permanente + } + } + } + ``` + +- [ ] **Step 3 : Créer `config/packages/messenger.yaml`** + + ```yaml + framework: + messenger: + # Failure transport : messages en échec stockés pour retry manuel + failure_transport: failed + + transports: + # Transport synchrone (défaut pour les messages non routés) + sync: + dsn: 'sync://' + + # Transport async via Doctrine (DBAL) + async: + dsn: 'doctrine://default?auto_setup=0' + options: + queue_name: default + retry_strategy: + max_retries: 3 + delay: 1000 + multiplier: 2 + max_delay: 0 + + # Transport pour les messages en échec + failed: + dsn: 'doctrine://default?queue_name=failed&auto_setup=0' + + routing: + # MailSyncRequested est dispatché en async pour retour 202 immédiat + 'App\Message\MailSyncRequested': async + ``` + + > Note : `auto_setup=0` signifie que la table `messenger_messages` doit être créée manuellement (via `php bin/console messenger:setup-transports`). Appeler cette commande en Task suivante. Si le projet n'a pas encore de table Messenger, utiliser `auto_setup=true` pour le développement. + +- [ ] **Step 4 : Setup des transports Messenger** + + ```bash + docker exec php-lesstime-fpm php bin/console messenger:setup-transports + ``` + + Attendu : table `messenger_messages` créée en BDD. + + > Si erreur "table déjà existante" → déjà configuré, ignorer. + +- [ ] **Step 5 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Message/MailSyncRequested.php + docker exec php-lesstime-fpm php -l src/MessageHandler/MailSyncRequestedHandler.php + make cache-clear + ``` + +- [ ] **Step 6 : Vérifier que le handler est enregistré** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:messenger 2>&1 | grep Mail + ``` + + Attendu : `App\Message\MailSyncRequested` → `App\MessageHandler\MailSyncRequestedHandler`. + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Message/MailSyncRequested.php src/MessageHandler/MailSyncRequestedHandler.php config/packages/messenger.yaml + git commit -m "feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine" + ``` + +--- + +### Task 13 : Custom controller `MailSyncTriggerController` + +Endpoint : `POST /api/mail/sync`. Dispatch `MailSyncRequested` au bus Messenger, retourne `202 Accepted` immédiat. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php`** + + ```php + request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSyncTriggerReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(403); + } + + public function testSyncTriggerReturns202ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('POST', '/api/mail/sync'); + + // 202 Accepted : le message est dispatché, la sync se fait en async + self::assertResponseStatusCodeSame(202); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('message', $data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailSyncTriggerController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Optionnel : folder spécifique via body JSON + $body = json_decode($request->getContent(), true) ?? []; + $folderPath = $body['folderPath'] ?? null; + + $this->bus->dispatch(new MailSyncRequested($folderPath)); + + return $this->json( + ['message' => 'Synchronisation démarrée en arrière-plan'], + Response::HTTP_ACCEPTED // 202 + ); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailSyncTriggerController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_sync_trigger + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : les trois tests passent (401, 403, 202). + + > Note : en environnement de test, Symfony Messenger utilise le transport `sync://` par défaut (via la config `when@test`). Le message sera traité synchronement — c'est attendu et correct pour les tests fonctionnels. Ajouter dans `config/packages/test/messenger.yaml` si nécessaire : + > + > ```yaml + > framework: + > messenger: + > transports: + > async: + > dsn: 'in-memory://' + > ``` + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailSyncTriggerController.php tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php + git commit -m "feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)" + ``` + +--- + +### Task 14 : Sécurité globale — `security.yaml` + access_control + +Ajouter les règles `access_control` dans `config/packages/security.yaml` pour que `^/api/mail` requiert `IS_AUTHENTICATED_FULLY` au niveau firewall (en plus des checks explicites dans les controllers). La route admin config requiert `ROLE_ADMIN` (géré via API Platform `security: "is_granted('ROLE_ADMIN')"`). + +- [ ] **Step 1 : Modifier `config/packages/security.yaml`** + + Ouvrir `config/packages/security.yaml` et localiser le bloc `access_control`. Ajouter les lignes mail **avant** la règle générique `^/api` : + + ```yaml + access_control: + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + # Version de l'application en public + - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } + # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + ``` + + > Note : La règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` bloque les requêtes non authentifiées avant même d'atteindre les controllers. Les vérifications fines ROLE_USER vs ROLE_CLIENT sont faites dans `MailAccessChecker` injecté dans chaque controller. + +- [ ] **Step 2 : Vider le cache et vérifier** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:config security access_control 2>&1 | head -20 + ``` + + Attendu : règle `^/api/mail` visible. + +- [ ] **Step 3 : Test rapide 401 sans auth** + + ```bash + curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/folders + ``` + + Attendu : `401`. + +- [ ] **Step 4 : Commit** + + ```bash + git add config/packages/security.yaml + git commit -m "feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)" + ``` + +--- + +### Task 15 : Validation finale + tests complets + +- [ ] **Step 1 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent, y compris : + - Phase 1 : `MailConfigurationRepositoryTest` (2 tests) + - Phase 2 : `MailSyncReportTest`, `ImapMailProviderTest`, `MailSyncServiceTest`, `MailSyncCommandTest` (10 tests) + - Phase 3 : `MailSettingsControllerTest`, `MailFoldersControllerTest`, `MailMessagesControllerTest`, `MailTaskIntegrationControllerTest`, `MailSyncTriggerControllerTest` + +- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass phase 3" + ``` + +- [ ] **Step 3 : Vider le cache et vérifier le container** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:container 2>&1 | grep -i "mail" | head -20 + ``` + + Attendu : `MailAccessChecker`, `MailSettingsProvider`, `MailSettingsProcessor`, tous les controllers Mail visibles. + +- [ ] **Step 4 : Vérifier les routes enregistrées** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep -E "mail|task_mails" + ``` + + Attendu (routes minimum) : + + | Route name | Method | Path | + |---|---|---| + | `mail_configuration_test` | POST | `/api/mail/configuration/test` | + | `mail_folders_list` | GET | `/api/mail/folders` | + | `mail_messages_list` | GET | `/api/mail/folders/{folderPath}/messages` | + | `mail_message_detail` | GET | `/api/mail/messages/{id}` | + | `mail_message_read` | POST | `/api/mail/messages/{id}/read` | + | `mail_message_flag` | POST | `/api/mail/messages/{id}/flag` | + | `mail_create_task` | POST | `/api/mail/messages/{id}/create-task` | + | `mail_link_task` | POST | `/api/mail/messages/{id}/link-task` | + | `mail_unlink_task` | DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | + | `task_mails_list` | GET | `/api/tasks/{id}/mails` | + | `mail_attachment_download` | GET | `/api/mail/attachments/{downloadId}` | + | `mail_sync_trigger` | POST | `/api/mail/sync` | + + + Les routes API Platform pour `MailSettings` : + - `GET /api/mail/configuration` + - `PATCH /api/mail/configuration` + +- [ ] **Step 5 : Smoke test curl avec token admin** + + ```bash + # Test 401 sans auth + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/api/mail/folders + # Attendu : 401 + + # Test avec JWT (récupérer le cookie après login) + curl -s -c /tmp/cookies.txt -X POST http://localhost:8082/login_check \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' | jq . + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/configuration | jq . + # Attendu : JSON avec hasPassword, imapHost, etc. — pas de "password" en clair + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/folders | jq . + # Attendu : [] (aucun dossier en fixtures — normal) + + curl -s -b /tmp/cookies.txt -X POST http://localhost:8082/api/mail/sync | jq . + # Attendu : 202 + { "message": "Synchronisation démarrée en arrière-plan" } + ``` + +- [ ] **Step 6 : Résumé des commits de la phase** + + ```bash + git log --oneline feat/mail-integration ^develop | head -30 + ``` + + Commits attendus (en plus de ceux des phases 1 et 2) : + + 1. `feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)` + 2. `feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test` + 3. `feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)` + 4. `feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)` + 5. `feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)` + 6. `feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)` + 7. `feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag` + 8. `feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task` + 9. `feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers` + 10. `feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)` + 11. `feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine` + 12. `feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)` + 13. `feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)` + +- [ ] **Step 7 : Pousser la branche et notifier le user** + + ```bash + git push origin feat/mail-integration + ``` + + Rapport final au user : + - Fichiers créés : 26 nouveaux fichiers + - Fichiers modifiés : 3 (`MailMessageRepository`, `TaskRepository`, `security.yaml`) + - Endpoints exposés : 14 (2 API Platform + 12 custom controllers) + - Tests ajoutés : 5 fichiers de tests fonctionnels + - Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0) + - Prêt pour Phase 4 : Frontend services + Pinia store + +--- + +### Exigences techniques — Rappels + +- **Sécurité** : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur tous les controllers + `MailAccessChecker::ensureCanAccessMail()` au début de chaque action ROLE_USER + `ensureIsAdmin()` pour config. Voir spec complète ci-dessous. +- **Password** : jamais retourné en clair, même pour admin. Réponse GET config : `{ ..., hasPassword: true|false }`. PATCH avec password vide ou null = pas de changement. +- **Format réponses** : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422). +- **Strict types** : `declare(strict_types=1);` en tête de chaque fichier PHP. +- **Format commit** : `feat(mail) : ` (espace avant `:`) +- **Custom controllers** : `#[Route(path: '/api/mail/...', methods: ['...'], priority: 1)]` — obligatoire pour éviter conflit API Platform. +- **Cache key** : `mail_body_{md5($messageId)}` — md5 sanitise les caractères spéciaux du Message-ID. +- **Messenger** : bus `messenger.bus.default` (injectable via `MessageBusInterface`). Transport `async` via `doctrine://default`. Setup : `php bin/console messenger:setup-transports`. +- **TaskService** : si `TaskNumberProcessor` existe uniquement en tant que `State/Processor` API Platform (non-injectable), créer la Task directement avec persist+flush + `findMaxNumberForProject`. Vérifier les champs obligatoires de `Task` avant de coder. +- **Tests** : étendre `WebTestCase` + `$client->loginUser($user)` + mocker `MailProviderInterface` via `static::getContainer()->set()` pour les tests qui touchent IMAP en live. +- **Logs** : OK de logger les actions (user → action → message-id), **JAMAIS** body/password/attachments. + +### Spec sécurité explicite `MailAccessChecker` + +```php +// src/Security/MailAccessChecker.php +public function ensureCanAccessMail(?UserInterface $user): void +{ + if (!$user instanceof User) { + throw new AccessDeniedException('Authentication required'); + } + $roles = $user->getRoles(); + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } +} + +public function ensureIsAdmin(?UserInterface $user): void +{ + if (!$user instanceof User || !in_array('ROLE_ADMIN', $user->getRoles(), true)) { + throw new AccessDeniedException('Admin only'); + } +} +``` + +### Spec body cache + +```php +// Dans MailMessageDetailController +$cacheKey = 'mail_body_' . md5($message->getMessageId()); +$item = $this->cache->getItem($cacheKey); +if (!$item->isHit()) { + $detail = $this->mailProvider->fetchMessage($folderPath, $uid); + $item->set($detail); + $item->expiresAfter(300); // 5 min + $this->cache->save($item); +} +$detail = $item->get(); +``` + +Pool : `cache.app` (injecté via `CacheItemPoolInterface` — Symfony le résout automatiquement). + +--- + +### Self-Review + +#### Table de correspondance endpoints / controllers + +| Endpoint | Controller / Resource | Sécurité | +|---|---|---| +| `GET /api/mail/configuration` | `MailSettings` ApiResource (Provider) | `ROLE_ADMIN` | +| `PATCH /api/mail/configuration` | `MailSettings` ApiResource (Processor) | `ROLE_ADMIN` | +| `POST /api/mail/configuration/test` | `MailTestConnectionController` | `ROLE_ADMIN` | +| `GET /api/mail/folders` | `MailFoldersListController` | `MailAccessChecker` (ROLE_USER/ADMIN, pas CLIENT) | +| `GET /api/mail/folders/{path}/messages` | `MailMessagesListController` | idem | +| `GET /api/mail/messages/{id}` | `MailMessageDetailController` | idem | +| `POST /api/mail/messages/{id}/read` | `MailMessageReadController` | idem | +| `POST /api/mail/messages/{id}/flag` | `MailMessageFlagController` | idem | +| `POST /api/mail/messages/{id}/create-task` | `MailCreateTaskController` | idem | +| `POST /api/mail/messages/{id}/link-task` | `MailLinkTaskController` | idem | +| `DELETE /api/mail/messages/{id}/link-task/{taskId}` | `MailUnlinkTaskController` | idem | +| `GET /api/tasks/{id}/mails` | `TaskMailsListController` | idem | +| `GET /api/mail/attachments/{downloadId}` | `MailAttachmentDownloadController` | idem | +| `POST /api/mail/sync` | `MailSyncTriggerController` | idem | + +#### Checklist finale avant de valider Phase 3 + +- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé +- [ ] Tous les custom controllers ont `priority: 1` sur leur `#[Route]` +- [ ] `MailAccessChecker` injecté et appelé dans chaque controller "métier" (hors admin) +- [ ] `password` jamais sérialisé en sortie (groupe `mail_settings:read` ne contient pas `password`) +- [ ] `hasPassword` retourné en lecture (bool uniquement) +- [ ] Cache key utilise `md5()` pour sanitiser le `messageId` (peut contenir `<>`, `@`, espaces) +- [ ] `Content-Disposition: attachment` systématique pour `MailAttachmentDownloadController` (jamais inline) +- [ ] `X-Content-Type-Options: nosniff` sur les réponses d'attachments +- [ ] Messenger configuré : transport `async` → `doctrine://default`, `MailSyncRequested` routé vers `async` +- [ ] `php bin/console messenger:setup-transports` exécuté (table `messenger_messages` créée) +- [ ] `security.yaml` : règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` ajoutée AVANT `^/api` +- [ ] ROLE_CLIENT refusé sur 100% des endpoints mail (test 403 présent dans chaque test file) +- [ ] Aucun body/password/attachment loggé dans les controllers ou handlers +- [ ] `make test` vert +- [ ] Smoke tests curl OK (401 sans auth, JSON correct avec admin, 202 sync trigger) +- [ ] Phase 3 NE contient PAS de frontend — tout ça = Phase 4+ +- [ ] Branche de travail : `feat/mail-integration` (pas `develop`) + +#### Remise à la review humaine + +Une fois tous les steps cochés, pousser la branche et notifier le user : + +```bash +git push origin feat/mail-integration +``` + +Indiquer au user : +- Endpoints créés : 14 (2 API Platform singleton + 12 custom controllers) +- Fichiers créés : 26 +- Fichiers modifiés : 3 +- Tests fonctionnels ajoutés : 5 fichiers (~20 assertions) +- Dépendances ajoutées : aucune (Messenger + Cache inclus dans Symfony 8.0) +- Prêt pour Phase 4 : Frontend services + Pinia store + DOMPurify