Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md

44 KiB

Mail Integration — Phase 1 : Backend Foundations

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: Créer les fondations BDD + interfaces de l'intégration mail OVH IMAP (4 entités, 4 repositories, 1 migration, 4 DTOs, 1 interface, 1 exception, 1 fixture, tests). Aucune logique métier.

Architecture: Singleton MailConfiguration (calqué sur ZimbraConfiguration), MailFolder (cache arbre IMAP), MailMessage (cache headers + snippet), TaskMailLink (join). Interface MailProviderInterface définit le contrat — implémentation en Phase 2.

Tech Stack: PHP 8.4, Symfony 8.0, Doctrine ORM avec attributs PHP 8, PostgreSQL 16, PHPUnit + DAMA DoctrineTestBundle.

Branche cible : feat/mail-integration (créer si elle n'existe pas).

Fichiers créés/modifiés par le codeur :

Fichier Action
src/Entity/MailConfiguration.php Créer
src/Entity/MailFolder.php Créer
src/Entity/MailMessage.php Créer
src/Entity/TaskMailLink.php Créer
src/Repository/MailConfigurationRepository.php Créer
src/Repository/MailFolderRepository.php Créer
src/Repository/MailMessageRepository.php Créer
src/Repository/TaskMailLinkRepository.php Créer
src/Mail/MailProviderInterface.php Créer
src/Mail/Exception/MailProviderException.php Créer
src/Mail/Dto/MailFolderDto.php Créer
src/Mail/Dto/MailMessageHeaderDto.php Créer
src/Mail/Dto/MailAttachmentDto.php Créer
src/Mail/Dto/MailMessageDetailDto.php Créer
migrations/Version<timestamp>.php Créer
src/DataFixtures/AppFixtures.php Modifier
tests/Unit/Repository/MailConfigurationRepositoryTest.php Créer

Task 1 : Préparation — branche + dossiers

  • Step 1 : Vérifier / créer la branche

    git checkout feat/mail-integration 2>/dev/null || git checkout -b feat/mail-integration
    
  • Step 2 : Créer les dossiers namespace

    mkdir -p src/Mail/Dto src/Mail/Exception
    mkdir -p tests/Unit/Repository
    
  • Step 3 : Vérifier que les dossiers sont dans l'autoloader Composer

    Ouvrir composer.json et confirmer que "App\\": "src/" est présent dans autoload.psr-4. Si oui, rien à faire (les sous-dossiers src/Mail/ sont automatiquement couverts).


Task 2 : Entité MailConfiguration + Repository + test singleton

Step 1 : Écrire le test (TDD — il doit échouer)

Créer tests/Unit/Repository/MailConfigurationRepositoryTest.php :

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository;

use App\Entity\MailConfiguration;
use App\Repository\MailConfigurationRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class MailConfigurationRepositoryTest extends KernelTestCase
{
    private MailConfigurationRepository $repository;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();
        $this->repository = $container->get(MailConfigurationRepository::class);
    }

    public function testFindSingletonReturnsNullWhenEmpty(): void
    {
        $result = $this->repository->findSingleton();

        self::assertNull($result);
    }

    public function testFindSingletonReturnsFirstRecord(): void
    {
        $container = static::getContainer();
        $em = $container->get('doctrine.orm.entity_manager');

        $config = new MailConfiguration();
        $config->setImapHost('ssl0.ovh.net');
        $config->setEnabled(false);
        $em->persist($config);
        $em->flush();

        $result = $this->repository->findSingleton();

        self::assertInstanceOf(MailConfiguration::class, $result);
        self::assertSame('ssl0.ovh.net', $result->getImapHost());
        self::assertFalse($result->isEnabled());
    }
}
  • Step 2 : Lancer le test pour vérifier qu'il échoue

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20
    

    Attendu : erreur de classe manquante (App\Entity\MailConfiguration).

  • Step 3 : Créer l'entité MailConfiguration

    Créer src/Entity/MailConfiguration.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity;
    
    use App\Repository\MailConfigurationRepository;
    use Doctrine\ORM\Mapping as ORM;
    
    #[ORM\Entity(repositoryClass: MailConfigurationRepository::class)]
    #[ORM\Table(name: 'mail_configuration')]
    class MailConfiguration
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\Column(length: 10)]
        private string $protocol = 'imap';
    
        #[ORM\Column(length: 255, nullable: true)]
        private ?string $imapHost = null;
    
        #[ORM\Column]
        private int $imapPort = 993;
    
        #[ORM\Column(length: 10)]
        private string $imapEncryption = 'ssl';
    
        #[ORM\Column(length: 255, nullable: true)]
        private ?string $smtpHost = null;
    
        #[ORM\Column]
        private int $smtpPort = 465;
    
        #[ORM\Column(length: 10)]
        private string $smtpEncryption = 'ssl';
    
        #[ORM\Column(length: 255, nullable: true)]
        private ?string $username = null;
    
        #[ORM\Column(type: 'text', nullable: true)]
        private ?string $encryptedPassword = null;
    
        #[ORM\Column(length: 255)]
        private string $sentFolderPath = 'Sent';
    
        #[ORM\Column(type: 'boolean')]
        private bool $enabled = false;
    
        public function getId(): ?int
        {
            return $this->id;
        }
    
        public function getProtocol(): string
        {
            return $this->protocol;
        }
    
        public function setProtocol(string $protocol): static
        {
            $this->protocol = $protocol;
    
            return $this;
        }
    
        public function getImapHost(): ?string
        {
            return $this->imapHost;
        }
    
        public function setImapHost(?string $imapHost): static
        {
            $this->imapHost = $imapHost;
    
            return $this;
        }
    
        public function getImapPort(): int
        {
            return $this->imapPort;
        }
    
        public function setImapPort(int $imapPort): static
        {
            $this->imapPort = $imapPort;
    
            return $this;
        }
    
        public function getImapEncryption(): string
        {
            return $this->imapEncryption;
        }
    
        public function setImapEncryption(string $imapEncryption): static
        {
            $this->imapEncryption = $imapEncryption;
    
            return $this;
        }
    
        public function getSmtpHost(): ?string
        {
            return $this->smtpHost;
        }
    
        public function setSmtpHost(?string $smtpHost): static
        {
            $this->smtpHost = $smtpHost;
    
            return $this;
        }
    
        public function getSmtpPort(): int
        {
            return $this->smtpPort;
        }
    
        public function setSmtpPort(int $smtpPort): static
        {
            $this->smtpPort = $smtpPort;
    
            return $this;
        }
    
        public function getSmtpEncryption(): string
        {
            return $this->smtpEncryption;
        }
    
        public function setSmtpEncryption(string $smtpEncryption): static
        {
            $this->smtpEncryption = $smtpEncryption;
    
            return $this;
        }
    
        public function getUsername(): ?string
        {
            return $this->username;
        }
    
        public function setUsername(?string $username): static
        {
            $this->username = $username;
    
            return $this;
        }
    
        public function getEncryptedPassword(): ?string
        {
            return $this->encryptedPassword;
        }
    
        public function setEncryptedPassword(?string $encryptedPassword): static
        {
            $this->encryptedPassword = $encryptedPassword;
    
            return $this;
        }
    
        public function getSentFolderPath(): string
        {
            return $this->sentFolderPath;
        }
    
        public function setSentFolderPath(string $sentFolderPath): static
        {
            $this->sentFolderPath = $sentFolderPath;
    
            return $this;
        }
    
        public function isEnabled(): bool
        {
            return $this->enabled;
        }
    
        public function setEnabled(bool $enabled): static
        {
            $this->enabled = $enabled;
    
            return $this;
        }
    
        public function hasPassword(): bool
        {
            return null !== $this->encryptedPassword;
        }
    }
    
  • Step 4 : Créer le repository MailConfigurationRepository

    Créer src/Repository/MailConfigurationRepository.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository;
    
    use App\Entity\MailConfiguration;
    use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    use Doctrine\Persistence\ManagerRegistry;
    
    class MailConfigurationRepository extends ServiceEntityRepository
    {
        public function __construct(ManagerRegistry $registry)
        {
            parent::__construct($registry, MailConfiguration::class);
        }
    
        public function findSingleton(): ?MailConfiguration
        {
            return $this->createQueryBuilder('m')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult()
            ;
        }
    }
    
  • Step 5 : Lancer le test pour vérifier qu'il passe

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20
    

    Attendu : OK (2 tests, 2 assertions).

    Note : si le test testFindSingletonReturnsNullWhenEmpty échoue car des données persistent entre tests, vérifier que DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver est bien configuré dans config/packages/test/dama_doctrine_test_bundle.yaml. Si non configuré, ajouter le @runInSeparateProcess en dernier recours — mais normalement le projet utilise déjà DAMA.

  • Step 6 : Commit

    make php-cs-fixer-allow-risky
    git add src/Entity/MailConfiguration.php src/Repository/MailConfigurationRepository.php tests/Unit/Repository/MailConfigurationRepositoryTest.php
    git commit -m "feat(mail) : MailConfiguration entity + repository + singleton test"
    

Task 3 : Entité MailFolder + Repository

  • Step 1 : Créer l'entité MailFolder

    Créer src/Entity/MailFolder.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity;
    
    use App\Repository\MailFolderRepository;
    use DateTimeImmutable;
    use Doctrine\ORM\Mapping as ORM;
    
    #[ORM\Entity(repositoryClass: MailFolderRepository::class)]
    #[ORM\Table(name: 'mail_folder')]
    #[ORM\Index(columns: ['parent_path'], name: 'idx_mail_folder_parent_path')]
    class MailFolder
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\Column(length: 500, unique: true)]
        private string $path;
    
        #[ORM\Column(length: 255)]
        private string $displayName;
    
        #[ORM\Column(length: 500, nullable: true)]
        private ?string $parentPath = null;
    
        #[ORM\Column]
        private int $unreadCount = 0;
    
        #[ORM\Column]
        private int $totalCount = 0;
    
        #[ORM\Column(type: 'datetimetz_immutable', nullable: true)]
        private ?DateTimeImmutable $lastSyncedAt = null;
    
        public function getId(): ?int
        {
            return $this->id;
        }
    
        public function getPath(): string
        {
            return $this->path;
        }
    
        public function setPath(string $path): static
        {
            $this->path = $path;
    
            return $this;
        }
    
        public function getDisplayName(): string
        {
            return $this->displayName;
        }
    
        public function setDisplayName(string $displayName): static
        {
            $this->displayName = $displayName;
    
            return $this;
        }
    
        public function getParentPath(): ?string
        {
            return $this->parentPath;
        }
    
        public function setParentPath(?string $parentPath): static
        {
            $this->parentPath = $parentPath;
    
            return $this;
        }
    
        public function getUnreadCount(): int
        {
            return $this->unreadCount;
        }
    
        public function setUnreadCount(int $unreadCount): static
        {
            $this->unreadCount = $unreadCount;
    
            return $this;
        }
    
        public function getTotalCount(): int
        {
            return $this->totalCount;
        }
    
        public function setTotalCount(int $totalCount): static
        {
            $this->totalCount = $totalCount;
    
            return $this;
        }
    
        public function getLastSyncedAt(): ?DateTimeImmutable
        {
            return $this->lastSyncedAt;
        }
    
        public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static
        {
            $this->lastSyncedAt = $lastSyncedAt;
    
            return $this;
        }
    }
    
  • Step 2 : Créer le repository MailFolderRepository

    Créer src/Repository/MailFolderRepository.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository;
    
    use App\Entity\MailFolder;
    use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    use Doctrine\Persistence\ManagerRegistry;
    
    class MailFolderRepository extends ServiceEntityRepository
    {
        public function __construct(ManagerRegistry $registry)
        {
            parent::__construct($registry, MailFolder::class);
        }
    
        /**
         * @return list<MailFolder>
         */
        public function findAllOrderedByPath(): array
        {
            return $this->createQueryBuilder('f')
                ->orderBy('f.path', 'ASC')
                ->getQuery()
                ->getResult()
            ;
        }
    
        public function findByPath(string $path): ?MailFolder
        {
            return $this->findOneBy(['path' => $path]);
        }
    }
    
  • Step 3 : Vérifier la syntaxe PHP

    docker exec php-lesstime-fpm php -l src/Entity/MailFolder.php
    docker exec php-lesstime-fpm php -l src/Repository/MailFolderRepository.php
    

    Attendu : No syntax errors detected.

  • Step 4 : Commit

    make php-cs-fixer-allow-risky
    git add src/Entity/MailFolder.php src/Repository/MailFolderRepository.php
    git commit -m "feat(mail) : MailFolder entity + repository"
    

Task 4 : Entité MailMessage + Repository

  • Step 1 : Créer l'entité MailMessage

    Créer src/Entity/MailMessage.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity;
    
    use App\Repository\MailMessageRepository;
    use DateTimeImmutable;
    use Doctrine\ORM\Mapping as ORM;
    
    #[ORM\Entity(repositoryClass: MailMessageRepository::class)]
    #[ORM\Table(name: 'mail_message')]
    #[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])]
    #[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')]
    #[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')]
    class MailMessage
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\Column(length: 500, unique: true)]
        private string $messageId;
    
        #[ORM\ManyToOne(targetEntity: MailFolder::class)]
        #[ORM\JoinColumn(name: 'folder_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
        private MailFolder $folder;
    
        #[ORM\Column]
        private int $uid;
    
        #[ORM\Column(length: 500, nullable: true)]
        private ?string $subject = null;
    
        #[ORM\Column(length: 255)]
        private string $fromAddress;
    
        #[ORM\Column(length: 255, nullable: true)]
        private ?string $fromName = null;
    
        #[ORM\Column(type: 'json')]
        private array $toAddresses = [];
    
        #[ORM\Column(type: 'json', nullable: true)]
        private ?array $ccAddresses = null;
    
        #[ORM\Column(type: 'datetimetz_immutable')]
        private DateTimeImmutable $sentAt;
    
        #[ORM\Column(type: 'boolean')]
        private bool $isRead = false;
    
        #[ORM\Column(type: 'boolean')]
        private bool $isFlagged = false;
    
        #[ORM\Column(type: 'boolean')]
        private bool $hasAttachments = false;
    
        #[ORM\Column(type: 'text', nullable: true)]
        private ?string $snippet = null;
    
        #[ORM\Column(type: 'datetimetz_immutable')]
        private DateTimeImmutable $syncedAt;
    
        public function getId(): ?int
        {
            return $this->id;
        }
    
        public function getMessageId(): string
        {
            return $this->messageId;
        }
    
        public function setMessageId(string $messageId): static
        {
            $this->messageId = $messageId;
    
            return $this;
        }
    
        public function getFolder(): MailFolder
        {
            return $this->folder;
        }
    
        public function setFolder(MailFolder $folder): static
        {
            $this->folder = $folder;
    
            return $this;
        }
    
        public function getUid(): int
        {
            return $this->uid;
        }
    
        public function setUid(int $uid): static
        {
            $this->uid = $uid;
    
            return $this;
        }
    
        public function getSubject(): ?string
        {
            return $this->subject;
        }
    
        public function setSubject(?string $subject): static
        {
            $this->subject = $subject;
    
            return $this;
        }
    
        public function getFromAddress(): string
        {
            return $this->fromAddress;
        }
    
        public function setFromAddress(string $fromAddress): static
        {
            $this->fromAddress = $fromAddress;
    
            return $this;
        }
    
        public function getFromName(): ?string
        {
            return $this->fromName;
        }
    
        public function setFromName(?string $fromName): static
        {
            $this->fromName = $fromName;
    
            return $this;
        }
    
        public function getToAddresses(): array
        {
            return $this->toAddresses;
        }
    
        public function setToAddresses(array $toAddresses): static
        {
            $this->toAddresses = $toAddresses;
    
            return $this;
        }
    
        public function getCcAddresses(): ?array
        {
            return $this->ccAddresses;
        }
    
        public function setCcAddresses(?array $ccAddresses): static
        {
            $this->ccAddresses = $ccAddresses;
    
            return $this;
        }
    
        public function getSentAt(): DateTimeImmutable
        {
            return $this->sentAt;
        }
    
        public function setSentAt(DateTimeImmutable $sentAt): static
        {
            $this->sentAt = $sentAt;
    
            return $this;
        }
    
        public function isRead(): bool
        {
            return $this->isRead;
        }
    
        public function setIsRead(bool $isRead): static
        {
            $this->isRead = $isRead;
    
            return $this;
        }
    
        public function isFlagged(): bool
        {
            return $this->isFlagged;
        }
    
        public function setIsFlagged(bool $isFlagged): static
        {
            $this->isFlagged = $isFlagged;
    
            return $this;
        }
    
        public function hasAttachments(): bool
        {
            return $this->hasAttachments;
        }
    
        public function setHasAttachments(bool $hasAttachments): static
        {
            $this->hasAttachments = $hasAttachments;
    
            return $this;
        }
    
        public function getSnippet(): ?string
        {
            return $this->snippet;
        }
    
        public function setSnippet(?string $snippet): static
        {
            $this->snippet = $snippet;
    
            return $this;
        }
    
        public function getSyncedAt(): DateTimeImmutable
        {
            return $this->syncedAt;
        }
    
        public function setSyncedAt(DateTimeImmutable $syncedAt): static
        {
            $this->syncedAt = $syncedAt;
    
            return $this;
        }
    }
    
  • Step 2 : Créer le repository MailMessageRepository

    Créer src/Repository/MailMessageRepository.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository;
    
    use App\Entity\MailFolder;
    use App\Entity\MailMessage;
    use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    use Doctrine\Persistence\ManagerRegistry;
    
    class MailMessageRepository extends ServiceEntityRepository
    {
        public function __construct(ManagerRegistry $registry)
        {
            parent::__construct($registry, MailMessage::class);
        }
    
        public function findByMessageId(string $messageId): ?MailMessage
        {
            return $this->findOneBy(['messageId' => $messageId]);
        }
    
        public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage
        {
            return $this->findOneBy(['folder' => $folder, 'uid' => $uid]);
        }
    
        /**
         * @return list<MailMessage>
         */
        public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array
        {
            return $this->createQueryBuilder('m')
                ->andWhere('m.folder = :folder')
                ->setParameter('folder', $folder)
                ->orderBy('m.sentAt', 'DESC')
                ->addOrderBy('m.id', 'DESC')
                ->setMaxResults($limit)
                ->setFirstResult($offset)
                ->getQuery()
                ->getResult()
            ;
        }
    
        public function countUnreadByFolder(MailFolder $folder): int
        {
            return (int) $this->createQueryBuilder('m')
                ->select('COUNT(m.id)')
                ->andWhere('m.folder = :folder')
                ->andWhere('m.isRead = false')
                ->setParameter('folder', $folder)
                ->getQuery()
                ->getSingleScalarResult()
            ;
        }
    }
    
  • Step 3 : Vérifier la syntaxe PHP

    docker exec php-lesstime-fpm php -l src/Entity/MailMessage.php
    docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.php
    

    Attendu : No syntax errors detected.

  • Step 4 : Commit

    make php-cs-fixer-allow-risky
    git add src/Entity/MailMessage.php src/Repository/MailMessageRepository.php
    git commit -m "feat(mail) : MailMessage entity + repository"
    

  • Step 1 : Créer l'entité TaskMailLink

    Créer src/Entity/TaskMailLink.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity;
    
    use App\Repository\TaskMailLinkRepository;
    use DateTimeImmutable;
    use Doctrine\ORM\Mapping as ORM;
    
    #[ORM\Entity(repositoryClass: TaskMailLinkRepository::class)]
    #[ORM\Table(name: 'task_mail_link')]
    #[ORM\UniqueConstraint(name: 'uq_task_mail_link', columns: ['task_id', 'mail_message_id'])]
    class TaskMailLink
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\ManyToOne(targetEntity: Task::class)]
        #[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
        private Task $task;
    
        #[ORM\ManyToOne(targetEntity: MailMessage::class)]
        #[ORM\JoinColumn(name: 'mail_message_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
        private MailMessage $mailMessage;
    
        #[ORM\Column(type: 'datetimetz_immutable')]
        private DateTimeImmutable $linkedAt;
    
        #[ORM\ManyToOne(targetEntity: User::class)]
        #[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
        private ?User $linkedBy = null;
    
        public function getId(): ?int
        {
            return $this->id;
        }
    
        public function getTask(): Task
        {
            return $this->task;
        }
    
        public function setTask(Task $task): static
        {
            $this->task = $task;
    
            return $this;
        }
    
        public function getMailMessage(): MailMessage
        {
            return $this->mailMessage;
        }
    
        public function setMailMessage(MailMessage $mailMessage): static
        {
            $this->mailMessage = $mailMessage;
    
            return $this;
        }
    
        public function getLinkedAt(): DateTimeImmutable
        {
            return $this->linkedAt;
        }
    
        public function setLinkedAt(DateTimeImmutable $linkedAt): static
        {
            $this->linkedAt = $linkedAt;
    
            return $this;
        }
    
        public function getLinkedBy(): ?User
        {
            return $this->linkedBy;
        }
    
        public function setLinkedBy(?User $linkedBy): static
        {
            $this->linkedBy = $linkedBy;
    
            return $this;
        }
    }
    
  • Step 2 : Créer le repository TaskMailLinkRepository

    Créer src/Repository/TaskMailLinkRepository.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository;
    
    use App\Entity\MailMessage;
    use App\Entity\Task;
    use App\Entity\TaskMailLink;
    use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    use Doctrine\Persistence\ManagerRegistry;
    
    class TaskMailLinkRepository extends ServiceEntityRepository
    {
        public function __construct(ManagerRegistry $registry)
        {
            parent::__construct($registry, TaskMailLink::class);
        }
    
        /**
         * @return list<TaskMailLink>
         */
        public function findByTask(Task $task): array
        {
            return $this->createQueryBuilder('l')
                ->andWhere('l.task = :task')
                ->setParameter('task', $task)
                ->orderBy('l.linkedAt', 'DESC')
                ->getQuery()
                ->getResult()
            ;
        }
    
        public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink
        {
            return $this->findOneBy(['task' => $task, 'mailMessage' => $message]);
        }
    
        /**
         * @return list<TaskMailLink>
         */
        public function findByMessage(MailMessage $message): array
        {
            return $this->findBy(['mailMessage' => $message]);
        }
    }
    
  • Step 3 : Vérifier la syntaxe PHP

    docker exec php-lesstime-fpm php -l src/Entity/TaskMailLink.php
    docker exec php-lesstime-fpm php -l src/Repository/TaskMailLinkRepository.php
    

    Attendu : No syntax errors detected.

  • Step 4 : Commit

    make php-cs-fixer-allow-risky
    git add src/Entity/TaskMailLink.php src/Repository/TaskMailLinkRepository.php
    git commit -m "feat(mail) : TaskMailLink entity + repository"
    

Task 6 : Migration Doctrine unique (raw SQL, 4 tables + FK + index)

  • Step 1 : Générer le squelette de migration vide

    docker exec php-lesstime-fpm php bin/console doctrine:migrations:generate
    

    Cela crée migrations/Version<timestamp>.php. Relever le nom exact du fichier créé (ex. Version20260519120000.php).

  • Step 2 : Écrire le SQL UP complet dans la migration générée

    Ouvrir le fichier migrations/Version<timestamp>.php et remplacer les méthodes up() et down() par :

    public function getDescription(): string
    {
        return 'Mail integration: create mail_configuration, mail_folder, mail_message, task_mail_link tables';
    }
    
    public function up(Schema $schema): void
    {
        $this->addSql(<<<'SQL'
            CREATE TABLE mail_configuration (
                id SERIAL NOT NULL,
                protocol VARCHAR(10) NOT NULL DEFAULT 'imap',
                imap_host VARCHAR(255) DEFAULT NULL,
                imap_port INT NOT NULL DEFAULT 993,
                imap_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl',
                smtp_host VARCHAR(255) DEFAULT NULL,
                smtp_port INT NOT NULL DEFAULT 465,
                smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl',
                username VARCHAR(255) DEFAULT NULL,
                encrypted_password TEXT DEFAULT NULL,
                sent_folder_path VARCHAR(255) NOT NULL DEFAULT 'Sent',
                enabled BOOLEAN NOT NULL DEFAULT false,
                PRIMARY KEY (id)
            )
        SQL);
    
        $this->addSql(<<<'SQL'
            CREATE TABLE mail_folder (
                id SERIAL NOT NULL,
                path VARCHAR(500) NOT NULL,
                display_name VARCHAR(255) NOT NULL,
                parent_path VARCHAR(500) DEFAULT NULL,
                unread_count INT NOT NULL DEFAULT 0,
                total_count INT NOT NULL DEFAULT 0,
                last_synced_at TIMESTAMPTZ DEFAULT NULL,
                PRIMARY KEY (id)
            )
        SQL);
    
        $this->addSql('CREATE UNIQUE INDEX uq_mail_folder_path ON mail_folder (path)');
        $this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)');
    
        $this->addSql(<<<'SQL'
            CREATE TABLE mail_message (
                id SERIAL NOT NULL,
                message_id VARCHAR(500) NOT NULL,
                folder_id INT NOT NULL,
                uid INT NOT NULL,
                subject VARCHAR(500) DEFAULT NULL,
                from_address VARCHAR(255) NOT NULL,
                from_name VARCHAR(255) DEFAULT NULL,
                to_addresses JSONB NOT NULL,
                cc_addresses JSONB DEFAULT NULL,
                sent_at TIMESTAMPTZ NOT NULL,
                is_read BOOLEAN NOT NULL DEFAULT false,
                is_flagged BOOLEAN NOT NULL DEFAULT false,
                has_attachments BOOLEAN NOT NULL DEFAULT false,
                snippet TEXT DEFAULT NULL,
                synced_at TIMESTAMPTZ NOT NULL,
                PRIMARY KEY (id)
            )
        SQL);
    
        $this->addSql('CREATE UNIQUE INDEX uq_mail_message_message_id ON mail_message (message_id)');
        $this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)');
        $this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)');
        $this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)');
        $this->addSql('ALTER TABLE mail_message ADD CONSTRAINT fk_mail_message_folder FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
    
        $this->addSql(<<<'SQL'
            CREATE TABLE task_mail_link (
                id SERIAL NOT NULL,
                task_id INT NOT NULL,
                mail_message_id INT NOT NULL,
                linked_at TIMESTAMPTZ NOT NULL,
                linked_by_id INT DEFAULT NULL,
                PRIMARY KEY (id)
            )
        SQL);
    
        $this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)');
        $this->addSql('CREATE INDEX idx_task_mail_link_task ON task_mail_link (task_id)');
        $this->addSql('CREATE INDEX idx_task_mail_link_message ON task_mail_link (mail_message_id)');
        $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_task FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
        $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_message FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
        $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_user FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
    }
    
    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_task');
        $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_message');
        $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_user');
        $this->addSql('DROP TABLE task_mail_link');
    
        $this->addSql('ALTER TABLE mail_message DROP CONSTRAINT fk_mail_message_folder');
        $this->addSql('DROP TABLE mail_message');
    
        $this->addSql('DROP TABLE mail_folder');
    
        $this->addSql('DROP TABLE mail_configuration');
    }
    

    Attention : declare(strict_types=1); doit être présent en tête du fichier (il est présent dans le squelette généré par Doctrine).

  • Step 3 : Lancer la migration

    make migration-migrate
    

    Attendu : migration exécutée sans erreur SQL.

  • Step 4 : Valider le schéma Doctrine

    docker exec php-lesstime-fpm php bin/console doctrine:schema:validate
    

    Attendu : [Mapping] OK - The mapping files are correct. et [Database] OK - The database schema is in sync with the mapping files.

    Si des différences sont signalées (ex. type json vs jsonb), ajuster l'attribut #[ORM\Column(type: 'json')] en #[ORM\Column(type: 'jsonb')] dans MailMessage.php si le mapping Doctrine du projet est configuré pour jsonb. Dans le doute, json est le type Doctrine standard et PostgreSQL accepte les deux.

  • Step 5 : Commit

    make php-cs-fixer-allow-risky
    git add migrations/
    git commit -m "feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link"
    

Task 7 : DTOs (4 fichiers sous src/Mail/Dto/)

  • Step 1 : Créer MailFolderDto

    Créer src/Mail/Dto/MailFolderDto.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Dto;
    
    final readonly class MailFolderDto
    {
        public function __construct(
            public string $path,
            public string $displayName,
            public ?string $parentPath,
            public int $unreadCount,
            public int $totalCount,
        ) {}
    }
    
  • Step 2 : Créer MailMessageHeaderDto

    Créer src/Mail/Dto/MailMessageHeaderDto.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Dto;
    
    use DateTimeImmutable;
    
    final readonly class MailMessageHeaderDto
    {
        public function __construct(
            public int $uid,
            public string $messageId,
            public ?string $subject,
            public string $fromAddress,
            public ?string $fromName,
            public array $toAddresses,
            public ?array $ccAddresses,
            public DateTimeImmutable $sentAt,
            public bool $isRead,
            public bool $isFlagged,
            public bool $hasAttachments,
            public ?string $snippet,
        ) {}
    }
    
  • Step 3 : Créer MailAttachmentDto

    Créer src/Mail/Dto/MailAttachmentDto.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Dto;
    
    final readonly class MailAttachmentDto
    {
        public function __construct(
            public string $partNumber,
            public string $filename,
            public string $mimeType,
            public int $size,
        ) {}
    }
    
  • Step 4 : Créer MailMessageDetailDto

    Créer src/Mail/Dto/MailMessageDetailDto.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Dto;
    
    final readonly class MailMessageDetailDto
    {
        /**
         * @param list<MailAttachmentDto> $attachments
         */
        public function __construct(
            public MailMessageHeaderDto $header,
            public ?string $bodyHtml,
            public ?string $bodyText,
            public array $attachments,
        ) {}
    }
    
  • Step 5 : Vérifier la syntaxe des 4 DTOs

    docker exec php-lesstime-fpm php -l src/Mail/Dto/MailFolderDto.php
    docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageHeaderDto.php
    docker exec php-lesstime-fpm php -l src/Mail/Dto/MailAttachmentDto.php
    docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageDetailDto.php
    

    Attendu : No syntax errors detected pour chacun.

  • Step 6 : Commit

    make php-cs-fixer-allow-risky
    git add src/Mail/Dto/
    git commit -m "feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto"
    

Task 8 : Interface MailProviderInterface + Exception MailProviderException

  • Step 1 : Créer MailProviderException

    Créer src/Mail/Exception/MailProviderException.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Exception;
    
    use RuntimeException;
    
    final class MailProviderException extends RuntimeException
    {
        public static function connectionFailed(string $reason): self
        {
            return new self(sprintf('Mail provider connection failed: %s', $reason));
        }
    
        public static function operationFailed(string $operation, string $reason): self
        {
            return new self(sprintf('Mail provider operation "%s" failed: %s', $operation, $reason));
        }
    }
    
  • Step 2 : Créer MailProviderInterface

    Créer src/Mail/MailProviderInterface.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail;
    
    use App\Mail\Dto\MailAttachmentDto;
    use App\Mail\Dto\MailFolderDto;
    use App\Mail\Dto\MailMessageDetailDto;
    use App\Mail\Dto\MailMessageHeaderDto;
    use App\Mail\Exception\MailProviderException;
    
    interface MailProviderInterface
    {
        /**
         * Returns the full folder tree of the configured mailbox.
         *
         * @return list<MailFolderDto>
         *
         * @throws MailProviderException
         */
        public function listFolders(): array;
    
        /**
         * Returns a paginated list of message headers for the given folder.
         *
         * @return list<MailMessageHeaderDto>
         *
         * @throws MailProviderException
         */
        public function listMessages(string $folderPath, int $limit, int $offset): array;
    
        /**
         * Fetches the full message (headers + body + attachments list) by UID.
         *
         * @throws MailProviderException
         */
        public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto;
    
        /**
         * Marks a message as read or unread on the IMAP server.
         *
         * @throws MailProviderException
         */
        public function markRead(string $folderPath, int $uid, bool $read): void;
    
        /**
         * Marks a message as flagged (starred) or unflagged on the IMAP server.
         *
         * @throws MailProviderException
         */
        public function markFlagged(string $folderPath, int $uid, bool $flagged): void;
    
        /**
         * Moves a message from one folder to another on the IMAP server.
         *
         * @throws MailProviderException
         */
        public function moveMessage(string $folderPath, int $uid, string $targetFolder): void;
    
        /**
         * Fetches the raw binary content of an attachment by its MIME part number.
         *
         * @throws MailProviderException
         */
        public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string;
    }
    
  • Step 3 : Vérifier la syntaxe

    docker exec php-lesstime-fpm php -l src/Mail/Exception/MailProviderException.php
    docker exec php-lesstime-fpm php -l src/Mail/MailProviderInterface.php
    

    Attendu : No syntax errors detected.

  • Step 4 : Commit

    make php-cs-fixer-allow-risky
    git add src/Mail/MailProviderInterface.php src/Mail/Exception/MailProviderException.php
    git commit -m "feat(mail) : MailProviderInterface + MailProviderException"
    

Task 9 : Fixture MailConfiguration désactivée (OVH defaults)

  • Step 1 : Modifier AppFixtures.php

    Ouvrir src/DataFixtures/AppFixtures.php.

    Ajouter l'import en tête du bloc use (après use App\Entity\ZimbraConfiguration;) :

    use App\Entity\MailConfiguration;
    

    Puis ajouter le bloc fixture juste avant $manager->flush(); (après le bloc TaskRecurrence existant) :

    // =============================================
    // Mail Configuration
    // =============================================
    $mailConfig = new MailConfiguration();
    $mailConfig->setImapHost('ssl0.ovh.net');
    $mailConfig->setImapPort(993);
    $mailConfig->setImapEncryption('ssl');
    $mailConfig->setSmtpHost('ssl0.ovh.net');
    $mailConfig->setSmtpPort(465);
    $mailConfig->setSmtpEncryption('ssl');
    $mailConfig->setUsername('lesstime@ovh.fr');
    $mailConfig->setSentFolderPath('Sent');
    $mailConfig->setEnabled(false);
    $manager->persist($mailConfig);
    
  • Step 2 : Vérifier la syntaxe

    docker exec php-lesstime-fpm php -l src/DataFixtures/AppFixtures.php
    

    Attendu : No syntax errors detected.

  • Step 3 : Charger les fixtures pour valider

    make fixtures
    

    Attendu : pas d'erreur. Vérifier en BDD :

    docker exec php-lesstime-fpm php bin/console dbal:run-sql "SELECT id, imap_host, smtp_host, enabled FROM mail_configuration"
    

    Attendu : une ligne avec ssl0.ovh.net, ssl0.ovh.net, f.

  • Step 4 : Commit

    make php-cs-fixer-allow-risky
    git add src/DataFixtures/AppFixtures.php
    git commit -m "feat(mail) : fixture MailConfiguration OVH defaults (disabled)"
    

Task 10 : Validation finale

  • Step 1 : Valider le mapping Doctrine

    docker exec php-lesstime-fpm php bin/console doctrine:schema:validate
    

    Attendu :

    [Mapping]  OK - The mapping files are correct.
    [Database] OK - The database schema is in sync with the mapping files.
    

    Si des erreurs apparaissent, les corriger (type mismatch json vs jsonb, colonnes manquantes, etc.) avant de continuer.

  • Step 2 : Lancer la suite de tests complète

    make test
    

    Attendu : tous les tests passent (au minimum les 2 tests MailConfigurationRepositoryTest).

  • Step 3 : PHP CS Fixer sur l'ensemble des fichiers modifiés

    make php-cs-fixer-allow-risky
    

    Si des fichiers sont modifiés par le fixer, les re-stageer et committer :

    git add -p
    git commit -m "style(mail) : php-cs-fixer pass"
    
  • Step 4 : Vider le cache Symfony

    make cache-clear
    

    Attendu : pas d'erreur de configuration ou de service manquant.

  • Step 5 : Vérification rapide du conteneur de services

    docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfiguration 2>&1 | head -20
    

    Attendu : App\Repository\MailConfigurationRepository apparaît dans la liste des services autowirables.

  • Step 6 : Résumé des commits de la phase

    Vérifier l'historique :

    git log --oneline feat/mail-integration ^develop | head -20
    

    Les commits attendus (dans l'ordre chronologique) :

    1. feat(mail) : MailConfiguration entity + repository + singleton test
    2. feat(mail) : MailFolder entity + repository
    3. feat(mail) : MailMessage entity + repository
    4. feat(mail) : TaskMailLink entity + repository
    5. feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link
    6. feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto
    7. feat(mail) : MailProviderInterface + MailProviderException
    8. feat(mail) : fixture MailConfiguration OVH defaults (disabled)

Self-Review

Cohérence des noms

Concept Classe PHP Table SQL Repository
Configuration singleton MailConfiguration mail_configuration MailConfigurationRepository
Cache dossiers IMAP MailFolder mail_folder MailFolderRepository
Cache messages IMAP MailMessage mail_message MailMessageRepository
Lien tâche ↔ mail TaskMailLink task_mail_link TaskMailLinkRepository
DTO dossier MailFolderDto
DTO header message MailMessageHeaderDto
DTO pièce jointe MailAttachmentDto
DTO message complet MailMessageDetailDto
Interface provider MailProviderInterface
Exception provider MailProviderException

Checklist finale avant de valider Phase 1

  • declare(strict_types=1); présent en tête de chaque fichier PHP créé
  • Toutes les FK définies avec ON DELETE CASCADE (sauf task_mail_link.linked_by_idON DELETE SET NULL)
  • UNIQUE INDEX sur mail_folder.path, mail_message.message_id, (mail_message.folder_id, mail_message.uid), (task_mail_link.task_id, task_mail_link.mail_message_id)
  • INDEX sur mail_folder.parent_path, mail_message.sent_at, mail_message.is_read
  • Table "user" entre guillemets dans le SQL de la migration (réservé PostgreSQL)
  • MailConfiguration.encryptedPassword est null par défaut en fixture (pas de mot de passe en clair)
  • DTOs déclarés final readonly class (PHP 8.2+)
  • MailProviderInterface contient exactement 7 méthodes : listFolders, listMessages, fetchMessage, markRead, markFlagged, moveMessage, fetchAttachment
  • make test vert (2 tests minimum)
  • doctrine:schema:validate OK
  • Aucune logique métier IMAP implémentée (Phase 2)
  • feat/mail-integration est la branche de travail (pas develop)

Remise à la review humaine

Une fois tous les steps cochés, pousser la branche et notifier le user :

git push -u origin feat/mail-integration

Indiquer au user :

  • Nombre de fichiers créés : 17
  • Tables créées : 4 (mail_configuration, mail_folder, mail_message, task_mail_link)
  • Tests : 2 (MailConfigurationRepositoryTest)
  • Prêt pour Phase 2 : IMAP provider + MailSyncService