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

1782 lines
60 KiB
Markdown

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