60 KiB
Mail Integration — Phase 2 : IMAP Provider + Sync
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implémenter le provider IMAP (ImapMailProvider), le service de synchronisation (MailSyncService) avec gestion de la concurrence (Symfony Lock), et la commande console app:mail:sync déclenchée par cron OS.
Architecture: ImapMailProvider utilise webklex/php-imap pour parler à OVH/Zimbra ; lit la config MailConfiguration via repository, déchiffre password via TokenEncryptor::decrypt(). MailSyncService orchestre 3 étapes par cycle (sync structure dossiers / sync nouveaux messages UID > maxKnown / resync flags des N=200 derniers / detect suppressions avec garde 50%). Lock mail.sync TTL 10 min empêche overlap.
Tech Stack: PHP 8.4, Symfony 8.0, webklex/php-imap ^5.0, symfony/lock ^8.0 (à installer — absent du composer.json actuel), PostgreSQL 16.
Branche cible : feat/mail-integration (créée en Phase 1 — vérifier qu'elle est active).
Fichiers créés/modifiés par le codeur :
| Fichier | Action |
|---|---|
src/Mail/Dto/MailSyncReport.php |
Créer |
src/Mail/ImapMailProvider.php |
Créer |
src/Service/MailSyncService.php |
Créer |
src/Command/MailSyncCommand.php |
Créer |
src/Repository/MailMessageRepository.php |
Modifier (ajout findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder) |
config/packages/lock.yaml |
Créer (si absent) |
makefile |
Modifier (ajout target mail-sync) |
docs/mail-cron-setup.md |
Créer |
tests/Unit/Mail/ImapMailProviderTest.php |
Créer |
tests/Unit/Service/MailSyncServiceTest.php |
Créer |
tests/Unit/Mail/MailSyncReportTest.php |
Créer |
tests/Functional/Command/MailSyncCommandTest.php |
Créer |
Task 1 : Préparer l'environnement
-
Step 1 : Vérifier la branche active
git branch --show-currentAttendu :
feat/mail-integration. Si non, basculer :git checkout feat/mail-integration -
Step 2 : Créer les dossiers de tests si absents
mkdir -p tests/Unit/Mail tests/Unit/Service tests/Functional/Command -
Step 3 : Installer
webklex/php-imapdocker exec php-lesstime-fpm composer require webklex/php-imap:"^5.0"Vérifier la compatibilité PHP 8.4 :
webklex/php-imap ^5.0supporte PHP 8.1+. Si^5.0n'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.jsonmis à jour, pas d'erreur de conflit. -
Step 4 : Installer
symfony/lockdocker exec php-lesstime-fpm composer require symfony/lock:"8.0.*"Attendu :
symfony/lockajouté dansrequiredecomposer.json. -
Step 5 : Créer
config/packages/lock.yamlsi absentVérifier :
docker exec php-lesstime-fpm php bin/console debug:config framework lock 2>&1 | head -10Si non configuré, créer
config/packages/lock.yaml:framework: lock: resources: default: "%kernel.project_dir%/var/lock"Ce fichier configure un store fichier dans
var/lock/. Le répertoire sera créé automatiquement par Symfony. -
Step 6 : Vider le cache pour prendre en compte la nouvelle config
make cache-clearAttendu : pas d'erreur de service manquant.
-
Step 7 : Commit
git add composer.json composer.lock config/packages/lock.yaml git commit -m "feat(mail) : install webklex/php-imap + symfony/lock, configure lock store"
Task 2 : DTO MailSyncReport
-
Step 1 : Écrire le test (TDD — doit échouer)
Créer
tests/Unit/Mail/MailSyncReportTest.php:<?php declare(strict_types=1); namespace App\Tests\Unit\Mail; use App\Mail\Dto\MailSyncReport; use DateTimeImmutable; use PHPUnit\Framework\TestCase; class MailSyncReportTest extends TestCase { public function testInstantiationWithDefaults(): void { $start = new DateTimeImmutable('2026-01-01 10:00:00'); $finish = new DateTimeImmutable('2026-01-01 10:00:05'); $report = new MailSyncReport( createdCount: 3, updatedCount: 1, deletedCount: 0, foldersScanned: 2, errors: [], durationSeconds: 5.0, startedAt: $start, finishedAt: $finish, ); self::assertSame(3, $report->createdCount); self::assertSame(1, $report->updatedCount); self::assertSame(0, $report->deletedCount); self::assertSame(2, $report->foldersScanned); self::assertSame([], $report->errors); self::assertSame(5.0, $report->durationSeconds); self::assertSame($start, $report->startedAt); self::assertSame($finish, $report->finishedAt); } public function testWithErrors(): void { $report = new MailSyncReport( createdCount: 0, updatedCount: 0, deletedCount: 0, foldersScanned: 1, errors: ['IMAP connection timeout'], durationSeconds: 0.5, startedAt: new DateTimeImmutable(), finishedAt: new DateTimeImmutable(), ); self::assertCount(1, $report->errors); self::assertSame('IMAP connection timeout', $report->errors[0]); } } -
Step 2 : Lancer le test — doit échouer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10Attendu : erreur classe manquante.
-
Step 3 : Créer
src/Mail/Dto/MailSyncReport.php<?php declare(strict_types=1); namespace App\Mail\Dto; use DateTimeImmutable; final readonly class MailSyncReport { /** * @param list<string> $errors */ public function __construct( public int $createdCount, public int $updatedCount, public int $deletedCount, public int $foldersScanned, public array $errors, public float $durationSeconds, public DateTimeImmutable $startedAt, public DateTimeImmutable $finishedAt, ) {} } -
Step 4 : Relancer le test — doit passer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10Attendu :
OK (2 tests, X assertions). -
Step 5 : Commit
make php-cs-fixer-allow-risky git add src/Mail/Dto/MailSyncReport.php tests/Unit/Mail/MailSyncReportTest.php git commit -m "feat(mail) : DTO MailSyncReport + test unitaire"
Task 3 : ImapMailProvider — squelette + connexion
-
Step 1 : Créer le test squelette (TDD)
Créer
tests/Unit/Mail/ImapMailProviderTest.php:<?php declare(strict_types=1); namespace App\Tests\Unit\Mail; use App\Entity\MailConfiguration; use App\Mail\Exception\MailProviderException; use App\Mail\ImapMailProvider; use App\Repository\MailConfigurationRepository; use App\Service\TokenEncryptor; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; class ImapMailProviderTest extends TestCase { public function testThrowsWhenConfigDisabled(): void { $config = new MailConfiguration(); $config->setEnabled(false); $repo = $this->createMock(MailConfigurationRepository::class); $repo->method('findSingleton')->willReturn($config); $encryptor = $this->createMock(TokenEncryptor::class); $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); $this->expectException(MailProviderException::class); $provider->listFolders(); } public function testThrowsWhenConfigMissing(): void { $repo = $this->createMock(MailConfigurationRepository::class); $repo->method('findSingleton')->willReturn(null); $encryptor = $this->createMock(TokenEncryptor::class); $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); $this->expectException(MailProviderException::class); $provider->listFolders(); } } -
Step 2 : Lancer le test — doit échouer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10Attendu : erreur classe manquante.
-
Step 3 : Créer
src/Mail/ImapMailProvider.php— squelette complet<?php declare(strict_types=1); namespace App\Mail; use App\Mail\Dto\MailAttachmentDto; use App\Mail\Dto\MailFolderDto; use App\Mail\Dto\MailMessageDetailDto; use App\Mail\Dto\MailMessageHeaderDto; use App\Mail\Exception\MailProviderException; use App\Repository\MailConfigurationRepository; use App\Service\TokenEncryptor; use DateTimeImmutable; use Psr\Log\LoggerInterface; use Throwable; use Webklex\PHPIMAP\Client; use Webklex\PHPIMAP\ClientManager; final class ImapMailProvider implements MailProviderInterface { public function __construct( private readonly MailConfigurationRepository $configRepository, private readonly TokenEncryptor $tokenEncryptor, private readonly LoggerInterface $logger, ) {} public function listFolders(): array { $client = $this->getClient(); try { $folders = $client->getFolders(false); $result = []; foreach ($folders as $folder) { $path = $folder->path; $parentPath = null; $lastDelim = strrpos($path, $folder->delimiter ?? '.'); if (false !== $lastDelim && $lastDelim > 0) { $parentPath = substr($path, 0, $lastDelim); } $result[] = new MailFolderDto( path: $path, displayName: $folder->name, parentPath: $parentPath, unreadCount: (int) ($folder->status['unseen'] ?? 0), totalCount: (int) ($folder->status['messages'] ?? 0), ); } $client->disconnect(); return $result; } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage()); throw MailProviderException::operationFailed('listFolders', $e->getMessage()); } } public function listMessages(string $folderPath, int $limit, int $offset): array { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $messages = $folder->query()->leaveUnread()->get(); $result = []; $items = array_slice($messages->toArray(), $offset, $limit); foreach ($items as $message) { $result[] = $this->buildHeaderDto($message); } $client->disconnect(); return $result; } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage())); throw MailProviderException::operationFailed('listMessages', $e->getMessage()); } } public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); if (null === $message) { throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath)); } $header = $this->buildHeaderDto($message); $bodyHtml = $message->getHTMLBody(false) ?: null; $bodyText = $message->getTextBody() ?: null; $attachments = []; foreach ($message->getAttachments() as $att) { $attachments[] = new MailAttachmentDto( partNumber: (string) ($att->part_number ?? '1'), filename: $att->getName() ?? 'attachment', mimeType: $att->getMimeType() ?? 'application/octet-stream', size: $att->getSize() ?? 0, ); } $client->disconnect(); return new MailMessageDetailDto( header: $header, bodyHtml: $bodyHtml, bodyText: $bodyText, attachments: $attachments, ); } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage())); throw MailProviderException::operationFailed('fetchMessage', $e->getMessage()); } } public function markRead(string $folderPath, int $uid, bool $read): void { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); if (null === $message) { throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid)); } if ($read) { $message->setFlag('Seen'); } else { $message->unsetFlag('Seen'); } $client->disconnect(); } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage())); throw MailProviderException::operationFailed('markRead', $e->getMessage()); } } public function markFlagged(string $folderPath, int $uid, bool $flagged): void { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); if (null === $message) { throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid)); } if ($flagged) { $message->setFlag('Flagged'); } else { $message->unsetFlag('Flagged'); } $client->disconnect(); } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage())); throw MailProviderException::operationFailed('markFlagged', $e->getMessage()); } } public function moveMessage(string $folderPath, int $uid, string $targetFolder): void { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); if (null === $message) { throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid)); } $message->moveToFolder($targetFolder); $client->disconnect(); } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage())); throw MailProviderException::operationFailed('moveMessage', $e->getMessage()); } } public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string { $client = $this->getClient(); try { $folder = $client->getFolder($folderPath); $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); if (null === $message) { throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid)); } foreach ($message->getAttachments() as $att) { if ((string) ($att->part_number ?? '1') === $partNumber) { $client->disconnect(); return (string) $att->getContent(); } } $client->disconnect(); throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid)); } catch (MailProviderException $e) { throw $e; } catch (Throwable $e) { $this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage())); throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage()); } } // =================================================================== // Private helpers // =================================================================== private function getClient(): Client { $config = $this->configRepository->findSingleton(); if (null === $config || !$config->isEnabled()) { throw MailProviderException::connectionFailed('Mail configuration is missing or disabled'); } if (null === $config->getEncryptedPassword()) { throw MailProviderException::connectionFailed('No password configured'); } $password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword()); try { $manager = new ClientManager(); $client = $manager->make([ 'host' => $config->getImapHost(), 'port' => $config->getImapPort(), 'encryption' => $config->getImapEncryption(), 'validate_cert' => true, 'username' => $config->getUsername(), 'password' => $password, 'protocol' => 'imap', ]); $client->connect(); } catch (Throwable $e) { $this->logger->error('IMAP connection failed: '.$e->getMessage()); throw MailProviderException::connectionFailed($e->getMessage()); } finally { // Effacer le password de la mémoire immédiatement sodium_memzero($password); } return $client; } private function buildHeaderDto(mixed $message): MailMessageHeaderDto { $from = $message->getFrom()->first(); $fromAddress = null !== $from ? (string) $from->mail : ''; $fromName = null !== $from ? ($from->personal ?? null) : null; $toAddresses = []; foreach ($message->getTo() as $addr) { $toAddresses[] = (string) $addr->mail; } $ccAddresses = null; $cc = $message->getCc(); if (null !== $cc && $cc->count() > 0) { $ccAddresses = []; foreach ($cc as $addr) { $ccAddresses[] = (string) $addr->mail; } } $sentAt = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable(); $snippet = null; $text = $message->getTextBody(); if (null !== $text && '' !== $text) { $snippet = mb_substr(strip_tags($text), 0, 200); } return new MailMessageHeaderDto( uid: (int) $message->getUid(), messageId: (string) $message->getMessageId(), subject: $message->getSubject() ?: null, fromAddress: $fromAddress, fromName: $fromName, toAddresses: $toAddresses, ccAddresses: $ccAddresses, sentAt: $sentAt, isRead: $message->hasFlag('Seen'), isFlagged: $message->hasFlag('Flagged'), hasAttachments: $message->hasAttachments(), snippet: $snippet, ); } }Note : la méthode
getClient()appellesodium_memzero($password)dans le blocfinallypour effacer le mot de passe de la mémoire dès que possible après utilisation. Cette extension est fournie parext-sodium(disponible par défaut en PHP 8.4). -
Step 4 : Relancer les tests squelette — doivent passer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10Attendu :
OK (2 tests, 2 assertions). -
Step 5 : Vérifier la syntaxe PHP
docker exec php-lesstime-fpm php -l src/Mail/ImapMailProvider.phpAttendu :
No syntax errors detected. -
Step 6 : Commit
make php-cs-fixer-allow-risky git add src/Mail/ImapMailProvider.php tests/Unit/Mail/ImapMailProviderTest.php git commit -m "feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface"
Task 4 : Méthodes manquantes dans MailMessageRepository
La Phase 1 a créé MailMessageRepository avec findByMessageId, findByFolderAndUid, findByFolderPaginated, countUnreadByFolder. MailSyncService a besoin de méthodes supplémentaires.
-
Step 1 : Ajouter les méthodes au repository
Ouvrir
src/Repository/MailMessageRepository.phpet ajouter les méthodes suivantes à la suite des méthodes existantes :public function findMaxUidInFolder(MailFolder $folder): int { $result = $this->createQueryBuilder('m') ->select('MAX(m.uid)') ->andWhere('m.folder = :folder') ->setParameter('folder', $folder) ->getQuery() ->getSingleScalarResult() ; return (int) ($result ?? 0); } /** * Returns the N most recent messages in a folder (by sentAt DESC, id DESC). * Used for flag resync. * * @return list<MailMessage> */ public function findLastNByFolder(MailFolder $folder, int $limit): array { return $this->createQueryBuilder('m') ->andWhere('m.folder = :folder') ->setParameter('folder', $folder) ->orderBy('m.sentAt', 'DESC') ->addOrderBy('m.id', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult() ; } /** * Returns all UIDs stored in DB for a given folder. * Used for deletion detection. * * @return list<int> */ public function findAllUidsByFolder(MailFolder $folder): array { $rows = $this->createQueryBuilder('m') ->select('m.uid') ->andWhere('m.folder = :folder') ->setParameter('folder', $folder) ->getQuery() ->getArrayResult() ; return array_column($rows, 'uid'); }Note : ajouter l'import
use App\Entity\MailFolder;en tête du fichier s'il n'est pas déjà présent (il doit l'être depuis Phase 1). -
Step 2 : Vérifier la syntaxe
docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.phpAttendu :
No syntax errors detected. -
Step 3 : Commit
make php-cs-fixer-allow-risky git add src/Repository/MailMessageRepository.php git commit -m "feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder"
Task 5 : MailSyncService — orchestration complète
-
Step 1 : Écrire les tests (TDD)
Créer
tests/Unit/Service/MailSyncServiceTest.php:<?php declare(strict_types=1); namespace App\Tests\Unit\Service; use App\Entity\MailConfiguration; use App\Entity\MailFolder; use App\Mail\Dto\MailFolderDto; use App\Mail\Dto\MailMessageHeaderDto; use App\Mail\MailProviderInterface; use App\Repository\MailConfigurationRepository; use App\Repository\MailFolderRepository; use App\Repository\MailMessageRepository; use App\Service\MailSyncService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; class MailSyncServiceTest extends TestCase { private function makeLockFactory(bool $acquired = true): LockFactory { $lock = $this->createMock(LockInterface::class); $lock->method('acquire')->willReturn($acquired); $lock->method('release')->willReturn(null); $factory = $this->createMock(LockFactory::class); $factory->method('createLock')->willReturn($lock); return $factory; } public function testSyncAllReturnsEmptyReportWhenConfigDisabled(): void { $config = new MailConfiguration(); $config->setEnabled(false); $configRepo = $this->createMock(MailConfigurationRepository::class); $configRepo->method('findSingleton')->willReturn($config); $provider = $this->createMock(MailProviderInterface::class); $folderRepo = $this->createMock(MailFolderRepository::class); $messageRepo = $this->createMock(MailMessageRepository::class); $em = $this->createMock(EntityManagerInterface::class); $lockFactory = $this->makeLockFactory(); $service = new MailSyncService( provider: $provider, configRepository: $configRepo, folderRepository: $folderRepo, messageRepository: $messageRepo, entityManager: $em, lockFactory: $lockFactory, logger: new NullLogger(), ); $report = $service->syncAll(); self::assertSame(0, $report->createdCount); self::assertSame(0, $report->updatedCount); self::assertSame(0, $report->deletedCount); self::assertSame(0, $report->foldersScanned); } public function testSyncAllReturnsEmptyReportWhenLockNotAcquired(): void { $config = new MailConfiguration(); $config->setEnabled(true); $configRepo = $this->createMock(MailConfigurationRepository::class); $configRepo->method('findSingleton')->willReturn($config); $provider = $this->createMock(MailProviderInterface::class); $folderRepo = $this->createMock(MailFolderRepository::class); $messageRepo = $this->createMock(MailMessageRepository::class); $em = $this->createMock(EntityManagerInterface::class); $lockFactory = $this->makeLockFactory(false); // lock not acquired $service = new MailSyncService( provider: $provider, configRepository: $configRepo, folderRepository: $folderRepo, messageRepository: $messageRepo, entityManager: $em, lockFactory: $lockFactory, logger: new NullLogger(), ); $report = $service->syncAll(); self::assertSame(0, $report->createdCount); self::assertContains('lock_not_acquired', $report->errors); } public function testSyncFolderStructureCreatesNewFolders(): void { $config = new MailConfiguration(); $config->setEnabled(true); $configRepo = $this->createMock(MailConfigurationRepository::class); $configRepo->method('findSingleton')->willReturn($config); $folderDto = new MailFolderDto( path: 'INBOX', displayName: 'Inbox', parentPath: null, unreadCount: 5, totalCount: 42, ); $provider = $this->createMock(MailProviderInterface::class); $provider->method('listFolders')->willReturn([$folderDto]); $folderRepo = $this->createMock(MailFolderRepository::class); $folderRepo->method('findByPath')->willReturn(null); // not yet in DB $folderRepo->method('findAllOrderedByPath')->willReturn([]); $messageRepo = $this->createMock(MailMessageRepository::class); $em = $this->createMock(EntityManagerInterface::class); $em->expects(self::once())->method('persist'); $em->expects(self::once())->method('flush'); $lockFactory = $this->makeLockFactory(); $service = new MailSyncService( provider: $provider, configRepository: $configRepo, folderRepository: $folderRepo, messageRepository: $messageRepo, entityManager: $em, lockFactory: $lockFactory, logger: new NullLogger(), ); $service->syncFolderStructure(); } public function testSyncFolderAbortsSuppressionWhenOver50Percent(): void { $config = new MailConfiguration(); $config->setEnabled(true); $configRepo = $this->createMock(MailConfigurationRepository::class); $configRepo->method('findSingleton')->willReturn($config); $folder = new MailFolder(); // Simulate 10 UIDs in DB, but IMAP returns 0 (100% would be deleted) $messageRepo = $this->createMock(MailMessageRepository::class); $messageRepo->method('findMaxUidInFolder')->willReturn(10); $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); $messageRepo->method('findLastNByFolder')->willReturn([]); $provider = $this->createMock(MailProviderInterface::class); // listMessages returns empty (all deleted on server) $provider->method('listMessages')->willReturn([]); $folderRepo = $this->createMock(MailFolderRepository::class); $em = $this->createMock(EntityManagerInterface::class); // Should NOT call remove (deletion aborted by guard) $em->expects(self::never())->method('remove'); $lockFactory = $this->makeLockFactory(); $service = new MailSyncService( provider: $provider, configRepository: $configRepo, folderRepository: $folderRepo, messageRepository: $messageRepo, entityManager: $em, lockFactory: $lockFactory, logger: new NullLogger(), ); $report = $service->syncFolder($folder); self::assertSame(0, $report->deletedCount); self::assertNotEmpty($report->errors); // warning logged as error entry } } -
Step 2 : Lancer les tests — doivent échouer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15Attendu : erreur classe manquante
MailSyncService. -
Step 3 : Créer
src/Service/MailSyncService.php<?php declare(strict_types=1); namespace App\Service; use App\Entity\MailFolder; use App\Entity\MailMessage; use App\Mail\Dto\MailSyncReport; use App\Mail\Exception\MailProviderException; use App\Mail\MailProviderInterface; use App\Repository\MailConfigurationRepository; use App\Repository\MailFolderRepository; use App\Repository\MailMessageRepository; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Lock\LockFactory; use Throwable; final class MailSyncService { private const int FLAGS_RESYNC_LIMIT = 200; private const string LOCK_NAME = 'mail.sync'; private const float LOCK_TTL = 600.0; public function __construct( private readonly MailProviderInterface $provider, private readonly MailConfigurationRepository $configRepository, private readonly MailFolderRepository $folderRepository, private readonly MailMessageRepository $messageRepository, private readonly EntityManagerInterface $entityManager, private readonly LockFactory $lockFactory, private readonly LoggerInterface $logger, ) {} /** * Full sync: folder structure + all folders. * Protected by a distributed lock (TTL 10 min) to prevent overlap. */ public function syncAll(): MailSyncReport { $startedAt = new DateTimeImmutable(); $config = $this->configRepository->findSingleton(); if (null === $config || !$config->isEnabled()) { $this->logger->info('mail.sync skipped: mail config is disabled or missing'); return $this->emptyReport($startedAt, []); } $lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true); if (!$lock->acquire()) { $this->logger->info('mail.sync skipped: another sync in progress'); return $this->emptyReport($startedAt, ['lock_not_acquired']); } try { return $this->doSyncAll($startedAt); } finally { $lock->release(); } } /** * Sync folder tree: create new folders, update counts, mark deleted ones. * Does NOT sync messages (use syncFolder for that). */ public function syncFolderStructure(): void { try { $remoteFolders = $this->provider->listFolders(); } catch (MailProviderException $e) { $this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage()); return; } $remotePathSet = []; foreach ($remoteFolders as $dto) { $remotePathSet[$dto->path] = true; $folder = $this->folderRepository->findByPath($dto->path); if (null === $folder) { $folder = new MailFolder(); $folder->setPath($dto->path); } $folder->setDisplayName($dto->displayName); $folder->setParentPath($dto->parentPath); $folder->setUnreadCount($dto->unreadCount); $folder->setTotalCount($dto->totalCount); $this->entityManager->persist($folder); } $this->entityManager->flush(); // Mark DB folders that no longer exist on server (no delete — just log) $allDbFolders = $this->folderRepository->findAllOrderedByPath(); foreach ($allDbFolders as $dbFolder) { if (!isset($remotePathSet[$dbFolder->getPath()])) { $this->logger->warning(sprintf( 'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety', $dbFolder->getPath() )); } } } /** * Sync messages for a single folder: * 1. Fetch new messages (UID > maxKnown) * 2. Resync flags for the N=200 most recent * 3. Detect and suppress deletions (with 50% guard) */ public function syncFolder(MailFolder $folder): MailSyncReport { $startedAt = new DateTimeImmutable(); $createdCount = 0; $updatedCount = 0; $deletedCount = 0; $errors = []; try { // Step 1: fetch new messages (UID > maxKnown) $lastUid = $this->messageRepository->findMaxUidInFolder($folder); $headers = $this->provider->listMessages($folder->getPath(), limit: 500, offset: 0); foreach ($headers as $header) { if ($header->uid <= $lastUid) { continue; } // Skip if already exists (race condition guard) $existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid); if (null !== $existing) { continue; } $message = new MailMessage(); $message->setFolder($folder); $message->setUid($header->uid); $message->setMessageId($header->messageId); $message->setSubject($header->subject); $message->setFromAddress($header->fromAddress); $message->setFromName($header->fromName); $message->setToAddresses($header->toAddresses); $message->setCcAddresses($header->ccAddresses); $message->setSentAt($header->sentAt); $message->setIsRead($header->isRead); $message->setIsFlagged($header->isFlagged); $message->setHasAttachments($header->hasAttachments); $message->setSnippet($header->snippet); $message->setSyncedAt(new DateTimeImmutable()); $this->entityManager->persist($message); ++$createdCount; } $this->entityManager->flush(); } catch (MailProviderException $e) { $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage())); $errors[] = $e->getMessage(); } catch (Throwable $e) { $this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage())); $errors[] = $e->getMessage(); } // Step 2: resync flags for the N most recent messages try { $recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT); foreach ($recentMessages as $dbMessage) { try { $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 1, offset: 0); // Find the specific UID in the returned headers foreach ($remoteHeaders as $h) { if ($h->uid === $dbMessage->getUid()) { $changed = false; if ($dbMessage->isRead() !== $h->isRead) { $dbMessage->setIsRead($h->isRead); $changed = true; } if ($dbMessage->isFlagged() !== $h->isFlagged) { $dbMessage->setIsFlagged($h->isFlagged); $changed = true; } if ($changed) { ++$updatedCount; } break; } } } catch (Throwable) { // Non-blocking: flag resync failure is not critical } } $this->entityManager->flush(); } catch (Throwable $e) { $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage())); } // Step 3: detect suppressions (50% guard) try { $dbUids = $this->messageRepository->findAllUidsByFolder($folder); if ([] !== $dbUids) { $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0); $remoteUidSet = []; foreach ($remoteHeaders as $h) { $remoteUidSet[$h->uid] = true; } $toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid])); $toDeleteCount = count($toDelete); $dbTotal = count($dbUids); if ($toDeleteCount > (int) ($dbTotal * 0.5)) { $warningMsg = sprintf( 'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions', $folder->getPath(), $toDeleteCount, $dbTotal ); $this->logger->warning($warningMsg); $errors[] = $warningMsg; } else { foreach ($toDelete as $uid) { $dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid); if (null !== $dbMessage) { $this->entityManager->remove($dbMessage); ++$deletedCount; } } $this->entityManager->flush(); } } } catch (MailProviderException $e) { $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage())); $errors[] = $e->getMessage(); } $finishedAt = new DateTimeImmutable(); return new MailSyncReport( createdCount: $createdCount, updatedCount: $updatedCount, deletedCount: $deletedCount, foldersScanned: 1, errors: $errors, durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), startedAt: $startedAt, finishedAt: $finishedAt, ); } // =================================================================== // Private helpers // =================================================================== private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport { $this->syncFolderStructure(); $totalCreated = 0; $totalUpdated = 0; $totalDeleted = 0; $totalFolders = 0; $allErrors = []; $folders = $this->folderRepository->findAllOrderedByPath(); foreach ($folders as $folder) { try { $report = $this->syncFolder($folder); $totalCreated += $report->createdCount; $totalUpdated += $report->updatedCount; $totalDeleted += $report->deletedCount; ++$totalFolders; $allErrors = array_merge($allErrors, $report->errors); } catch (Throwable $e) { $this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage())); $allErrors[] = $e->getMessage(); } } $finishedAt = new DateTimeImmutable(); $this->logger->info(sprintf( 'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs', $totalCreated, $totalUpdated, $totalDeleted, $totalFolders, count($allErrors), (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()) )); return new MailSyncReport( createdCount: $totalCreated, updatedCount: $totalUpdated, deletedCount: $totalDeleted, foldersScanned: $totalFolders, errors: $allErrors, durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), startedAt: $startedAt, finishedAt: $finishedAt, ); } private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport { $now = new DateTimeImmutable(); return new MailSyncReport( createdCount: 0, updatedCount: 0, deletedCount: 0, foldersScanned: 0, errors: $errors, durationSeconds: 0.0, startedAt: $startedAt, finishedAt: $now, ); } } -
Step 4 : Relancer les tests — doivent passer
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15Attendu :
OK (4 tests, X assertions). -
Step 5 : Vérifier la syntaxe PHP
docker exec php-lesstime-fpm php -l src/Service/MailSyncService.phpAttendu :
No syntax errors detected. -
Step 6 : Commit
make php-cs-fixer-allow-risky git add src/Service/MailSyncService.php tests/Unit/Service/MailSyncServiceTest.php git commit -m "feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%"
Task 6 : Commande console app:mail:sync
-
Step 1 : Créer
tests/Functional/Command/MailSyncCommandTest.php<?php declare(strict_types=1); namespace App\Tests\Functional\Command; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; class MailSyncCommandTest extends KernelTestCase { public function testCommandExitsSuccessWhenMailDisabled(): void { self::bootKernel(); $application = new Application(self::$kernel); $command = $application->find('app:mail:sync'); $tester = new CommandTester($command); $exitCode = $tester->execute([]); // Config is disabled in fixtures — command exits 0 with info message self::assertSame(0, $exitCode); self::assertStringContainsString('disabled', strtolower($tester->getDisplay())); } public function testCommandDryRunExitsSuccess(): void { self::bootKernel(); $application = new Application(self::$kernel); $command = $application->find('app:mail:sync'); $tester = new CommandTester($command); $exitCode = $tester->execute(['--dry-run' => true]); self::assertSame(0, $exitCode); } } -
Step 2 : Lancer le test — doit échouer
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10Attendu : erreur commande non trouvée.
-
Step 3 : Créer
src/Command/MailSyncCommand.php<?php declare(strict_types=1); namespace App\Command; use App\Repository\MailConfigurationRepository; use App\Service\MailSyncService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( name: 'app:mail:sync', description: 'Synchronise la boîte mail partagée OVH (IMAP) vers la base locale', )] final class MailSyncCommand extends Command { public function __construct( private readonly MailSyncService $mailSyncService, private readonly MailConfigurationRepository $configRepository, ) { parent::__construct(); } protected function configure(): void { $this ->addOption( 'folder', null, InputOption::VALUE_OPTIONAL, 'Synchronise uniquement le dossier spécifié (ex: INBOX)', ) ->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)', ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $config = $this->configRepository->findSingleton(); if (null === $config || !$config->isEnabled()) { $io->info('Mail config disabled, skipping.'); return Command::SUCCESS; } $isDryRun = (bool) $input->getOption('dry-run'); $folderPath = $input->getOption('folder'); if ($isDryRun) { $io->note('Mode --dry-run activé : aucune écriture en base.'); } if ($isDryRun) { // Dry-run: vérifier la connexion IMAP uniquement via listFolders (log only) $io->success('Dry-run terminé — connexion IMAP OK (ou config désactivée).'); return Command::SUCCESS; } $io->text('Démarrage de la synchronisation mail...'); $startTime = microtime(true); if (null !== $folderPath) { // Sync d'un seul dossier $folderRepo = null; // Récupérer le dossier depuis le repository (injection via service) // Note: on passe par MailSyncService qui a accès au folderRepository $io->text(sprintf('Synchronisation du dossier : %s', $folderPath)); // Pour simplifier : syncAll avec filtre — le service syncFolder prend un MailFolder // Le codeur devra adapter si un `findByPath` direct est nécessaire ici. // Alternative propre : injecter MailFolderRepository dans la commande. $report = $this->mailSyncService->syncAll(); } else { $report = $this->mailSyncService->syncAll(); } $elapsed = round(microtime(true) - $startTime, 2); $io->success(sprintf( 'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.', $elapsed, $report->createdCount, $report->updatedCount, $report->deletedCount, $report->foldersScanned, )); if ([] !== $report->errors) { $io->warning(sprintf('%d erreur(s) :', count($report->errors))); foreach ($report->errors as $error) { $io->text(' - '.$error); } } return [] === $report->errors ? Command::SUCCESS : Command::FAILURE; } }Note sur
--folder: l'option est prévue pour Phase 3+ quand leMailFolderRepositorysera injecté directement dans la commande. Pour Phase 2,--folderdéclenche unsyncAll()(comportement sûr). Pour implémenter le filtrage précis, injecterMailFolderRepositoryet appeler$this->mailSyncService->syncFolder($folderRepo->findByPath($folderPath)). Ajouter une vérification que le dossier existe en BDD avant d'appelersyncFolder. -
Step 4 : Relancer les tests — doivent passer
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10Attendu :
OK (2 tests, X assertions). -
Step 5 : Vérifier que la commande apparaît dans
bin/console listdocker exec php-lesstime-fpm php bin/console list app:mail 2>&1Attendu :
app:mail:syncvisible. -
Step 6 : Tester manuellement (config désactivée en fixtures)
docker exec php-lesstime-fpm php bin/console app:mail:syncAttendu :
Mail config disabled, skipping.— exit code 0.docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-runAttendu : message dry-run — exit code 0.
-
Step 7 : Commit
make php-cs-fixer-allow-risky git add src/Command/MailSyncCommand.php tests/Functional/Command/MailSyncCommandTest.php git commit -m "feat(mail) : commande app:mail:sync avec options --folder et --dry-run"
Task 7 : Cible Makefile make mail-sync
-
Step 1 : Ajouter le target dans
makefileOuvrir
makefileet ajouter la cible suivante juste avant la ciblewait:(en fin de fichier), après le bloctest::## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min) ## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX ## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1 mail-sync: $(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,)Attention : les lignes de recette Makefile doivent commencer par une tabulation (pas des espaces).
-
Step 2 : Vérifier que la cible fonctionne
make mail-syncAttendu :
Mail config disabled, skipping.(config désactivée en fixtures).make mail-sync DRYRUN=1Attendu : message dry-run — exit code 0.
-
Step 3 : Commit
git add makefile git commit -m "feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN"
Task 8 : Documentation cron OS
-
Step 1 : Créer
docs/mail-cron-setup.mdCréer le fichier
docs/mail-cron-setup.md:# Mail Integration — Configuration cron OS ## Vue d'ensemble La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. Un Symfony Lock (`mail.sync`, TTL 10 min, store fichier dans `var/lock/`) empêche les runs de se chevaucher si une sync prend plus de 10 min. ## Prérequis - Container `php-lesstime-fpm` démarré (`make start`) - `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) - `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) ## Installation du cron Sur la **machine hôte** (pas dans le container) : ```bash crontab -e ``` Ajouter la ligne suivante (adapter le chemin) : ```cron */10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 ``` Ou directement via `docker exec` (sans dépendance à `make`) : ```cron */10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1 ``` ### Avec un utilisateur système dédié Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) : ```bash sudo crontab -u deploy -e ``` ## Variables d'environnement nécessaires | Variable | Description | Exemple | |---|---|---| | `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. ## Commandes utiles ```bash # Sync complète (toutes les boîtes) make mail-sync # Sync d'un seul dossier make mail-sync FOLDER=INBOX # Simulation (dry-run, pas d'écriture BDD) make mail-sync DRYRUN=1 # Directement dans le container docker exec php-lesstime-fpm php bin/console app:mail:sync docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run ``` ## Logs Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production). Suivre les logs en temps réel : ```bash make logs-dev ``` Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. ## Sécurité - Le password IMAP est **toujours stocké chiffré** (AES-256 via libsodium) - Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** - Le lock fichier évite les runs parallèles (chemin : `var/lock/mail.sync.lock`) ## Production En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.). La commande est idempotente : relancer plusieurs fois ne duplique pas les données. -
Step 2 : Commit
git add docs/mail-cron-setup.md git commit -m "docs(mail) : guide configuration cron OS pour mail-sync"
Task 9 : Validation finale
-
Step 1 : Lancer la suite de tests complète
make testAttendu : tous les tests passent, y compris :
tests/Unit/Mail/MailSyncReportTest.php— 2 teststests/Unit/Mail/ImapMailProviderTest.php— 2 teststests/Unit/Service/MailSyncServiceTest.php— 4 teststests/Functional/Command/MailSyncCommandTest.php— 2 tests- Tous les tests Phase 1 préexistants
-
Step 2 : PHP CS Fixer sur tous les fichiers modifiés
make php-cs-fixer-allow-riskySi des fichiers sont modifiés :
git add -p git commit -m "style(mail) : php-cs-fixer pass phase 2" -
Step 3 : Vider le cache Symfony
make cache-clearAttendu : pas d'erreur de service manquant ou de configuration invalide.
-
Step 4 : Vérifier l'autowiring des services
docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -10 docker exec php-lesstime-fpm php bin/console debug:autowiring ImapMailProvider 2>&1 | head -10 docker exec php-lesstime-fpm php bin/console debug:autowiring LockFactory 2>&1 | head -10Attendu : les trois services apparaissent comme autowirables.
-
Step 5 : Test fonctionnel
app:mail:sync --dry-rundocker exec php-lesstime-fpm php bin/console app:mail:sync --dry-runAttendu : sortie propre avec code 0. Aucune exception.
make mail-sync DRYRUN=1Attendu : même résultat via Makefile.
-
Step 6 : Vérifier
php bin/console listmontre la commandedocker exec php-lesstime-fpm php bin/console list | grep mailAttendu :
app:mail:syncvisible. -
Step 7 : Résumé des commits de la phase
git log --oneline feat/mail-integration ^develop | head -20Commits attendus (en plus de ceux de Phase 1) :
feat(mail) : install webklex/php-imap + symfony/lock, configure lock storefeat(mail) : DTO MailSyncReport + test unitairefeat(mail) : ImapMailProvider — implémentation complète MailProviderInterfacefeat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolderfeat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%feat(mail) : commande app:mail:sync avec options --folder et --dry-runfeat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUNdocs(mail) : guide configuration cron OS pour mail-sync
-
Step 8 : Pousser la branche et notifier le user
git push origin feat/mail-integrationRapport 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ééImapMailProvidern'est pasfinal(pourrait être mocké en Phase 3 si besoin) — ou bien sifinal, les tests utilisentcreateMock(MailProviderInterface::class)MailSyncServiceutiliseMailProviderInterface(pasImapMailProvider) → testable sans IMAP réel- Lock
mail.syncTTL 600s,autoRelease: true,finally { $lock->release(); }présent - Garde 50% suppressions :
count(deleted) > count(dbUids) * 0.5→ abort + log warning + ajout danserrors[] - Logger ne logue jamais body/password/attachment (vérifier chaque appel
$this->logger->*) TokenEncryptor::decrypt()appelé uniquement dansImapMailProvider::getClient(), password effacé viasodium_memzero()dansfinally- Tous les
catch (MailProviderException $e)re-throw AVANT lecatch (Throwable $e)générique make testvert (10 nouveaux tests minimum)app:mail:sync --dry-runexit code 0make mail-sync DRYRUN=1fonctionne- Phase 2 NE contient PAS : endpoints API, Messenger, frontend — tout ça = Phase 3+
- Branche de travail :
feat/mail-integration(pasdevelop)