91 KiB
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
git branch --show-currentAttendu :
feat/mail-integration. Si non, basculer :git checkout feat/mail-integration -
Step 2 : Créer les dossiers namespace
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)ls src/Security/Attendu :
ApiTokenAuthenticator.phpprésent. Le dossier existe déjà — pas besoin de le recréer. -
Step 4 : Vérifier l'autowiring de base
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 -5Attendu : 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 declare(strict_types=1); namespace App\Tests\Functional\Controller\Mail; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MailSettingsControllerTest extends WebTestCase { public function testGetConfigurationReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('GET', '/api/mail/configuration'); self::assertResponseStatusCodeSame(401); } public function testGetConfigurationReturns403ForRoleUser(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); $client->request('GET', '/api/mail/configuration'); self::assertResponseStatusCodeSame(403); } public function testGetConfigurationReturns200ForAdmin(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); $client->loginUser($admin); $client->request('GET', '/api/mail/configuration'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); // Password jamais en clair self::assertArrayNotHasKey('password', $data); self::assertArrayNotHasKey('encryptedPassword', $data); self::assertArrayHasKey('hasPassword', $data); self::assertArrayHasKey('imapHost', $data); self::assertArrayHasKey('enabled', $data); } public function testPatchConfigurationReturns403ForRoleUser(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); $client->request( 'PATCH', '/api/mail/configuration', [], [], ['CONTENT_TYPE' => 'application/merge-patch+json'], json_encode(['enabled' => false]) ); self::assertResponseStatusCodeSame(403); } public function testPatchConfigurationUpdatesFieldsForAdmin(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); $client->loginUser($admin); $client->request( 'PATCH', '/api/mail/configuration', [], [], ['CONTENT_TYPE' => 'application/merge-patch+json'], json_encode(['imapHost' => 'imap.example.com', 'enabled' => false]) ); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertSame('imap.example.com', $data['imapHost']); self::assertArrayNotHasKey('password', $data); } public function testPatchConfigurationWithPasswordEncryptsIt(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); $client->loginUser($admin); $client->request( 'PATCH', '/api/mail/configuration', [], [], ['CONTENT_TYPE' => 'application/merge-patch+json'], json_encode(['password' => 'secret123']) ); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertTrue($data['hasPassword']); self::assertArrayNotHasKey('password', $data); } } -
Step 2 : Lancer le test pour vérifier qu'il échoue
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20Attendu : 404 (route non créée).
-
Step 3 : Créer
src/ApiResource/MailSettings.phpCalqué exactement sur
src/ApiResource/ZimbraSettings.php, mais pourMailConfiguration:<?php declare(strict_types=1); namespace App\ApiResource; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Patch; use App\State\Mail\MailSettingsProcessor; use App\State\Mail\MailSettingsProvider; use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ new Get( uriTemplate: '/mail/configuration', normalizationContext: ['groups' => ['mail_settings:read']], provider: MailSettingsProvider::class, security: "is_granted('ROLE_ADMIN')", ), new Patch( uriTemplate: '/mail/configuration', denormalizationContext: ['groups' => ['mail_settings:write']], normalizationContext: ['groups' => ['mail_settings:read']], provider: MailSettingsProvider::class, processor: MailSettingsProcessor::class, security: "is_granted('ROLE_ADMIN')", ), ], )] final class MailSettings { #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $protocol = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $imapHost = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?int $imapPort = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $imapEncryption = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $smtpHost = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?int $smtpPort = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $smtpEncryption = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $username = null; /** Write-only: jamais retourné en lecture */ #[Groups(['mail_settings:write'])] public ?string $password = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public ?string $sentFolderPath = null; #[Groups(['mail_settings:read', 'mail_settings:write'])] public bool $enabled = false; /** Lecture seule : indique si un password chiffré existe */ #[Groups(['mail_settings:read'])] public bool $hasPassword = false; } -
Step 4 : Créer
src/State/Mail/MailSettingsProvider.php<?php declare(strict_types=1); namespace App\State\Mail; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\ApiResource\MailSettings; use App\Repository\MailConfigurationRepository; final readonly class MailSettingsProvider implements ProviderInterface { public function __construct( private MailConfigurationRepository $configRepository, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): MailSettings { $config = $this->configRepository->findSingleton(); $dto = new MailSettings(); if (null !== $config) { $dto->protocol = $config->getProtocol(); $dto->imapHost = $config->getImapHost(); $dto->imapPort = $config->getImapPort(); $dto->imapEncryption = $config->getImapEncryption(); $dto->smtpHost = $config->getSmtpHost(); $dto->smtpPort = $config->getSmtpPort(); $dto->smtpEncryption = $config->getSmtpEncryption(); $dto->username = $config->getUsername(); $dto->sentFolderPath = $config->getSentFolderPath(); $dto->enabled = $config->isEnabled(); $dto->hasPassword = $config->hasPassword(); // password JAMAIS retourné } return $dto; } } -
Step 5 : Créer
src/State/Mail/MailSettingsProcessor.php<?php declare(strict_types=1); namespace App\State\Mail; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\MailSettings; use App\Entity\MailConfiguration; use App\Repository\MailConfigurationRepository; use App\Service\TokenEncryptor; use Doctrine\ORM\EntityManagerInterface; final readonly class MailSettingsProcessor implements ProcessorInterface { public function __construct( private EntityManagerInterface $em, private MailConfigurationRepository $configRepository, private TokenEncryptor $tokenEncryptor, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MailSettings { assert($data instanceof MailSettings); $config = $this->configRepository->findSingleton(); if (null === $config) { $config = new MailConfiguration(); } if (null !== $data->protocol) { $config->setProtocol($data->protocol); } if (null !== $data->imapHost) { $config->setImapHost($data->imapHost); } if (null !== $data->imapPort) { $config->setImapPort($data->imapPort); } if (null !== $data->imapEncryption) { $config->setImapEncryption($data->imapEncryption); } if (null !== $data->smtpHost) { $config->setSmtpHost($data->smtpHost); } if (null !== $data->smtpPort) { $config->setSmtpPort($data->smtpPort); } if (null !== $data->smtpEncryption) { $config->setSmtpEncryption($data->smtpEncryption); } if (null !== $data->username) { $config->setUsername($data->username); } if (null !== $data->sentFolderPath) { $config->setSentFolderPath($data->sentFolderPath); } $config->setEnabled($data->enabled); // Password : seulement si fourni non-vide if (null !== $data->password && '' !== $data->password) { $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); } $this->em->persist($config); $this->em->flush(); $result = new MailSettings(); $result->protocol = $config->getProtocol(); $result->imapHost = $config->getImapHost(); $result->imapPort = $config->getImapPort(); $result->imapEncryption = $config->getImapEncryption(); $result->smtpHost = $config->getSmtpHost(); $result->smtpPort = $config->getSmtpPort(); $result->smtpEncryption = $config->getSmtpEncryption(); $result->username = $config->getUsername(); $result->sentFolderPath = $config->getSentFolderPath(); $result->enabled = $config->isEnabled(); $result->hasPassword = $config->hasPassword(); // password JAMAIS retourné return $result; } } -
Step 6 : Vérifier la syntaxe PHP
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.phpAttendu :
No syntax errors detectedpour les trois. -
Step 7 : Vider le cache et relancer les tests
make cache-clear docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20Attendu : tous les tests passent.
-
Step 8 : Commit
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 declare(strict_types=1); namespace App\Controller\Mail; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Throwable; #[Route('/api/mail/configuration/test', name: 'mail_configuration_test', methods: ['POST'], priority: 1)] #[IsGranted('ROLE_ADMIN')] class MailTestConnectionController extends AbstractController { public function __construct( private readonly MailProviderInterface $mailProvider, ) {} public function __invoke(): JsonResponse { try { $folders = $this->mailProvider->listFolders(); return $this->json([ 'ok' => true, 'foldersCount' => count($folders), ]); } catch (MailProviderException $e) { return $this->json([ 'ok' => false, 'error' => 'Connexion IMAP impossible. Vérifiez la configuration.', ], 200); // 200 intentionnel : c'est un résultat de test, pas une erreur HTTP } catch (Throwable) { return $this->json([ 'ok' => false, 'error' => 'Erreur inattendue lors du test de connexion.', ], 200); } } }Note :
priority: 1est 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
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailTestConnectionController.php -
Step 3 : Vérifier que la route est enregistrée
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_configuration_testAttendu : route
mail_configuration_testvisible avec méthode POST. -
Step 4 : Commit
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 declare(strict_types=1); namespace App\Security; use App\Entity\User; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; final readonly class MailAccessChecker { public function __construct( private AuthorizationCheckerInterface $authorizationChecker, ) {} /** * Vérifie que l'utilisateur courant peut accéder aux endpoints mail. * Autorisé : ROLE_USER, ROLE_ADMIN. * Refusé : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifié. * * @throws AccessDeniedException */ public function ensureCanAccessMail(?UserInterface $user): void { if (!$user instanceof User) { throw new AccessDeniedException('Authentication required'); } $roles = $user->getRoles(); if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { throw new AccessDeniedException('Mail not accessible to clients'); } if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { throw new AccessDeniedException('ROLE_USER required'); } } /** * Vérifie que l'utilisateur est ROLE_ADMIN. * * @throws AccessDeniedException */ public function ensureIsAdmin(?UserInterface $user): void { if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Admin only'); } } } -
Step 2 : Vérifier la syntaxe PHP
docker exec php-lesstime-fpm php -l src/Security/MailAccessChecker.php -
Step 3 : Commit
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 declare(strict_types=1); namespace App\Tests\Functional\Controller\Mail; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MailFoldersControllerTest extends WebTestCase { public function testListFoldersReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('GET', '/api/mail/folders'); self::assertResponseStatusCodeSame(401); } public function testListFoldersReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('GET', '/api/mail/folders'); self::assertResponseStatusCodeSame(403); } public function testListFoldersReturns200ForRoleUser(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); $client->request('GET', '/api/mail/folders'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertIsArray($data); } } -
Step 2 : Créer
src/Controller/Mail/MailFoldersListController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Repository\MailFolderRepository; use App\Security\MailAccessChecker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/folders', name: 'mail_folders_list', methods: ['GET'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailFoldersListController extends AbstractController { public function __construct( private readonly MailFolderRepository $folderRepository, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $folders = $this->folderRepository->findAllOrderedByPath(); $data = array_map(static fn ($folder) => [ 'id' => $folder->getId(), 'path' => $folder->getPath(), 'displayName' => $folder->getDisplayName(), 'parentPath' => $folder->getParentPath(), 'unreadCount' => $folder->getUnreadCount(), 'totalCount' => $folder->getTotalCount(), 'lastSyncedAt' => $folder->getLastSyncedAt()?->format(\DateTimeInterface::ATOM), ], $folders); return $this->json($data); } } -
Step 3 : Vérifier la syntaxe et la route
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
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailFoldersControllerTest.php 2>&1 | tail -15Attendu : tous les tests passent.
-
Step 5 : Commit
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
findByFolderCursordansMailMessageRepositoryOuvrir
src/Repository/MailMessageRepository.phpet ajouter :/** * Pagination cursor: retourne `$limit` messages après le cursor `sentAt DESC, id DESC`. * Cursor format: "sentAt_iso8601:id" — null = première page. * * @return array{ messages: list<MailMessage>, nextCursor: ?string } */ public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array { $qb = $this->createQueryBuilder('m') ->andWhere('m.folder = :folder') ->setParameter('folder', $folder) ->orderBy('m.sentAt', 'DESC') ->addOrderBy('m.id', 'DESC') ->setMaxResults($limit + 1); // +1 pour détecter s'il y a une page suivante if (null !== $cursor) { // cursor = base64url(sentAt_iso:id) $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); if (false !== $decoded && str_contains($decoded, ':')) { [$sentAtStr, $idStr] = explode(':', $decoded, 2); $cursorSentAt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $sentAtStr); $cursorId = (int) $idStr; if ($cursorSentAt instanceof \DateTimeImmutable) { $qb ->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)') ->setParameter('cursorSentAt', $cursorSentAt) ->setParameter('cursorId', $cursorId); } } } /** @var list<MailMessage> $results */ $results = $qb->getQuery()->getResult(); $hasMore = count($results) > $limit; $messages = $hasMore ? array_slice($results, 0, $limit) : $results; $nextCursor = null; if ($hasMore && [] !== $messages) { $last = end($messages); $raw = $last->getSentAt()->format(\DateTimeInterface::ATOM).':'.$last->getId(); $nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } return ['messages' => $messages, 'nextCursor' => $nextCursor]; } -
Step 2 : Créer
src/Controller/Mail/MailMessagesListController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Repository\MailFolderRepository; use App\Repository\MailMessageRepository; use App\Security\MailAccessChecker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/folders/{folderPath}/messages', name: 'mail_messages_list', methods: ['GET'], priority: 1, requirements: ['folderPath' => '.+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailMessagesListController extends AbstractController { public function __construct( private readonly MailFolderRepository $folderRepository, private readonly MailMessageRepository $messageRepository, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(Request $request, string $folderPath): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); // folderPath peut être URL-encodé si contient des slashes $decodedPath = urldecode($folderPath); $folder = $this->folderRepository->findByPath($decodedPath); if (null === $folder) { throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath)); } $limit = min((int) ($request->query->get('limit', 50)), 100); $cursor = $request->query->get('cursor'); $result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null); $messages = array_map(static fn ($m) => [ 'id' => $m->getId(), 'messageId' => $m->getMessageId(), 'uid' => $m->getUid(), 'subject' => $m->getSubject(), 'fromAddress' => $m->getFromAddress(), 'fromName' => $m->getFromName(), 'toAddresses' => $m->getToAddresses(), 'ccAddresses' => $m->getCcAddresses(), 'sentAt' => $m->getSentAt()->format(\DateTimeInterface::ATOM), 'isRead' => $m->isRead(), 'isFlagged' => $m->isFlagged(), 'hasAttachments' => $m->hasAttachments(), 'snippet' => $m->getSnippet(), ], $result['messages']); return $this->json([ 'messages' => $messages, 'nextCursor' => $result['nextCursor'], ]); } }Note :
requirements: ['folderPath' => '.+']permet àfolderPathde 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
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
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 declare(strict_types=1); namespace App\Tests\Functional\Controller\Mail; use App\Entity\User; use App\Mail\MailProviderInterface; use App\Mail\Dto\MailMessageDetailDto; use App\Mail\Dto\MailMessageHeaderDto; use DateTimeImmutable; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MailMessagesControllerTest extends WebTestCase { public function testGetMessageDetailReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('GET', '/api/mail/messages/999'); self::assertResponseStatusCodeSame(401); } public function testGetMessageDetailReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('GET', '/api/mail/messages/999'); self::assertResponseStatusCodeSame(403); } public function testGetMessageDetailReturns404WhenMessageNotFound(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); $client->request('GET', '/api/mail/messages/99999'); self::assertResponseStatusCodeSame(404); } public function testMarkReadReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); self::assertResponseStatusCodeSame(401); } public function testMarkReadReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); self::assertResponseStatusCodeSame(403); } } -
Step 2 : Créer
src/Controller/Mail/MailMessageDetailController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use App\Repository\MailMessageRepository; use App\Security\MailAccessChecker; use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}', name: 'mail_message_detail', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailMessageDetailController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly MailProviderInterface $mailProvider, private readonly MailAccessChecker $accessChecker, private readonly CacheItemPoolInterface $cache, ) {} public function __invoke(int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } // Cache key basé sur messageId (md5 pour sanitiser les caractères spéciaux) $cacheKey = 'mail_body_' . md5($message->getMessageId()); $item = $this->cache->getItem($cacheKey); if (!$item->isHit()) { try { $detail = $this->mailProvider->fetchMessage( $message->getFolder()->getPath(), $message->getUid() ); $item->set($detail); $item->expiresAfter(300); // 5 minutes $this->cache->save($item); } catch (MailProviderException $e) { throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body'); } } $detail = $item->get(); // Sérialiser les attachments (sans contenu binaire — seulement metadata) $attachments = array_map(static fn ($att) => [ 'partNumber' => $att->partNumber, 'filename' => $att->filename, 'mimeType' => $att->mimeType, 'size' => $att->size, // URL téléchargement : /api/mail/attachments/{messageId_base64}:{partNumber_base64} 'downloadId' => rtrim(strtr(base64_encode($message->getId().':'.$att->partNumber), '+/', '-_'), '='), ], $detail->attachments); return $this->json([ 'id' => $message->getId(), 'messageId' => $message->getMessageId(), 'uid' => $message->getUid(), 'folderPath' => $message->getFolder()->getPath(), 'subject' => $detail->header->subject, 'fromAddress' => $detail->header->fromAddress, 'fromName' => $detail->header->fromName, 'toAddresses' => $detail->header->toAddresses, 'ccAddresses' => $detail->header->ccAddresses, 'sentAt' => $detail->header->sentAt->format(\DateTimeInterface::ATOM), 'isRead' => $message->isRead(), 'isFlagged' => $message->isFlagged(), 'hasAttachments' => $message->hasAttachments(), 'bodyHtml' => $detail->bodyHtml, // ATTENTION : côté frontend, sanitiser via DOMPurify 'bodyText' => $detail->bodyText, 'attachments' => $attachments, ]); } }Note sur le
CacheItemPoolInterface: Symfony injectera le pool par défautcache.app. Aucune configuration supplémentaire nécessaire —cache.appest défini dans le framework par défaut. -
Step 3 : Vérifier la syntaxe et la route
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
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailMessagesControllerTest.php 2>&1 | tail -20Attendu : les tests 401/403/404 passent.
-
Step 5 : Commit
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 declare(strict_types=1); namespace App\Controller\Mail; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use App\Repository\MailMessageRepository; use App\Security\MailAccessChecker; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}/read', name: 'mail_message_read', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailMessageReadController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly MailProviderInterface $mailProvider, private readonly EntityManagerInterface $em, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(Request $request, int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } $body = json_decode($request->getContent(), true); $read = (bool) ($body['read'] ?? true); try { $this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read); } catch (MailProviderException) { // Non-bloquant : on met quand même à jour la BDD (sync IMAP au prochain cycle) } $message->setIsRead($read); $this->em->flush(); return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]); } } -
Step 2 : Créer
src/Controller/Mail/MailMessageFlagController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use App\Repository\MailMessageRepository; use App\Security\MailAccessChecker; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}/flag', name: 'mail_message_flag', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailMessageFlagController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly MailProviderInterface $mailProvider, private readonly EntityManagerInterface $em, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(Request $request, int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } $body = json_decode($request->getContent(), true); $flagged = (bool) ($body['flagged'] ?? true); try { $this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged); } catch (MailProviderException) { // Non-bloquant } $message->setIsFlagged($flagged); $this->em->flush(); return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]); } } -
Step 3 : Vérifier la syntaxe
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
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
TaskNumberProcessorou unTaskServiceexistedocker 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 taskSelon le résultat :
- Si
TaskNumberProcessorest unState/ProcessorAPI Platform (pas un service injectable) → créer la Task directement avec persist+flush, sans passer par le processor. - Si un
TaskServiceinjectable 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::numberest assigné dans l'existant et reproduire le même mécanisme. - Si
-
Step 2 : Créer
src/Controller/Mail/MailCreateTaskController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Entity\Task; use App\Entity\TaskMailLink; use App\Repository\MailMessageRepository; use App\Repository\TaskGroupRepository; use App\Repository\TaskPriorityRepository; use App\Repository\TaskRepository; use App\Security\MailAccessChecker; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}/create-task', name: 'mail_create_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailCreateTaskController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly EntityManagerInterface $em, private readonly MailAccessChecker $accessChecker, private readonly TaskRepository $taskRepository, ) {} public function __invoke(Request $request, int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } $body = json_decode($request->getContent(), true); $projectId = $body['projectId'] ?? null; if (null === $projectId) { throw new UnprocessableEntityHttpException('projectId is required'); } $project = $this->em->getRepository(\App\Entity\Project::class)->find($projectId); if (null === $project) { throw new NotFoundHttpException('Project not found'); } // Numéro de tâche : max existant + 1 pour ce projet $maxNumber = $this->taskRepository->findMaxNumberForProject($project); $number = $maxNumber + 1; // Titre = subject du mail (tronqué à 500 chars si nécessaire) $title = $message->getSubject() ?? 'Mail sans sujet'; if (mb_strlen($title) > 500) { $title = mb_substr($title, 0, 497).'...'; } $task = new Task(); $task->setProject($project); $task->setTitle($title); $task->setNumber($number); $task->setCreatedAt(new DateTimeImmutable()); // TaskGroup optionnel if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) { $taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']); if (null !== $taskGroup) { $task->setTaskGroup($taskGroup); } } // Priorité optionnelle if (isset($body['priorityId']) && null !== $body['priorityId']) { $priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']); if (null !== $priority) { $task->setPriority($priority); } } // Créateur = user courant $task->setCreatedBy($this->getUser()); $this->em->persist($task); // Lien mail ↔ tâche $link = new TaskMailLink(); $link->setTask($task); $link->setMailMessage($message); $link->setLinkedAt(new DateTimeImmutable()); $link->setLinkedBy($this->getUser()); $this->em->persist($link); $this->em->flush(); return $this->json([ 'taskId' => $task->getId(), 'taskNumber' => $task->getNumber(), 'taskTitle' => $task->getTitle(), 'messageId' => $message->getId(), ], 201); } }Note importante :
Task::setNumber(),Task::setCreatedAt(),Task::setCreatedBy(), etc. : vérifier les setters réels de l'entitéTaskavant 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 deTask.TaskRepository::findMaxNumberForProject: vérifier si cette méthode existe. Sinon la créer :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
# 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
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 declare(strict_types=1); namespace App\Controller\Mail; use App\Entity\Task; use App\Entity\TaskMailLink; use App\Repository\MailMessageRepository; use App\Repository\TaskMailLinkRepository; use App\Security\MailAccessChecker; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}/link-task', name: 'mail_link_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailLinkTaskController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly TaskMailLinkRepository $linkRepository, private readonly EntityManagerInterface $em, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(Request $request, int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } $body = json_decode($request->getContent(), true); $taskId = $body['taskId'] ?? null; if (null === $taskId) { throw new UnprocessableEntityHttpException('taskId is required'); } $task = $this->em->getRepository(Task::class)->find($taskId); if (null === $task) { throw new NotFoundHttpException('Task not found'); } // Éviter le doublon (contrainte unique en BDD, mais prévenir le 500) $existing = $this->linkRepository->findByTaskAndMessage($task, $message); if (null !== $existing) { return $this->json(['message' => 'Already linked'], 200); } $link = new TaskMailLink(); $link->setTask($task); $link->setMailMessage($message); $link->setLinkedAt(new DateTimeImmutable()); $link->setLinkedBy($this->getUser()); $this->em->persist($link); $this->em->flush(); return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201); } } -
Step 2 : Créer
src/Controller/Mail/MailUnlinkTaskController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Entity\Task; use App\Repository\MailMessageRepository; use App\Repository\TaskMailLinkRepository; use App\Security\MailAccessChecker; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/messages/{id}/link-task/{taskId}', name: 'mail_unlink_task', methods: ['DELETE'], priority: 1, requirements: ['id' => '\d+', 'taskId' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailUnlinkTaskController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly TaskMailLinkRepository $linkRepository, private readonly EntityManagerInterface $em, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(int $id, int $taskId): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $message = $this->messageRepository->find($id); if (null === $message) { throw new NotFoundHttpException('Message not found'); } $task = $this->em->getRepository(Task::class)->find($taskId); if (null === $task) { throw new NotFoundHttpException('Task not found'); } $link = $this->linkRepository->findByTaskAndMessage($task, $message); if (null === $link) { throw new NotFoundHttpException('Link not found'); } $this->em->remove($link); $this->em->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } } -
Step 3 : Créer
src/Controller/Mail/TaskMailsListController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Entity\Task; use App\Repository\TaskMailLinkRepository; use App\Security\MailAccessChecker; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/tasks/{id}/mails', name: 'task_mails_list', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] class TaskMailsListController extends AbstractController { public function __construct( private readonly EntityManagerInterface $em, private readonly TaskMailLinkRepository $linkRepository, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(int $id): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); $task = $this->em->getRepository(Task::class)->find($id); if (null === $task) { throw new NotFoundHttpException('Task not found'); } $links = $this->linkRepository->findByTask($task); $data = array_map(static fn ($link) => [ 'id' => $link->getMailMessage()->getId(), 'messageId' => $link->getMailMessage()->getMessageId(), 'subject' => $link->getMailMessage()->getSubject(), 'fromAddress' => $link->getMailMessage()->getFromAddress(), 'fromName' => $link->getMailMessage()->getFromName(), 'sentAt' => $link->getMailMessage()->getSentAt()->format(\DateTimeInterface::ATOM), 'isRead' => $link->getMailMessage()->isRead(), 'isFlagged' => $link->getMailMessage()->isFlagged(), 'snippet' => $link->getMailMessage()->getSnippet(), 'linkedAt' => $link->getLinkedAt()->format(\DateTimeInterface::ATOM), ], $links); return $this->json($data); } } -
Step 4 : Créer le test fonctionnel pour l'intégration tâches
Créer
tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php:<?php declare(strict_types=1); namespace App\Tests\Functional\Controller\Mail; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MailTaskIntegrationControllerTest extends WebTestCase { public function testLinkTaskReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); self::assertResponseStatusCodeSame(401); } public function testLinkTaskReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); self::assertResponseStatusCodeSame(403); } public function testUnlinkTaskReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('DELETE', '/api/mail/messages/1/link-task/1'); self::assertResponseStatusCodeSame(401); } public function testTaskMailsListReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('GET', '/api/tasks/1/mails'); self::assertResponseStatusCodeSame(401); } public function testTaskMailsListReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('GET', '/api/tasks/1/mails'); self::assertResponseStatusCodeSame(403); } public function testCreateTaskReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1])); self::assertResponseStatusCodeSame(401); } } -
Step 5 : Vérifier la syntaxe des 3 controllers
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
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php 2>&1 | tail -15 -
Step 7 : Commit
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 declare(strict_types=1); namespace App\Controller\Mail; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use App\Repository\MailMessageRepository; use App\Security\MailAccessChecker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/attachments/{downloadId}', name: 'mail_attachment_download', methods: ['GET'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailAttachmentDownloadController extends AbstractController { public function __construct( private readonly MailMessageRepository $messageRepository, private readonly MailProviderInterface $mailProvider, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(string $downloadId): Response { $this->accessChecker->ensureCanAccessMail($this->getUser()); // Décoder le downloadId : base64url(messageDbId:partNumber) $decoded = base64_decode(strtr($downloadId, '-_', '+/'), true); if (false === $decoded || !str_contains($decoded, ':')) { throw new BadRequestHttpException('Invalid attachment ID format'); } [$messageDbIdStr, $partNumber] = explode(':', $decoded, 2); $messageDbId = (int) $messageDbIdStr; $message = $this->messageRepository->find($messageDbId); if (null === $message) { throw new NotFoundHttpException('Message not found'); } // Trouver les métadonnées de l'attachment dans le detail (via cache ou live) // Pour l'instant, fetch live (le cache body sera utilisé si disponible) try { $detail = $this->mailProvider->fetchMessage( $message->getFolder()->getPath(), $message->getUid() ); } catch (MailProviderException) { throw new NotFoundHttpException('Could not fetch message from IMAP server'); } // Trouver le bon attachment par partNumber $targetAttachment = null; foreach ($detail->attachments as $att) { if ($att->partNumber === $partNumber) { $targetAttachment = $att; break; } } if (null === $targetAttachment) { throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber)); } // Fetch le contenu binaire try { $content = $this->mailProvider->fetchAttachment( $message->getFolder()->getPath(), $message->getUid(), $partNumber ); } catch (MailProviderException) { throw new NotFoundHttpException('Could not fetch attachment content'); } // Sanitiser le nom de fichier (éviter path traversal) $filename = basename($targetAttachment->filename); if ('' === $filename || '.' === $filename) { $filename = 'attachment'; } // Toujours Content-Disposition: attachment (jamais inline — risque XSS via HTML attachments) $response = new Response($content); $response->headers->set('Content-Type', $targetAttachment->mimeType); $response->headers->set( 'Content-Disposition', sprintf('attachment; filename="%s"', addslashes($filename)) ); $response->headers->set('Content-Length', (string) strlen($content)); $response->headers->set('X-Content-Type-Options', 'nosniff'); return $response; } } -
Step 2 : Vérifier la syntaxe
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailAttachmentDownloadController.php -
Step 3 : Commit
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 declare(strict_types=1); namespace App\Message; final readonly class MailSyncRequested { public function __construct( /** Optionnel : si null, sync tous les dossiers. Sinon, sync uniquement ce dossier. */ public ?string $folderPath = null, ) {} } -
Step 2 : Créer
src/MessageHandler/MailSyncRequestedHandler.php<?php declare(strict_types=1); namespace App\MessageHandler; use App\Message\MailSyncRequested; use App\Repository\MailFolderRepository; use App\Service\MailSyncService; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Throwable; #[AsMessageHandler] final readonly class MailSyncRequestedHandler { public function __construct( private MailSyncService $mailSyncService, private MailFolderRepository $folderRepository, private LoggerInterface $logger, ) {} public function __invoke(MailSyncRequested $message): void { try { if (null !== $message->folderPath) { $folder = $this->folderRepository->findByPath($message->folderPath); if (null !== $folder) { $report = $this->mailSyncService->syncFolder($folder); $this->logger->info(sprintf( 'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted', $message->folderPath, $report->createdCount, $report->updatedCount, $report->deletedCount, )); } else { $this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath)); } } else { $report = $this->mailSyncService->syncAll(); $this->logger->info(sprintf( 'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned', $report->createdCount, $report->updatedCount, $report->deletedCount, $report->foldersScanned, )); } } catch (Throwable $e) { $this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage()); // Ne pas throw — éviter la retry infinie en cas d'erreur IMAP permanente } } } -
Step 3 : Créer
config/packages/messenger.yamlframework: 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': asyncNote :
auto_setup=0signifie que la tablemessenger_messagesdoit être créée manuellement (viaphp bin/console messenger:setup-transports). Appeler cette commande en Task suivante. Si le projet n'a pas encore de table Messenger, utiliserauto_setup=truepour le développement. -
Step 4 : Setup des transports Messenger
docker exec php-lesstime-fpm php bin/console messenger:setup-transportsAttendu : table
messenger_messagescréée en BDD.Si erreur "table déjà existante" → déjà configuré, ignorer.
-
Step 5 : Vérifier la syntaxe
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é
docker exec php-lesstime-fpm php bin/console debug:messenger 2>&1 | grep MailAttendu :
App\Message\MailSyncRequested→App\MessageHandler\MailSyncRequestedHandler. -
Step 7 : Commit
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 declare(strict_types=1); namespace App\Tests\Functional\Controller\Mail; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MailSyncTriggerControllerTest extends WebTestCase { public function testSyncTriggerReturns401WhenNotAuthenticated(): void { $client = static::createClient(); $client->request('POST', '/api/mail/sync'); self::assertResponseStatusCodeSame(401); } public function testSyncTriggerReturns403ForRoleClient(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); $client->loginUser($clientUser); $client->request('POST', '/api/mail/sync'); self::assertResponseStatusCodeSame(403); } public function testSyncTriggerReturns202ForRoleUser(): void { $client = static::createClient(); $container = static::getContainer(); $em = $container->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); $client->request('POST', '/api/mail/sync'); // 202 Accepted : le message est dispatché, la sync se fait en async self::assertResponseStatusCodeSame(202); $data = json_decode($client->getResponse()->getContent(), true); self::assertArrayHasKey('message', $data); } } -
Step 2 : Créer
src/Controller/Mail/MailSyncTriggerController.php<?php declare(strict_types=1); namespace App\Controller\Mail; use App\Message\MailSyncRequested; use App\Security\MailAccessChecker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/mail/sync', name: 'mail_sync_trigger', methods: ['POST'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] class MailSyncTriggerController extends AbstractController { public function __construct( private readonly MessageBusInterface $bus, private readonly MailAccessChecker $accessChecker, ) {} public function __invoke(Request $request): JsonResponse { $this->accessChecker->ensureCanAccessMail($this->getUser()); // Optionnel : folder spécifique via body JSON $body = json_decode($request->getContent(), true) ?? []; $folderPath = $body['folderPath'] ?? null; $this->bus->dispatch(new MailSyncRequested($folderPath)); return $this->json( ['message' => 'Synchronisation démarrée en arrière-plan'], Response::HTTP_ACCEPTED // 202 ); } } -
Step 3 : Vérifier la syntaxe et la route
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
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php 2>&1 | tail -15Attendu : les trois tests passent (401, 403, 202).
Note : en environnement de test, Symfony Messenger utilise le transport
sync://par défaut (via la configwhen@test). Le message sera traité synchronement — c'est attendu et correct pour les tests fonctionnels. Ajouter dansconfig/packages/test/messenger.yamlsi nécessaire :framework: messenger: transports: async: dsn: 'in-memory://' -
Step 5 : Commit
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.yamlOuvrir
config/packages/security.yamlet localiser le blocaccess_control. Ajouter les lignes mail avant la règle générique^/api: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/mailavecIS_AUTHENTICATED_FULLYbloque les requêtes non authentifiées avant même d'atteindre les controllers. Les vérifications fines ROLE_USER vs ROLE_CLIENT sont faites dansMailAccessCheckerinjecté dans chaque controller. -
Step 2 : Vider le cache et vérifier
make cache-clear docker exec php-lesstime-fpm php bin/console debug:config security access_control 2>&1 | head -20Attendu : règle
^/api/mailvisible. -
Step 3 : Test rapide 401 sans auth
curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/foldersAttendu :
401. -
Step 4 : Commit
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
make testAttendu : 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
- Phase 1 :
-
Step 2 : PHP CS Fixer sur tous les fichiers modifiés
make php-cs-fixer-allow-riskySi des fichiers sont modifiés :
git add -p git commit -m "style(mail) : php-cs-fixer pass phase 3" -
Step 3 : Vider le cache et vérifier le container
make cache-clear docker exec php-lesstime-fpm php bin/console debug:container 2>&1 | grep -i "mail" | head -20Attendu :
MailAccessChecker,MailSettingsProvider,MailSettingsProcessor, tous les controllers Mail visibles. -
Step 4 : Vérifier les routes enregistrées
docker exec php-lesstime-fpm php bin/console debug:router | grep -E "mail|task_mails"Attendu (routes minimum) :
Route name Method Path mail_configuration_testPOST /api/mail/configuration/testmail_folders_listGET /api/mail/foldersmail_messages_listGET /api/mail/folders/{folderPath}/messagesmail_message_detailGET /api/mail/messages/{id}mail_message_readPOST /api/mail/messages/{id}/readmail_message_flagPOST /api/mail/messages/{id}/flagmail_create_taskPOST /api/mail/messages/{id}/create-taskmail_link_taskPOST /api/mail/messages/{id}/link-taskmail_unlink_taskDELETE /api/mail/messages/{id}/link-task/{taskId}task_mails_listGET /api/tasks/{id}/mailsmail_attachment_downloadGET /api/mail/attachments/{downloadId}mail_sync_triggerPOST /api/mail/sync- Les routes API Platform pour
MailSettings:
GET /api/mail/configurationPATCH /api/mail/configuration
- Les routes API Platform pour
-
Step 5 : Smoke test curl avec token admin
# 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
git log --oneline feat/mail-integration ^develop | head -30Commits attendus (en plus de ceux des phases 1 et 2) :
feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)feat(mail) : MailTestConnectionController — POST /api/mail/configuration/testfeat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flagfeat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-taskfeat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllersfeat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrinefeat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)
-
Step 7 : Pousser la branche et notifier le user
git push origin feat/mail-integrationRapport final au user :
- Fichiers créés : 26 nouveaux fichiers
- Fichiers modifiés : 3 (
MailMessageRepository,TaskRepository,security.yaml) - Endpoints exposés : 14 (2 API Platform + 12 custom controllers)
- Tests ajoutés : 5 fichiers de tests fonctionnels
- Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0)
- Prêt pour Phase 4 : Frontend services + Pinia store
Exigences techniques — Rappels
- Sécurité :
#[IsGranted('IS_AUTHENTICATED_FULLY')]sur tous les controllers +MailAccessChecker::ensureCanAccessMail()au début de chaque action ROLE_USER +ensureIsAdmin()pour config. Voir spec complète ci-dessous. - Password : jamais retourné en clair, même pour admin. Réponse GET config :
{ ..., hasPassword: true|false }. PATCH avec password vide ou null = pas de changement. - Format réponses : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422).
- Strict types :
declare(strict_types=1);en tête de chaque fichier PHP. - Format commit :
feat(mail) : <message>(espace avant:) - Custom controllers :
#[Route(path: '/api/mail/...', methods: ['...'], priority: 1)]— obligatoire pour éviter conflit API Platform. - Cache key :
mail_body_{md5($messageId)}— md5 sanitise les caractères spéciaux du Message-ID. - Messenger : bus
messenger.bus.default(injectable viaMessageBusInterface). Transportasyncviadoctrine://default. Setup :php bin/console messenger:setup-transports. - TaskService : si
TaskNumberProcessorexiste uniquement en tant queState/ProcessorAPI Platform (non-injectable), créer la Task directement avec persist+flush +findMaxNumberForProject. Vérifier les champs obligatoires deTaskavant de coder. - Tests : étendre
WebTestCase+$client->loginUser($user)+ mockerMailProviderInterfaceviastatic::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
// 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
// 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: 1sur leur#[Route] MailAccessCheckerinjecté et appelé dans chaque controller "métier" (hors admin)passwordjamais sérialisé en sortie (groupemail_settings:readne contient paspassword)hasPasswordretourné en lecture (bool uniquement)- Cache key utilise
md5()pour sanitiser lemessageId(peut contenir<>,@, espaces) Content-Disposition: attachmentsystématique pourMailAttachmentDownloadController(jamais inline)X-Content-Type-Options: nosniffsur les réponses d'attachments- Messenger configuré : transport
async→doctrine://default,MailSyncRequestedrouté versasync php bin/console messenger:setup-transportsexécuté (tablemessenger_messagescréée)security.yaml: règle^/api/mailavecIS_AUTHENTICATED_FULLYajouté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 testvert- 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(pasdevelop)
Remise à la review humaine
Une fois tous les steps cochés, pousser la branche et notifier le user :
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