# 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