From c47434b5028344d11ec47f01ec561f5e46bbd062 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:37:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail)=20:=20MailSyncService=20=E2=80=94=20?= =?UTF-8?q?syncAll/syncFolder/syncFolderStructure=20+=20lock=20+=20garde?= =?UTF-8?q?=2050%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/MailSyncService.php | 325 +++++++++++++++++++++ tests/Unit/Service/MailSyncServiceTest.php | 182 ++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 src/Service/MailSyncService.php create mode 100644 tests/Unit/Service/MailSyncServiceTest.php diff --git a/src/Service/MailSyncService.php b/src/Service/MailSyncService.php new file mode 100644 index 0000000..af06b20 --- /dev/null +++ b/src/Service/MailSyncService.php @@ -0,0 +1,325 @@ +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(); + } + } + + 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); + } 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, + ); + } + + /** + * @param list $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, + ); + } +} diff --git a/tests/Unit/Service/MailSyncServiceTest.php b/tests/Unit/Service/MailSyncServiceTest.php new file mode 100644 index 0000000..7e4dd63 --- /dev/null +++ b/tests/Unit/Service/MailSyncServiceTest.php @@ -0,0 +1,182 @@ +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); + + $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); + $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(); + $folder->setPath('INBOX'); + + $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); + $provider->method('listMessages')->willReturn([]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $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); + } + + private function makeLockFactory(bool $acquired = true): LockFactory + { + $lock = $this->createMock(SharedLockInterface::class); + $lock->method('acquire')->willReturn($acquired); + + $factory = $this->createMock(LockFactory::class); + $factory->method('createLock')->willReturn($lock); + + return $factory; + } +}