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

  ```bash
  git branch --show-current
  ```

  Attendu : `feat/mail-integration`. Si non, basculer :

  ```bash
  git checkout feat/mail-integration
  ```

- [ ] **Step 2 : Créer les dossiers de tests si absents**

  ```bash
  mkdir -p tests/Unit/Mail tests/Unit/Service tests/Functional/Command
  ```

- [ ] **Step 3 : Installer `webklex/php-imap`**

  ```bash
  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`**

  ```bash
  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 :

  ```bash
  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` :

  ```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**

  ```bash
  make cache-clear
  ```

  Attendu : pas d'erreur de service manquant.

- [ ] **Step 7 : Commit**

  ```bash
  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
  <?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**

  ```bash
  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
  <?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**

  ```bash
  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**

  ```bash
  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
  <?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**

  ```bash
  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
  <?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**

  ```bash
  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**

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

  Attendu : `No syntax errors detected`.

- [ ] **Step 6 : Commit**

  ```bash
  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 :

  ```php
  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**

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

  Attendu : `No syntax errors detected`.

- [ ] **Step 3 : Commit**

  ```bash
  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
  <?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**

  ```bash
  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
  <?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**

  ```bash
  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**

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

  Attendu : `No syntax errors detected`.

- [ ] **Step 6 : Commit**

  ```bash
  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
  <?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**

  ```bash
  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
  <?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**

  ```bash
  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`**

  ```bash
  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)**

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

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

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

  Attendu : message dry-run — exit code 0.

- [ ] **Step 7 : Commit**

  ```bash
  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:` :

  ```makefile
  ## 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**

  ```bash
  make mail-sync
  ```

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

  ```bash
  make mail-sync DRYRUN=1
  ```

  Attendu : message dry-run — exit code 0.

- [ ] **Step 3 : Commit**

  ```bash
  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` :

  ````markdown
  # 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**

  ```bash
  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**

  ```bash
  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**

  ```bash
  make php-cs-fixer-allow-risky
  ```

  Si des fichiers sont modifiés :

  ```bash
  git add -p
  git commit -m "style(mail) : php-cs-fixer pass phase 2"
  ```

- [ ] **Step 3 : Vider le cache Symfony**

  ```bash
  make cache-clear
  ```

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

- [ ] **Step 4 : Vérifier l'autowiring des services**

  ```bash
  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`**

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

  Attendu : sortie propre avec code 0. Aucune exception.

  ```bash
  make mail-sync DRYRUN=1
  ```

  Attendu : même résultat via Makefile.

- [ ] **Step 6 : Vérifier `php bin/console list` montre la commande**

  ```bash
  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**

  ```bash
  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**

  ```bash
  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`)
