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