1782 lines
60 KiB
Markdown
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`)
|