From 23191bdab60e2c5084ea8a314cd3a929a9cececa Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:29:18 +0200 Subject: [PATCH] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill=C3=A9=20Ph?= =?UTF-8?q?ase=202=20=E2=80=94=20ImapMailProvider,=20MailSyncService,=20co?= =?UTF-8?q?mmande=20app:mail:sync=20(9=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-mail-phase2-imap-sync.md | 1781 +++++++++++++++++ 1 file changed, 1781 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md new file mode 100644 index 0000000..8f27a53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md @@ -0,0 +1,1781 @@ +# 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 + 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 + $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 + 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 + 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 + */ + 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 + */ + 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 + 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 + 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 + 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 + 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`)