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 $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, ); } }