Files
Lesstime/src/Service/MailSyncService.php
matthieu c75dfa0371 fix(mail) : synchro multi-dossiers fiable contre OVH
Trois causes racines révélées par une vraie synchro complète (139 dossiers) :
- contrainte UNIQUE globale sur message_id : fausse pour IMAP (un même Message-ID
  existe dans plusieurs dossiers) → violation → fermeture de l'EntityManager →
  cascade qui tuait tous les dossiers suivants. Migration : index simple à la place.
- 139 connexions IMAP (une par dossier) → throttling OVH (failed to authenticate) :
  réutilisation d'une seule connexion (closeConnection() ajouté à l'interface).
- état de connexion corrompu après un dossier en erreur (must be in SELECTED state) :
  reconnexion ciblée après chaque dossier en échec.
- garde anti-cascade : reset du ManagerRegistry + arrêt propre si l'EM se ferme.

Résultat : 456 messages sur 57 dossiers (avant : 188/30 puis crash). Les rares
dossiers à encodage spécial sont skippés proprement et réessayés au cycle suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:21:02 +02:00

348 lines
13 KiB
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 Doctrine\Persistence\ManagerRegistry;
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,
private readonly ManagerRegistry $managerRegistry,
) {}
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 {
$this->provider->closeConnection();
$lock->release();
}
}
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();
$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()
));
}
}
}
public function syncFolder(MailFolder $folder): MailSyncReport
{
$startedAt = new DateTimeImmutable();
$createdCount = 0;
$updatedCount = 0;
$deletedCount = 0;
$errors = [];
$remoteHeaders = null;
try {
$lastUid = $this->messageRepository->findMaxUidInFolder($folder);
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
foreach ($remoteHeaders as $header) {
if ($header->uid <= $lastUid) {
continue;
}
$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();
}
try {
$recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT);
if (null !== $recentMessages && [] !== $recentMessages) {
$remoteByUid = [];
if (null !== $remoteHeaders) {
foreach ($remoteHeaders as $h) {
$remoteByUid[$h->uid] = $h;
}
}
foreach ($recentMessages as $dbMessage) {
$remote = $remoteByUid[$dbMessage->getUid()] ?? null;
if (null === $remote) {
continue;
}
$changed = false;
if ($dbMessage->isRead() !== $remote->isRead) {
$dbMessage->setIsRead($remote->isRead);
$changed = true;
}
if ($dbMessage->isFlagged() !== $remote->isFlagged) {
$dbMessage->setIsFlagged($remote->isFlagged);
$changed = true;
}
if ($changed) {
++$updatedCount;
}
}
$this->entityManager->flush();
}
} catch (Throwable $e) {
$this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage()));
}
try {
$dbUids = $this->messageRepository->findAllUidsByFolder($folder);
if ([] !== $dbUids) {
if (null === $remoteHeaders) {
$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 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);
// A folder error can leave the reused IMAP connection in a bad
// selection state ("must be in SELECTED state", "empty response").
// Drop it so the next folder reconnects on a clean session.
if ([] !== $report->errors) {
$this->provider->closeConnection();
}
} catch (Throwable $e) {
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
$allErrors[] = $e->getMessage();
$this->provider->closeConnection();
}
// A failed flush closes the Doctrine EntityManager; without a reset
// every subsequent folder would fail with "EntityManager is closed".
// Reset it via the registry and stop the run cleanly — the next cron
// cycle resumes incrementally from where we left off.
if (!$this->entityManager->isOpen()) {
$this->logger->error('doSyncAll: EntityManager was closed mid-sync, resetting and aborting this run');
$this->managerRegistry->resetManager();
$allErrors[] = 'EntityManager closed mid-sync — run aborted, will resume next cycle';
break;
}
}
$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,
);
}
/**
* @param list<string> $errors
*/
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,
);
}
}