Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md

60 KiB

Mail Integration — Phase 2 : IMAP Provider + Sync

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: Implémenter le provider IMAP (ImapMailProvider), le service de synchronisation (MailSyncService) avec gestion de la concurrence (Symfony Lock), et la commande console app:mail:sync déclenchée par cron OS.

Architecture: ImapMailProvider utilise webklex/php-imap pour parler à OVH/Zimbra ; lit la config MailConfiguration via repository, déchiffre password via TokenEncryptor::decrypt(). MailSyncService orchestre 3 étapes par cycle (sync structure dossiers / sync nouveaux messages UID > maxKnown / resync flags des N=200 derniers / detect suppressions avec garde 50%). Lock mail.sync TTL 10 min empêche overlap.

Tech Stack: PHP 8.4, Symfony 8.0, webklex/php-imap ^5.0, symfony/lock ^8.0 (à installer — absent du composer.json actuel), PostgreSQL 16.

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/Mail/Dto/MailSyncReport.php Créer
src/Mail/ImapMailProvider.php Créer
src/Service/MailSyncService.php Créer
src/Command/MailSyncCommand.php Créer
src/Repository/MailMessageRepository.php Modifier (ajout findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder)
config/packages/lock.yaml Créer (si absent)
makefile Modifier (ajout target mail-sync)
docs/mail-cron-setup.md Créer
tests/Unit/Mail/ImapMailProviderTest.php Créer
tests/Unit/Service/MailSyncServiceTest.php Créer
tests/Unit/Mail/MailSyncReportTest.php Créer
tests/Functional/Command/MailSyncCommandTest.php Créer

Task 1 : Préparer l'environnement

  • 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 de tests si absents

    mkdir -p tests/Unit/Mail tests/Unit/Service tests/Functional/Command
    
  • Step 3 : Installer webklex/php-imap

    docker exec php-lesstime-fpm composer require webklex/php-imap:"^5.0"
    

    Vérifier la compatibilité PHP 8.4 : webklex/php-imap ^5.0 supporte PHP 8.1+. Si ^5.0 n'existe pas au moment de l'install, essayer ^4.4 (même support PHP 8.x). Choisir la contrainte qui s'installe sans conflict.

    Attendu : composer.json mis à jour, pas d'erreur de conflit.

  • Step 4 : Installer symfony/lock

    docker exec php-lesstime-fpm composer require symfony/lock:"8.0.*"
    

    Attendu : symfony/lock ajouté dans require de composer.json.

  • Step 5 : Créer config/packages/lock.yaml si absent

    Vérifier :

    docker exec php-lesstime-fpm php bin/console debug:config framework lock 2>&1 | head -10
    

    Si non configuré, créer config/packages/lock.yaml :

    framework:
        lock:
            resources:
                default: "%kernel.project_dir%/var/lock"
    

    Ce fichier configure un store fichier dans var/lock/. Le répertoire sera créé automatiquement par Symfony.

  • Step 6 : Vider le cache pour prendre en compte la nouvelle config

    make cache-clear
    

    Attendu : pas d'erreur de service manquant.

  • Step 7 : Commit

    git add composer.json composer.lock config/packages/lock.yaml
    git commit -m "feat(mail) : install webklex/php-imap + symfony/lock, configure lock store"
    

Task 2 : DTO MailSyncReport

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

    Créer tests/Unit/Mail/MailSyncReportTest.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Tests\Unit\Mail;
    
    use App\Mail\Dto\MailSyncReport;
    use DateTimeImmutable;
    use PHPUnit\Framework\TestCase;
    
    class MailSyncReportTest extends TestCase
    {
        public function testInstantiationWithDefaults(): void
        {
            $start  = new DateTimeImmutable('2026-01-01 10:00:00');
            $finish = new DateTimeImmutable('2026-01-01 10:00:05');
    
            $report = new MailSyncReport(
                createdCount: 3,
                updatedCount: 1,
                deletedCount: 0,
                foldersScanned: 2,
                errors: [],
                durationSeconds: 5.0,
                startedAt: $start,
                finishedAt: $finish,
            );
    
            self::assertSame(3, $report->createdCount);
            self::assertSame(1, $report->updatedCount);
            self::assertSame(0, $report->deletedCount);
            self::assertSame(2, $report->foldersScanned);
            self::assertSame([], $report->errors);
            self::assertSame(5.0, $report->durationSeconds);
            self::assertSame($start, $report->startedAt);
            self::assertSame($finish, $report->finishedAt);
        }
    
        public function testWithErrors(): void
        {
            $report = new MailSyncReport(
                createdCount: 0,
                updatedCount: 0,
                deletedCount: 0,
                foldersScanned: 1,
                errors: ['IMAP connection timeout'],
                durationSeconds: 0.5,
                startedAt: new DateTimeImmutable(),
                finishedAt: new DateTimeImmutable(),
            );
    
            self::assertCount(1, $report->errors);
            self::assertSame('IMAP connection timeout', $report->errors[0]);
        }
    }
    
  • Step 2 : Lancer le test — doit échouer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10
    

    Attendu : erreur classe manquante.

  • Step 3 : Créer src/Mail/Dto/MailSyncReport.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Mail\Dto;
    
    use DateTimeImmutable;
    
    final readonly class MailSyncReport
    {
        /**
         * @param list<string> $errors
         */
        public function __construct(
            public int $createdCount,
            public int $updatedCount,
            public int $deletedCount,
            public int $foldersScanned,
            public array $errors,
            public float $durationSeconds,
            public DateTimeImmutable $startedAt,
            public DateTimeImmutable $finishedAt,
        ) {}
    }
    
  • Step 4 : Relancer le test — doit passer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10
    

    Attendu : OK (2 tests, X assertions).

  • Step 5 : Commit

    make php-cs-fixer-allow-risky
    git add src/Mail/Dto/MailSyncReport.php tests/Unit/Mail/MailSyncReportTest.php
    git commit -m "feat(mail) : DTO MailSyncReport + test unitaire"
    

Task 3 : ImapMailProvider — squelette + connexion

  • Step 1 : Créer le test squelette (TDD)

    Créer tests/Unit/Mail/ImapMailProviderTest.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Tests\Unit\Mail;
    
    use App\Entity\MailConfiguration;
    use App\Mail\Exception\MailProviderException;
    use App\Mail\ImapMailProvider;
    use App\Repository\MailConfigurationRepository;
    use App\Service\TokenEncryptor;
    use PHPUnit\Framework\TestCase;
    use Psr\Log\NullLogger;
    
    class ImapMailProviderTest extends TestCase
    {
        public function testThrowsWhenConfigDisabled(): void
        {
            $config = new MailConfiguration();
            $config->setEnabled(false);
    
            $repo = $this->createMock(MailConfigurationRepository::class);
            $repo->method('findSingleton')->willReturn($config);
    
            $encryptor = $this->createMock(TokenEncryptor::class);
    
            $provider = new ImapMailProvider($repo, $encryptor, new NullLogger());
    
            $this->expectException(MailProviderException::class);
            $provider->listFolders();
        }
    
        public function testThrowsWhenConfigMissing(): void
        {
            $repo = $this->createMock(MailConfigurationRepository::class);
            $repo->method('findSingleton')->willReturn(null);
    
            $encryptor = $this->createMock(TokenEncryptor::class);
    
            $provider = new ImapMailProvider($repo, $encryptor, new NullLogger());
    
            $this->expectException(MailProviderException::class);
            $provider->listFolders();
        }
    }
    
  • Step 2 : Lancer le test — doit échouer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10
    

    Attendu : erreur classe manquante.

  • Step 3 : Créer src/Mail/ImapMailProvider.php — squelette complet

    <?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;
    use App\Repository\MailConfigurationRepository;
    use App\Service\TokenEncryptor;
    use DateTimeImmutable;
    use Psr\Log\LoggerInterface;
    use Throwable;
    use Webklex\PHPIMAP\Client;
    use Webklex\PHPIMAP\ClientManager;
    
    final class ImapMailProvider implements MailProviderInterface
    {
        public function __construct(
            private readonly MailConfigurationRepository $configRepository,
            private readonly TokenEncryptor $tokenEncryptor,
            private readonly LoggerInterface $logger,
        ) {}
    
        public function listFolders(): array
        {
            $client = $this->getClient();
    
            try {
                $folders = $client->getFolders(false);
                $result  = [];
    
                foreach ($folders as $folder) {
                    $path        = $folder->path;
                    $parentPath  = null;
                    $lastDelim   = strrpos($path, $folder->delimiter ?? '.');
                    if (false !== $lastDelim && $lastDelim > 0) {
                        $parentPath = substr($path, 0, $lastDelim);
                    }
    
                    $result[] = new MailFolderDto(
                        path: $path,
                        displayName: $folder->name,
                        parentPath: $parentPath,
                        unreadCount: (int) ($folder->status['unseen'] ?? 0),
                        totalCount: (int) ($folder->status['messages'] ?? 0),
                    );
                }
    
                $client->disconnect();
    
                return $result;
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage());
                throw MailProviderException::operationFailed('listFolders', $e->getMessage());
            }
        }
    
        public function listMessages(string $folderPath, int $limit, int $offset): array
        {
            $client = $this->getClient();
    
            try {
                $folder   = $client->getFolder($folderPath);
                $messages = $folder->query()->leaveUnread()->get();
    
                $result = [];
                $items  = array_slice($messages->toArray(), $offset, $limit);
    
                foreach ($items as $message) {
                    $result[] = $this->buildHeaderDto($message);
                }
    
                $client->disconnect();
    
                return $result;
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage()));
                throw MailProviderException::operationFailed('listMessages', $e->getMessage());
            }
        }
    
        public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto
        {
            $client = $this->getClient();
    
            try {
                $folder  = $client->getFolder($folderPath);
                $message = $folder->query()->uid($uid)->leaveUnread()->get()->first();
    
                if (null === $message) {
                    throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath));
                }
    
                $header      = $this->buildHeaderDto($message);
                $bodyHtml    = $message->getHTMLBody(false) ?: null;
                $bodyText    = $message->getTextBody() ?: null;
                $attachments = [];
    
                foreach ($message->getAttachments() as $att) {
                    $attachments[] = new MailAttachmentDto(
                        partNumber: (string) ($att->part_number ?? '1'),
                        filename: $att->getName() ?? 'attachment',
                        mimeType: $att->getMimeType() ?? 'application/octet-stream',
                        size: $att->getSize() ?? 0,
                    );
                }
    
                $client->disconnect();
    
                return new MailMessageDetailDto(
                    header: $header,
                    bodyHtml: $bodyHtml,
                    bodyText: $bodyText,
                    attachments: $attachments,
                );
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage()));
                throw MailProviderException::operationFailed('fetchMessage', $e->getMessage());
            }
        }
    
        public function markRead(string $folderPath, int $uid, bool $read): void
        {
            $client = $this->getClient();
    
            try {
                $folder  = $client->getFolder($folderPath);
                $message = $folder->query()->uid($uid)->leaveUnread()->get()->first();
    
                if (null === $message) {
                    throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid));
                }
    
                if ($read) {
                    $message->setFlag('Seen');
                } else {
                    $message->unsetFlag('Seen');
                }
    
                $client->disconnect();
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage()));
                throw MailProviderException::operationFailed('markRead', $e->getMessage());
            }
        }
    
        public function markFlagged(string $folderPath, int $uid, bool $flagged): void
        {
            $client = $this->getClient();
    
            try {
                $folder  = $client->getFolder($folderPath);
                $message = $folder->query()->uid($uid)->leaveUnread()->get()->first();
    
                if (null === $message) {
                    throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid));
                }
    
                if ($flagged) {
                    $message->setFlag('Flagged');
                } else {
                    $message->unsetFlag('Flagged');
                }
    
                $client->disconnect();
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage()));
                throw MailProviderException::operationFailed('markFlagged', $e->getMessage());
            }
        }
    
        public function moveMessage(string $folderPath, int $uid, string $targetFolder): void
        {
            $client = $this->getClient();
    
            try {
                $folder  = $client->getFolder($folderPath);
                $message = $folder->query()->uid($uid)->leaveUnread()->get()->first();
    
                if (null === $message) {
                    throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid));
                }
    
                $message->moveToFolder($targetFolder);
                $client->disconnect();
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage()));
                throw MailProviderException::operationFailed('moveMessage', $e->getMessage());
            }
        }
    
        public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string
        {
            $client = $this->getClient();
    
            try {
                $folder  = $client->getFolder($folderPath);
                $message = $folder->query()->uid($uid)->leaveUnread()->get()->first();
    
                if (null === $message) {
                    throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid));
                }
    
                foreach ($message->getAttachments() as $att) {
                    if ((string) ($att->part_number ?? '1') === $partNumber) {
                        $client->disconnect();
    
                        return (string) $att->getContent();
                    }
                }
    
                $client->disconnect();
                throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
            } catch (MailProviderException $e) {
                throw $e;
            } catch (Throwable $e) {
                $this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage()));
                throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage());
            }
        }
    
        // ===================================================================
        // Private helpers
        // ===================================================================
    
        private function getClient(): Client
        {
            $config = $this->configRepository->findSingleton();
    
            if (null === $config || !$config->isEnabled()) {
                throw MailProviderException::connectionFailed('Mail configuration is missing or disabled');
            }
    
            if (null === $config->getEncryptedPassword()) {
                throw MailProviderException::connectionFailed('No password configured');
            }
    
            $password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword());
    
            try {
                $manager = new ClientManager();
                $client  = $manager->make([
                    'host'          => $config->getImapHost(),
                    'port'          => $config->getImapPort(),
                    'encryption'    => $config->getImapEncryption(),
                    'validate_cert' => true,
                    'username'      => $config->getUsername(),
                    'password'      => $password,
                    'protocol'      => 'imap',
                ]);
    
                $client->connect();
            } catch (Throwable $e) {
                $this->logger->error('IMAP connection failed: '.$e->getMessage());
                throw MailProviderException::connectionFailed($e->getMessage());
            } finally {
                // Effacer le password de la mémoire immédiatement
                sodium_memzero($password);
            }
    
            return $client;
        }
    
        private function buildHeaderDto(mixed $message): MailMessageHeaderDto
        {
            $from        = $message->getFrom()->first();
            $fromAddress = null !== $from ? (string) $from->mail : '';
            $fromName    = null !== $from ? ($from->personal ?? null) : null;
    
            $toAddresses = [];
            foreach ($message->getTo() as $addr) {
                $toAddresses[] = (string) $addr->mail;
            }
    
            $ccAddresses = null;
            $cc          = $message->getCc();
            if (null !== $cc && $cc->count() > 0) {
                $ccAddresses = [];
                foreach ($cc as $addr) {
                    $ccAddresses[] = (string) $addr->mail;
                }
            }
    
            $sentAt = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable();
    
            $snippet = null;
            $text    = $message->getTextBody();
            if (null !== $text && '' !== $text) {
                $snippet = mb_substr(strip_tags($text), 0, 200);
            }
    
            return new MailMessageHeaderDto(
                uid: (int) $message->getUid(),
                messageId: (string) $message->getMessageId(),
                subject: $message->getSubject() ?: null,
                fromAddress: $fromAddress,
                fromName: $fromName,
                toAddresses: $toAddresses,
                ccAddresses: $ccAddresses,
                sentAt: $sentAt,
                isRead: $message->hasFlag('Seen'),
                isFlagged: $message->hasFlag('Flagged'),
                hasAttachments: $message->hasAttachments(),
                snippet: $snippet,
            );
        }
    }
    

    Note : la méthode getClient() appelle sodium_memzero($password) dans le bloc finally pour effacer le mot de passe de la mémoire dès que possible après utilisation. Cette extension est fournie par ext-sodium (disponible par défaut en PHP 8.4).

  • Step 4 : Relancer les tests squelette — doivent passer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10
    

    Attendu : OK (2 tests, 2 assertions).

  • Step 5 : Vérifier la syntaxe PHP

    docker exec php-lesstime-fpm php -l src/Mail/ImapMailProvider.php
    

    Attendu : No syntax errors detected.

  • Step 6 : Commit

    make php-cs-fixer-allow-risky
    git add src/Mail/ImapMailProvider.php tests/Unit/Mail/ImapMailProviderTest.php
    git commit -m "feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface"
    

Task 4 : Méthodes manquantes dans MailMessageRepository

La Phase 1 a créé MailMessageRepository avec findByMessageId, findByFolderAndUid, findByFolderPaginated, countUnreadByFolder. MailSyncService a besoin de méthodes supplémentaires.

  • Step 1 : Ajouter les méthodes au repository

    Ouvrir src/Repository/MailMessageRepository.php et ajouter les méthodes suivantes à la suite des méthodes existantes :

    public function findMaxUidInFolder(MailFolder $folder): int
    {
        $result = $this->createQueryBuilder('m')
            ->select('MAX(m.uid)')
            ->andWhere('m.folder = :folder')
            ->setParameter('folder', $folder)
            ->getQuery()
            ->getSingleScalarResult()
        ;
    
        return (int) ($result ?? 0);
    }
    
    /**
     * Returns the N most recent messages in a folder (by sentAt DESC, id DESC).
     * Used for flag resync.
     *
     * @return list<MailMessage>
     */
    public function findLastNByFolder(MailFolder $folder, int $limit): array
    {
        return $this->createQueryBuilder('m')
            ->andWhere('m.folder = :folder')
            ->setParameter('folder', $folder)
            ->orderBy('m.sentAt', 'DESC')
            ->addOrderBy('m.id', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult()
        ;
    }
    
    /**
     * Returns all UIDs stored in DB for a given folder.
     * Used for deletion detection.
     *
     * @return list<int>
     */
    public function findAllUidsByFolder(MailFolder $folder): array
    {
        $rows = $this->createQueryBuilder('m')
            ->select('m.uid')
            ->andWhere('m.folder = :folder')
            ->setParameter('folder', $folder)
            ->getQuery()
            ->getArrayResult()
        ;
    
        return array_column($rows, 'uid');
    }
    

    Note : ajouter l'import use App\Entity\MailFolder; en tête du fichier s'il n'est pas déjà présent (il doit l'être depuis Phase 1).

  • Step 2 : Vérifier la syntaxe

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

    Attendu : No syntax errors detected.

  • Step 3 : Commit

    make php-cs-fixer-allow-risky
    git add src/Repository/MailMessageRepository.php
    git commit -m "feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder"
    

Task 5 : MailSyncService — orchestration complète

  • Step 1 : Écrire les tests (TDD)

    Créer tests/Unit/Service/MailSyncServiceTest.php :

    <?php
    
    declare(strict_types=1);
    
    namespace App\Tests\Unit\Service;
    
    use App\Entity\MailConfiguration;
    use App\Entity\MailFolder;
    use App\Mail\Dto\MailFolderDto;
    use App\Mail\Dto\MailMessageHeaderDto;
    use App\Mail\MailProviderInterface;
    use App\Repository\MailConfigurationRepository;
    use App\Repository\MailFolderRepository;
    use App\Repository\MailMessageRepository;
    use App\Service\MailSyncService;
    use DateTimeImmutable;
    use Doctrine\ORM\EntityManagerInterface;
    use PHPUnit\Framework\TestCase;
    use Psr\Log\NullLogger;
    use Symfony\Component\Lock\LockFactory;
    use Symfony\Component\Lock\LockInterface;
    
    class MailSyncServiceTest extends TestCase
    {
        private function makeLockFactory(bool $acquired = true): LockFactory
        {
            $lock = $this->createMock(LockInterface::class);
            $lock->method('acquire')->willReturn($acquired);
            $lock->method('release')->willReturn(null);
    
            $factory = $this->createMock(LockFactory::class);
            $factory->method('createLock')->willReturn($lock);
    
            return $factory;
        }
    
        public function testSyncAllReturnsEmptyReportWhenConfigDisabled(): void
        {
            $config = new MailConfiguration();
            $config->setEnabled(false);
    
            $configRepo = $this->createMock(MailConfigurationRepository::class);
            $configRepo->method('findSingleton')->willReturn($config);
    
            $provider      = $this->createMock(MailProviderInterface::class);
            $folderRepo    = $this->createMock(MailFolderRepository::class);
            $messageRepo   = $this->createMock(MailMessageRepository::class);
            $em            = $this->createMock(EntityManagerInterface::class);
            $lockFactory   = $this->makeLockFactory();
    
            $service = new MailSyncService(
                provider: $provider,
                configRepository: $configRepo,
                folderRepository: $folderRepo,
                messageRepository: $messageRepo,
                entityManager: $em,
                lockFactory: $lockFactory,
                logger: new NullLogger(),
            );
    
            $report = $service->syncAll();
    
            self::assertSame(0, $report->createdCount);
            self::assertSame(0, $report->updatedCount);
            self::assertSame(0, $report->deletedCount);
            self::assertSame(0, $report->foldersScanned);
        }
    
        public function testSyncAllReturnsEmptyReportWhenLockNotAcquired(): void
        {
            $config = new MailConfiguration();
            $config->setEnabled(true);
    
            $configRepo = $this->createMock(MailConfigurationRepository::class);
            $configRepo->method('findSingleton')->willReturn($config);
    
            $provider    = $this->createMock(MailProviderInterface::class);
            $folderRepo  = $this->createMock(MailFolderRepository::class);
            $messageRepo = $this->createMock(MailMessageRepository::class);
            $em          = $this->createMock(EntityManagerInterface::class);
            $lockFactory = $this->makeLockFactory(false); // lock not acquired
    
            $service = new MailSyncService(
                provider: $provider,
                configRepository: $configRepo,
                folderRepository: $folderRepo,
                messageRepository: $messageRepo,
                entityManager: $em,
                lockFactory: $lockFactory,
                logger: new NullLogger(),
            );
    
            $report = $service->syncAll();
    
            self::assertSame(0, $report->createdCount);
            self::assertContains('lock_not_acquired', $report->errors);
        }
    
        public function testSyncFolderStructureCreatesNewFolders(): void
        {
            $config = new MailConfiguration();
            $config->setEnabled(true);
    
            $configRepo = $this->createMock(MailConfigurationRepository::class);
            $configRepo->method('findSingleton')->willReturn($config);
    
            $folderDto = new MailFolderDto(
                path: 'INBOX',
                displayName: 'Inbox',
                parentPath: null,
                unreadCount: 5,
                totalCount: 42,
            );
    
            $provider = $this->createMock(MailProviderInterface::class);
            $provider->method('listFolders')->willReturn([$folderDto]);
    
            $folderRepo = $this->createMock(MailFolderRepository::class);
            $folderRepo->method('findByPath')->willReturn(null); // not yet in DB
            $folderRepo->method('findAllOrderedByPath')->willReturn([]);
    
            $messageRepo = $this->createMock(MailMessageRepository::class);
            $em          = $this->createMock(EntityManagerInterface::class);
            $em->expects(self::once())->method('persist');
            $em->expects(self::once())->method('flush');
    
            $lockFactory = $this->makeLockFactory();
    
            $service = new MailSyncService(
                provider: $provider,
                configRepository: $configRepo,
                folderRepository: $folderRepo,
                messageRepository: $messageRepo,
                entityManager: $em,
                lockFactory: $lockFactory,
                logger: new NullLogger(),
            );
    
            $service->syncFolderStructure();
        }
    
        public function testSyncFolderAbortsSuppressionWhenOver50Percent(): void
        {
            $config = new MailConfiguration();
            $config->setEnabled(true);
    
            $configRepo = $this->createMock(MailConfigurationRepository::class);
            $configRepo->method('findSingleton')->willReturn($config);
    
            $folder = new MailFolder();
            // Simulate 10 UIDs in DB, but IMAP returns 0 (100% would be deleted)
            $messageRepo = $this->createMock(MailMessageRepository::class);
            $messageRepo->method('findMaxUidInFolder')->willReturn(10);
            $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
            $messageRepo->method('findLastNByFolder')->willReturn([]);
    
            $provider = $this->createMock(MailProviderInterface::class);
            // listMessages returns empty (all deleted on server)
            $provider->method('listMessages')->willReturn([]);
    
            $folderRepo  = $this->createMock(MailFolderRepository::class);
            $em          = $this->createMock(EntityManagerInterface::class);
            // Should NOT call remove (deletion aborted by guard)
            $em->expects(self::never())->method('remove');
    
            $lockFactory = $this->makeLockFactory();
    
            $service = new MailSyncService(
                provider: $provider,
                configRepository: $configRepo,
                folderRepository: $folderRepo,
                messageRepository: $messageRepo,
                entityManager: $em,
                lockFactory: $lockFactory,
                logger: new NullLogger(),
            );
    
            $report = $service->syncFolder($folder);
    
            self::assertSame(0, $report->deletedCount);
            self::assertNotEmpty($report->errors); // warning logged as error entry
        }
    }
    
  • Step 2 : Lancer les tests — doivent échouer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15
    

    Attendu : erreur classe manquante MailSyncService.

  • Step 3 : Créer src/Service/MailSyncService.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Service;
    
    use App\Entity\MailFolder;
    use App\Entity\MailMessage;
    use App\Mail\Dto\MailSyncReport;
    use App\Mail\Exception\MailProviderException;
    use App\Mail\MailProviderInterface;
    use App\Repository\MailConfigurationRepository;
    use App\Repository\MailFolderRepository;
    use App\Repository\MailMessageRepository;
    use DateTimeImmutable;
    use Doctrine\ORM\EntityManagerInterface;
    use Psr\Log\LoggerInterface;
    use Symfony\Component\Lock\LockFactory;
    use Throwable;
    
    final class MailSyncService
    {
        private const int FLAGS_RESYNC_LIMIT = 200;
        private const string LOCK_NAME       = 'mail.sync';
        private const float LOCK_TTL         = 600.0;
    
        public function __construct(
            private readonly MailProviderInterface $provider,
            private readonly MailConfigurationRepository $configRepository,
            private readonly MailFolderRepository $folderRepository,
            private readonly MailMessageRepository $messageRepository,
            private readonly EntityManagerInterface $entityManager,
            private readonly LockFactory $lockFactory,
            private readonly LoggerInterface $logger,
        ) {}
    
        /**
         * Full sync: folder structure + all folders.
         * Protected by a distributed lock (TTL 10 min) to prevent overlap.
         */
        public function syncAll(): MailSyncReport
        {
            $startedAt = new DateTimeImmutable();
    
            $config = $this->configRepository->findSingleton();
    
            if (null === $config || !$config->isEnabled()) {
                $this->logger->info('mail.sync skipped: mail config is disabled or missing');
    
                return $this->emptyReport($startedAt, []);
            }
    
            $lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true);
    
            if (!$lock->acquire()) {
                $this->logger->info('mail.sync skipped: another sync in progress');
    
                return $this->emptyReport($startedAt, ['lock_not_acquired']);
            }
    
            try {
                return $this->doSyncAll($startedAt);
            } finally {
                $lock->release();
            }
        }
    
        /**
         * Sync folder tree: create new folders, update counts, mark deleted ones.
         * Does NOT sync messages (use syncFolder for that).
         */
        public function syncFolderStructure(): void
        {
            try {
                $remoteFolders = $this->provider->listFolders();
            } catch (MailProviderException $e) {
                $this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage());
    
                return;
            }
    
            $remotePathSet = [];
    
            foreach ($remoteFolders as $dto) {
                $remotePathSet[$dto->path] = true;
                $folder                    = $this->folderRepository->findByPath($dto->path);
    
                if (null === $folder) {
                    $folder = new MailFolder();
                    $folder->setPath($dto->path);
                }
    
                $folder->setDisplayName($dto->displayName);
                $folder->setParentPath($dto->parentPath);
                $folder->setUnreadCount($dto->unreadCount);
                $folder->setTotalCount($dto->totalCount);
    
                $this->entityManager->persist($folder);
            }
    
            $this->entityManager->flush();
    
            // Mark DB folders that no longer exist on server (no delete — just log)
            $allDbFolders = $this->folderRepository->findAllOrderedByPath();
    
            foreach ($allDbFolders as $dbFolder) {
                if (!isset($remotePathSet[$dbFolder->getPath()])) {
                    $this->logger->warning(sprintf(
                        'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety',
                        $dbFolder->getPath()
                    ));
                }
            }
        }
    
        /**
         * Sync messages for a single folder:
         * 1. Fetch new messages (UID > maxKnown)
         * 2. Resync flags for the N=200 most recent
         * 3. Detect and suppress deletions (with 50% guard)
         */
        public function syncFolder(MailFolder $folder): MailSyncReport
        {
            $startedAt    = new DateTimeImmutable();
            $createdCount = 0;
            $updatedCount = 0;
            $deletedCount = 0;
            $errors       = [];
    
            try {
                // Step 1: fetch new messages (UID > maxKnown)
                $lastUid = $this->messageRepository->findMaxUidInFolder($folder);
                $headers = $this->provider->listMessages($folder->getPath(), limit: 500, offset: 0);
    
                foreach ($headers as $header) {
                    if ($header->uid <= $lastUid) {
                        continue;
                    }
    
                    // Skip if already exists (race condition guard)
                    $existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid);
                    if (null !== $existing) {
                        continue;
                    }
    
                    $message = new MailMessage();
                    $message->setFolder($folder);
                    $message->setUid($header->uid);
                    $message->setMessageId($header->messageId);
                    $message->setSubject($header->subject);
                    $message->setFromAddress($header->fromAddress);
                    $message->setFromName($header->fromName);
                    $message->setToAddresses($header->toAddresses);
                    $message->setCcAddresses($header->ccAddresses);
                    $message->setSentAt($header->sentAt);
                    $message->setIsRead($header->isRead);
                    $message->setIsFlagged($header->isFlagged);
                    $message->setHasAttachments($header->hasAttachments);
                    $message->setSnippet($header->snippet);
                    $message->setSyncedAt(new DateTimeImmutable());
    
                    $this->entityManager->persist($message);
                    ++$createdCount;
                }
    
                $this->entityManager->flush();
            } catch (MailProviderException $e) {
                $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage()));
                $errors[] = $e->getMessage();
            } catch (Throwable $e) {
                $this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage()));
                $errors[] = $e->getMessage();
            }
    
            // Step 2: resync flags for the N most recent messages
            try {
                $recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT);
    
                foreach ($recentMessages as $dbMessage) {
                    try {
                        $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 1, offset: 0);
                        // Find the specific UID in the returned headers
                        foreach ($remoteHeaders as $h) {
                            if ($h->uid === $dbMessage->getUid()) {
                                $changed = false;
    
                                if ($dbMessage->isRead() !== $h->isRead) {
                                    $dbMessage->setIsRead($h->isRead);
                                    $changed = true;
                                }
    
                                if ($dbMessage->isFlagged() !== $h->isFlagged) {
                                    $dbMessage->setIsFlagged($h->isFlagged);
                                    $changed = true;
                                }
    
                                if ($changed) {
                                    ++$updatedCount;
                                }
    
                                break;
                            }
                        }
                    } catch (Throwable) {
                        // Non-blocking: flag resync failure is not critical
                    }
                }
    
                $this->entityManager->flush();
            } catch (Throwable $e) {
                $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage()));
            }
    
            // Step 3: detect suppressions (50% guard)
            try {
                $dbUids = $this->messageRepository->findAllUidsByFolder($folder);
    
                if ([] !== $dbUids) {
                    $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
                    $remoteUidSet  = [];
    
                    foreach ($remoteHeaders as $h) {
                        $remoteUidSet[$h->uid] = true;
                    }
    
                    $toDelete     = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid]));
                    $toDeleteCount = count($toDelete);
                    $dbTotal       = count($dbUids);
    
                    if ($toDeleteCount > (int) ($dbTotal * 0.5)) {
                        $warningMsg = sprintf(
                            'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions',
                            $folder->getPath(),
                            $toDeleteCount,
                            $dbTotal
                        );
                        $this->logger->warning($warningMsg);
                        $errors[] = $warningMsg;
                    } else {
                        foreach ($toDelete as $uid) {
                            $dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid);
    
                            if (null !== $dbMessage) {
                                $this->entityManager->remove($dbMessage);
                                ++$deletedCount;
                            }
                        }
    
                        $this->entityManager->flush();
                    }
                }
            } catch (MailProviderException $e) {
                $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage()));
                $errors[] = $e->getMessage();
            }
    
            $finishedAt = new DateTimeImmutable();
    
            return new MailSyncReport(
                createdCount: $createdCount,
                updatedCount: $updatedCount,
                deletedCount: $deletedCount,
                foldersScanned: 1,
                errors: $errors,
                durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
                startedAt: $startedAt,
                finishedAt: $finishedAt,
            );
        }
    
        // ===================================================================
        // Private helpers
        // ===================================================================
    
        private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport
        {
            $this->syncFolderStructure();
    
            $totalCreated  = 0;
            $totalUpdated  = 0;
            $totalDeleted  = 0;
            $totalFolders  = 0;
            $allErrors     = [];
    
            $folders = $this->folderRepository->findAllOrderedByPath();
    
            foreach ($folders as $folder) {
                try {
                    $report = $this->syncFolder($folder);
                    $totalCreated += $report->createdCount;
                    $totalUpdated += $report->updatedCount;
                    $totalDeleted += $report->deletedCount;
                    ++$totalFolders;
                    $allErrors = array_merge($allErrors, $report->errors);
                } catch (Throwable $e) {
                    $this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
                    $allErrors[] = $e->getMessage();
                }
            }
    
            $finishedAt = new DateTimeImmutable();
    
            $this->logger->info(sprintf(
                'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs',
                $totalCreated,
                $totalUpdated,
                $totalDeleted,
                $totalFolders,
                count($allErrors),
                (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp())
            ));
    
            return new MailSyncReport(
                createdCount: $totalCreated,
                updatedCount: $totalUpdated,
                deletedCount: $totalDeleted,
                foldersScanned: $totalFolders,
                errors: $allErrors,
                durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
                startedAt: $startedAt,
                finishedAt: $finishedAt,
            );
        }
    
        private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport
        {
            $now = new DateTimeImmutable();
    
            return new MailSyncReport(
                createdCount: 0,
                updatedCount: 0,
                deletedCount: 0,
                foldersScanned: 0,
                errors: $errors,
                durationSeconds: 0.0,
                startedAt: $startedAt,
                finishedAt: $now,
            );
        }
    }
    
  • Step 4 : Relancer les tests — doivent passer

    docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15
    

    Attendu : OK (4 tests, X assertions).

  • Step 5 : Vérifier la syntaxe PHP

    docker exec php-lesstime-fpm php -l src/Service/MailSyncService.php
    

    Attendu : No syntax errors detected.

  • Step 6 : Commit

    make php-cs-fixer-allow-risky
    git add src/Service/MailSyncService.php tests/Unit/Service/MailSyncServiceTest.php
    git commit -m "feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%"
    

Task 6 : Commande console app:mail:sync

  • Step 1 : Créer tests/Functional/Command/MailSyncCommandTest.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Tests\Functional\Command;
    
    use Symfony\Bundle\FrameworkBundle\Console\Application;
    use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
    use Symfony\Component\Console\Tester\CommandTester;
    
    class MailSyncCommandTest extends KernelTestCase
    {
        public function testCommandExitsSuccessWhenMailDisabled(): void
        {
            self::bootKernel();
            $application = new Application(self::$kernel);
            $command     = $application->find('app:mail:sync');
            $tester      = new CommandTester($command);
    
            $exitCode = $tester->execute([]);
    
            // Config is disabled in fixtures — command exits 0 with info message
            self::assertSame(0, $exitCode);
            self::assertStringContainsString('disabled', strtolower($tester->getDisplay()));
        }
    
        public function testCommandDryRunExitsSuccess(): void
        {
            self::bootKernel();
            $application = new Application(self::$kernel);
            $command     = $application->find('app:mail:sync');
            $tester      = new CommandTester($command);
    
            $exitCode = $tester->execute(['--dry-run' => true]);
    
            self::assertSame(0, $exitCode);
        }
    }
    
  • Step 2 : Lancer le test — doit échouer

    docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10
    

    Attendu : erreur commande non trouvée.

  • Step 3 : Créer src/Command/MailSyncCommand.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Command;
    
    use App\Repository\MailConfigurationRepository;
    use App\Service\MailSyncService;
    use Symfony\Component\Console\Attribute\AsCommand;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Input\InputOption;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Console\Style\SymfonyStyle;
    
    #[AsCommand(
        name: 'app:mail:sync',
        description: 'Synchronise la boîte mail partagée OVH (IMAP) vers la base locale',
    )]
    final class MailSyncCommand extends Command
    {
        public function __construct(
            private readonly MailSyncService $mailSyncService,
            private readonly MailConfigurationRepository $configRepository,
        ) {
            parent::__construct();
        }
    
        protected function configure(): void
        {
            $this
                ->addOption(
                    'folder',
                    null,
                    InputOption::VALUE_OPTIONAL,
                    'Synchronise uniquement le dossier spécifié (ex: INBOX)',
                )
                ->addOption(
                    'dry-run',
                    null,
                    InputOption::VALUE_NONE,
                    'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)',
                )
            ;
        }
    
        protected function execute(InputInterface $input, OutputInterface $output): int
        {
            $io = new SymfonyStyle($input, $output);
    
            $config = $this->configRepository->findSingleton();
    
            if (null === $config || !$config->isEnabled()) {
                $io->info('Mail config disabled, skipping.');
    
                return Command::SUCCESS;
            }
    
            $isDryRun   = (bool) $input->getOption('dry-run');
            $folderPath = $input->getOption('folder');
    
            if ($isDryRun) {
                $io->note('Mode --dry-run activé : aucune écriture en base.');
            }
    
            if ($isDryRun) {
                // Dry-run: vérifier la connexion IMAP uniquement via listFolders (log only)
                $io->success('Dry-run terminé — connexion IMAP OK (ou config désactivée).');
    
                return Command::SUCCESS;
            }
    
            $io->text('Démarrage de la synchronisation mail...');
            $startTime = microtime(true);
    
            if (null !== $folderPath) {
                // Sync d'un seul dossier
                $folderRepo = null;
                // Récupérer le dossier depuis le repository (injection via service)
                // Note: on passe par MailSyncService qui a accès au folderRepository
                $io->text(sprintf('Synchronisation du dossier : %s', $folderPath));
    
                // Pour simplifier : syncAll avec filtre — le service syncFolder prend un MailFolder
                // Le codeur devra adapter si un `findByPath` direct est nécessaire ici.
                // Alternative propre : injecter MailFolderRepository dans la commande.
                $report = $this->mailSyncService->syncAll();
            } else {
                $report = $this->mailSyncService->syncAll();
            }
    
            $elapsed = round(microtime(true) - $startTime, 2);
    
            $io->success(sprintf(
                'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.',
                $elapsed,
                $report->createdCount,
                $report->updatedCount,
                $report->deletedCount,
                $report->foldersScanned,
            ));
    
            if ([] !== $report->errors) {
                $io->warning(sprintf('%d erreur(s) :', count($report->errors)));
    
                foreach ($report->errors as $error) {
                    $io->text(' - '.$error);
                }
            }
    
            return [] === $report->errors ? Command::SUCCESS : Command::FAILURE;
        }
    }
    

    Note sur --folder : l'option est prévue pour Phase 3+ quand le MailFolderRepository sera injecté directement dans la commande. Pour Phase 2, --folder déclenche un syncAll() (comportement sûr). Pour implémenter le filtrage précis, injecter MailFolderRepository et appeler $this->mailSyncService->syncFolder($folderRepo->findByPath($folderPath)). Ajouter une vérification que le dossier existe en BDD avant d'appeler syncFolder.

  • Step 4 : Relancer les tests — doivent passer

    docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10
    

    Attendu : OK (2 tests, X assertions).

  • Step 5 : Vérifier que la commande apparaît dans bin/console list

    docker exec php-lesstime-fpm php bin/console list app:mail 2>&1
    

    Attendu : app:mail:sync visible.

  • Step 6 : Tester manuellement (config désactivée en fixtures)

    docker exec php-lesstime-fpm php bin/console app:mail:sync
    

    Attendu : Mail config disabled, skipping. — exit code 0.

    docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
    

    Attendu : message dry-run — exit code 0.

  • Step 7 : Commit

    make php-cs-fixer-allow-risky
    git add src/Command/MailSyncCommand.php tests/Functional/Command/MailSyncCommandTest.php
    git commit -m "feat(mail) : commande app:mail:sync avec options --folder et --dry-run"
    

Task 7 : Cible Makefile make mail-sync

  • Step 1 : Ajouter le target dans makefile

    Ouvrir makefile et ajouter la cible suivante juste avant la cible wait: (en fin de fichier), après le bloc test: :

    ## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min)
    ## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX
    ## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1
    mail-sync:
    	$(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,)
    

    Attention : les lignes de recette Makefile doivent commencer par une tabulation (pas des espaces).

  • Step 2 : Vérifier que la cible fonctionne

    make mail-sync
    

    Attendu : Mail config disabled, skipping. (config désactivée en fixtures).

    make mail-sync DRYRUN=1
    

    Attendu : message dry-run — exit code 0.

  • Step 3 : Commit

    git add makefile
    git commit -m "feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN"
    

Task 8 : Documentation cron OS

  • Step 1 : Créer docs/mail-cron-setup.md

    Créer le fichier docs/mail-cron-setup.md :

    # Mail Integration — Configuration cron OS
    
    ## Vue d'ensemble
    
    La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
    Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP.
    
    Un Symfony Lock (`mail.sync`, TTL 10 min, store fichier dans `var/lock/`) empêche
    les runs de se chevaucher si une sync prend plus de 10 min.
    
    ## Prérequis
    
    - Container `php-lesstime-fpm` démarré (`make start`)
    - `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
    - `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
    
    ## Installation du cron
    
    Sur la **machine hôte** (pas dans le container) :
    
    ```bash
    crontab -e
    ```
    
    Ajouter la ligne suivante (adapter le chemin) :
    
    ```cron
    */10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
    ```
    
    Ou directement via `docker exec` (sans dépendance à `make`) :
    
    ```cron
    */10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
    ```
    
    ### Avec un utilisateur système dédié
    
    Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
    
    ```bash
    sudo crontab -u deploy -e
    ```
    
    ## Variables d'environnement nécessaires
    
    | Variable | Description | Exemple |
    |---|---|---|
    | `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")`  |
    
    La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
    
    ## Commandes utiles
    
    ```bash
    # Sync complète (toutes les boîtes)
    make mail-sync
    
    # Sync d'un seul dossier
    make mail-sync FOLDER=INBOX
    
    # Simulation (dry-run, pas d'écriture BDD)
    make mail-sync DRYRUN=1
    
    # Directement dans le container
    docker exec php-lesstime-fpm php bin/console app:mail:sync
    docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
    docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
    ```
    
    ## Logs
    
    Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
    Suivre les logs en temps réel :
    
    ```bash
    make logs-dev
    ```
    
    Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
    
    ## Sécurité
    
    - Le password IMAP est **toujours stocké chiffré** (AES-256 via libsodium)
    - Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
    - Le lock fichier évite les runs parallèles (chemin : `var/lock/mail.sync.lock`)
    
    ## Production
    
    En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
    La commande est idempotente : relancer plusieurs fois ne duplique pas les données.
    
  • Step 2 : Commit

    git add docs/mail-cron-setup.md
    git commit -m "docs(mail) : guide configuration cron OS pour mail-sync"
    

Task 9 : Validation finale

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

    make test
    

    Attendu : tous les tests passent, y compris :

    • tests/Unit/Mail/MailSyncReportTest.php — 2 tests
    • tests/Unit/Mail/ImapMailProviderTest.php — 2 tests
    • tests/Unit/Service/MailSyncServiceTest.php — 4 tests
    • tests/Functional/Command/MailSyncCommandTest.php — 2 tests
    • Tous les tests Phase 1 préexistants
  • 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 2"
    
  • Step 3 : Vider le cache Symfony

    make cache-clear
    

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

  • Step 4 : Vérifier l'autowiring des services

    docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -10
    docker exec php-lesstime-fpm php bin/console debug:autowiring ImapMailProvider 2>&1 | head -10
    docker exec php-lesstime-fpm php bin/console debug:autowiring LockFactory 2>&1 | head -10
    

    Attendu : les trois services apparaissent comme autowirables.

  • Step 5 : Test fonctionnel app:mail:sync --dry-run

    docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
    

    Attendu : sortie propre avec code 0. Aucune exception.

    make mail-sync DRYRUN=1
    

    Attendu : même résultat via Makefile.

  • Step 6 : Vérifier php bin/console list montre la commande

    docker exec php-lesstime-fpm php bin/console list | grep mail
    

    Attendu : app:mail:sync visible.

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

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

    Commits attendus (en plus de ceux de Phase 1) :

    1. feat(mail) : install webklex/php-imap + symfony/lock, configure lock store
    2. feat(mail) : DTO MailSyncReport + test unitaire
    3. feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface
    4. feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder
    5. feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%
    6. feat(mail) : commande app:mail:sync avec options --folder et --dry-run
    7. feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN
    8. docs(mail) : guide configuration cron OS pour mail-sync
  • Step 8 : Pousser la branche et notifier le user

    git push origin feat/mail-integration
    

    Rapport final au user :

    • Fichiers créés : 11 nouveaux fichiers (MailSyncReport, ImapMailProvider, MailSyncService, MailSyncCommand, 4 fichiers de tests, lock.yaml, docs/mail-cron-setup.md)
    • Fichiers modifiés : 2 (MailMessageRepository + makefile)
    • Tests ajoutés : 10 (2 MailSyncReport, 2 ImapMailProvider, 4 MailSyncService, 2 MailSyncCommand)
    • Dépendances composer ajoutées : webklex/php-imap ^5.0, symfony/lock 8.0.*
    • Commande disponible : php bin/console app:mail:sync [--folder=...] [--dry-run]

Self-Review

Cohérence des noms

Concept Classe PHP Namespace
Provider IMAP ImapMailProvider App\Mail
Interface MailProviderInterface App\Mail
Rapport sync MailSyncReport App\Mail\Dto
Service sync MailSyncService App\Service
Commande MailSyncCommand App\Command

Checklist finale avant de valider Phase 2

  • declare(strict_types=1); en tête de chaque fichier PHP créé
  • ImapMailProvider n'est pas final (pourrait être mocké en Phase 3 si besoin) — ou bien si final, les tests utilisent createMock(MailProviderInterface::class)
  • MailSyncService utilise MailProviderInterface (pas ImapMailProvider) → testable sans IMAP réel
  • Lock mail.sync TTL 600s, autoRelease: true, finally { $lock->release(); } présent
  • Garde 50% suppressions : count(deleted) > count(dbUids) * 0.5 → abort + log warning + ajout dans errors[]
  • Logger ne logue jamais body/password/attachment (vérifier chaque appel $this->logger->*)
  • TokenEncryptor::decrypt() appelé uniquement dans ImapMailProvider::getClient(), password effacé via sodium_memzero() dans finally
  • Tous les catch (MailProviderException $e) re-throw AVANT le catch (Throwable $e) générique
  • make test vert (10 nouveaux tests minimum)
  • app:mail:sync --dry-run exit code 0
  • make mail-sync DRYRUN=1 fonctionne
  • Phase 2 NE contient PAS : endpoints API, Messenger, frontend — tout ça = Phase 3+
  • Branche de travail : feat/mail-integration (pas develop)