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

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-current
    

    Attendu : 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.php pré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 -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
    
    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 -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
    
    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.php
    

    Attendu : No syntax errors detected pour 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 -20
    

    Attendu : 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: 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

    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_test
    

    Attendu : route mail_configuration_test visible 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 -15
    

    Attendu : 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 findByFolderCursor dans MailMessageRepository

    Ouvrir src/Repository/MailMessageRepository.php et 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 à 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

    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é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

    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 -20
    

    Attendu : 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 TaskNumberProcessor ou un TaskService existe

    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
    
    declare(strict_types=1);
    
    namespace App\Controller\Mail;
    
    use App\Entity\Task;
    use App\Entity\TaskMailLink;
    use App\Repository\MailMessageRepository;
    use App\Repository\TaskGroupRepository;
    use App\Repository\TaskPriorityRepository;
    use App\Repository\TaskRepository;
    use App\Security\MailAccessChecker;
    use DateTimeImmutable;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
    use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
    use Symfony\Component\Routing\Attribute\Route;
    use Symfony\Component\Security\Http\Attribute\IsGranted;
    
    #[Route('/api/mail/messages/{id}/create-task', name: 'mail_create_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    class MailCreateTaskController extends AbstractController
    {
        public function __construct(
            private readonly MailMessageRepository $messageRepository,
            private readonly EntityManagerInterface $em,
            private readonly MailAccessChecker $accessChecker,
            private readonly TaskRepository $taskRepository,
        ) {}
    
        public function __invoke(Request $request, int $id): JsonResponse
        {
            $this->accessChecker->ensureCanAccessMail($this->getUser());
    
            $message = $this->messageRepository->find($id);
            if (null === $message) {
                throw new NotFoundHttpException('Message not found');
            }
    
            $body      = json_decode($request->getContent(), true);
            $projectId = $body['projectId'] ?? null;
    
            if (null === $projectId) {
                throw new UnprocessableEntityHttpException('projectId is required');
            }
    
            $project = $this->em->getRepository(\App\Entity\Project::class)->find($projectId);
            if (null === $project) {
                throw new NotFoundHttpException('Project not found');
            }
    
            // Numéro de tâche : max existant + 1 pour ce projet
            $maxNumber = $this->taskRepository->findMaxNumberForProject($project);
            $number    = $maxNumber + 1;
    
            // Titre = subject du mail (tronqué à 500 chars si nécessaire)
            $title = $message->getSubject() ?? 'Mail sans sujet';
            if (mb_strlen($title) > 500) {
                $title = mb_substr($title, 0, 497).'...';
            }
    
            $task = new Task();
            $task->setProject($project);
            $task->setTitle($title);
            $task->setNumber($number);
            $task->setCreatedAt(new DateTimeImmutable());
    
            // TaskGroup optionnel
            if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) {
                $taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']);
                if (null !== $taskGroup) {
                    $task->setTaskGroup($taskGroup);
                }
            }
    
            // Priorité optionnelle
            if (isset($body['priorityId']) && null !== $body['priorityId']) {
                $priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']);
                if (null !== $priority) {
                    $task->setPriority($priority);
                }
            }
    
            // Créateur = user courant
            $task->setCreatedBy($this->getUser());
    
            $this->em->persist($task);
    
            // Lien mail ↔ tâche
            $link = new TaskMailLink();
            $link->setTask($task);
            $link->setMailMessage($message);
            $link->setLinkedAt(new DateTimeImmutable());
            $link->setLinkedBy($this->getUser());
            $this->em->persist($link);
    
            $this->em->flush();
    
            return $this->json([
                'taskId'     => $task->getId(),
                'taskNumber' => $task->getNumber(),
                'taskTitle'  => $task->getTitle(),
                'messageId'  => $message->getId(),
            ], 201);
        }
    }
    

    Note importante : Task::setNumber(), Task::setCreatedAt(), Task::setCreatedBy(), etc. : vérifier les setters réels de l'entité Task avant de coder. Si des champs obligatoires manquent (ex. status), injecter le repository correspondant et récupérer le statut par défaut ("À faire" ou premier statut du projet). Adapter selon la structure réelle de Task.

    TaskRepository::findMaxNumberForProject : vérifier si cette méthode existe. Sinon la créer :

    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}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.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

    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

    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 Mail
    

    Attendu : App\Message\MailSyncRequestedApp\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 -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 :

    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.yaml

    Ouvrir config/packages/security.yaml et localiser le bloc access_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/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

    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

    curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/folders
    

    Attendu : 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 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

    make php-cs-fixer-allow-risky
    

    Si 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 -20
    

    Attendu : 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_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

    # 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 -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

    git push origin feat/mail-integration
    

    Rapport final au user :

    • Fichiers créés : 26 nouveaux fichiers
    • Fichiers modifiés : 3 (MailMessageRepository, TaskRepository, security.yaml)
    • Endpoints exposés : 14 (2 API Platform + 12 custom controllers)
    • Tests ajoutés : 5 fichiers de tests fonctionnels
    • Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0)
    • Prêt pour Phase 4 : Frontend services + Pinia store

Exigences techniques — Rappels

  • Sécurité : #[IsGranted('IS_AUTHENTICATED_FULLY')] sur tous les controllers + MailAccessChecker::ensureCanAccessMail() au début de chaque action ROLE_USER + ensureIsAdmin() pour config. Voir spec complète ci-dessous.
  • Password : jamais retourné en clair, même pour admin. Réponse GET config : { ..., hasPassword: true|false }. PATCH avec password vide ou null = pas de changement.
  • Format réponses : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422).
  • Strict types : declare(strict_types=1); en tête de chaque fichier PHP.
  • Format commit : feat(mail) : <message> (espace avant :)
  • Custom controllers : #[Route(path: '/api/mail/...', methods: ['...'], priority: 1)] — obligatoire pour éviter conflit API Platform.
  • Cache key : mail_body_{md5($messageId)} — md5 sanitise les caractères spéciaux du Message-ID.
  • Messenger : bus messenger.bus.default (injectable via MessageBusInterface). Transport async via doctrine://default. Setup : php bin/console messenger:setup-transports.
  • TaskService : si TaskNumberProcessor existe uniquement en tant que State/Processor API Platform (non-injectable), créer la Task directement avec persist+flush + findMaxNumberForProject. Vérifier les champs obligatoires de Task avant de coder.
  • Tests : étendre WebTestCase + $client->loginUser($user) + mocker MailProviderInterface via static::getContainer()->set() pour les tests qui touchent IMAP en live.
  • Logs : OK de logger les actions (user → action → message-id), JAMAIS body/password/attachments.

Spec sécurité explicite MailAccessChecker

// 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: 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 asyncdoctrine://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 :

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