feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%
This commit is contained in:
325
src/Service/MailSyncService.php
Normal file
325
src/Service/MailSyncService.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?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,
|
||||
) {}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
182
tests/Unit/Service/MailSyncServiceTest.php
Normal file
182
tests/Unit/Service/MailSyncServiceTest.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?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\MailProviderInterface;
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Service\MailSyncService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\SharedLockInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MailSyncServiceTest extends TestCase
|
||||
{
|
||||
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);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user