diff --git a/docs/mail-integration.md b/docs/mail-integration.md index babb7c4..0859ab7 100644 --- a/docs/mail-integration.md +++ b/docs/mail-integration.md @@ -28,6 +28,12 @@ > 1. **Double-encodage UTF7-IMAP** : `listFolders()` stocke `$folder->path` = nom **brut UTF7-IMAP** (webklex `Folder::$path`). `ImapMailProvider` appelait ensuite `$client->getFolder($path)` qui ré-encode UTF8→UTF7-IMAP (`Client::getFolderByPath`, `$utf7=false`) → le `&` (shift UTF7) est ré-encodé → dossiers à accents/specials introuvables. **Fix** : `getFolder($path, null, utf7: true)` partout dans `ImapMailProvider` (les paths sont déjà UTF7-IMAP). Résout les ~7 dossiers à encodage spécial qui étaient « skippés ». > 2. **Dossiers fantômes jamais purgés** : `syncFolderStructure()` gardait en DB les dossiers disparus du serveur (Trash vidé, dossiers RH supprimés) → re-tentés à chaque cycle → `listMessages` → "not found" → log error en boucle. **Fix** : `syncFolderStructure()` retourne le set des chemins **présents sur le serveur** ; `doSyncAll()` skip silencieusement les dossiers DB absents de ce set (gardés en DB pour les liens messages/tâches, mais plus synchronisés). Si `listFolders` échoue (retour `null`), fallback = sync de tous les dossiers connus (comportement historique). > +> ### Bugs corrigés 2026-06-30 (spam GlitchTip `AUTHENTICATIONFAILED` + double-log) +> 4 events GlitchTip pour **un seul** échec de dossier (`INBOX/RH/LUCILE NEAU`, release 0.4.54). Root cause **différente** du fix du 29/06 (le « Folder not found » est bien éteint). Deux amplificateurs dans `MailSyncService::syncFolder` : +> 1. **Re-fetch après échec** : quand le `listMessages` initial échoue (`empty response`), le bloc de détection de suppression **rappelait `listMessages`** (`$remoteHeaders === null`). Ce 2ᵉ appel forçait une **reconnexion IMAP** que OVH refusait (`AUTHENTICATIONFAILED`, throttling) → 2 events parasites. **Fix** : le bloc deletion est gardé par `if (null !== $remoteHeaders)` — si le fetch a échoué, on saute la détection de suppression (impossible de differ sans liste distante fiable ; reprise au cycle suivant). Les credentials sont valides — l'auth-fail venait de la reconnexion, pas du mot de passe. +> 2. **Double-log error** : provider (`ImapMailProvider::listMessages`, error) **et** service (`syncFolder[...] listMessages failed`, error) logguaient la même `MailProviderException` → 2 issues GlitchTip. **Fix** : le log service `MailProviderException` passe en `warning` (le provider reste la source unique au niveau `error` pour GlitchTip, ce qui couvre aussi les chemins HTTP où les controllers catchent sans logger). Net : 1 event GlitchTip par échec de dossier. +> Test de régression : `MailSyncServiceTest::testSyncFolderDoesNotRefetchMessagesWhenInitialFetchFails` (assert `listMessages` appelé **une** seule fois). +> > ### Points en suspens / à savoir > - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main. > - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker. diff --git a/src/Module/Mail/Application/Service/MailSyncService.php b/src/Module/Mail/Application/Service/MailSyncService.php index e0ebfc4..a0b1187 100644 --- a/src/Module/Mail/Application/Service/MailSyncService.php +++ b/src/Module/Mail/Application/Service/MailSyncService.php @@ -151,7 +151,10 @@ final class MailSyncService $this->entityManager->flush(); } catch (MailProviderException $e) { - $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage())); + // The provider already logged this at error level (single GlitchTip + // issue covering both HTTP and sync paths). Log at warning here to keep + // the folder context in the file logs without raising a duplicate issue. + $this->logger->warning(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())); @@ -198,48 +201,52 @@ final class MailSyncService $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage())); } - try { - $dbUids = $this->messageRepository->findAllUidsByFolder($folder); + // Deletion detection needs a reliable remote message list. If the fetch + // above failed ($remoteHeaders === null), skip it: re-fetching here would + // only force an IMAP reconnect that trips OVH throttling + // (AUTHENTICATIONFAILED) and turns one folder failure into a cascade of + // GlitchTip errors. Deletions resume on the next cycle once the fetch + // succeeds. + if (null !== $remoteHeaders) { + 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; - } + if ([] !== $dbUids) { + $remoteUidSet = []; + foreach ($remoteHeaders as $h) { + $remoteUidSet[$h->uid] = true; } - $this->entityManager->flush(); + $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(); } - } catch (MailProviderException $e) { - $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage())); - $errors[] = $e->getMessage(); } $finishedAt = new DateTimeImmutable(); diff --git a/tests/Unit/Service/MailSyncServiceTest.php b/tests/Unit/Service/MailSyncServiceTest.php index 42c128a..40a62af 100644 --- a/tests/Unit/Service/MailSyncServiceTest.php +++ b/tests/Unit/Service/MailSyncServiceTest.php @@ -8,6 +8,7 @@ use App\Module\Mail\Application\Dto\MailFolderDto; use App\Module\Mail\Application\Service\MailSyncService; use App\Module\Mail\Domain\Entity\MailConfiguration; use App\Module\Mail\Domain\Entity\MailFolder; +use App\Module\Mail\Domain\Exception\MailProviderException; use App\Module\Mail\Domain\Provider\MailProviderInterface; use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface; use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface; @@ -237,6 +238,58 @@ class MailSyncServiceTest extends TestCase self::assertNotEmpty($report->errors); } + public function testSyncFolderDoesNotRefetchMessagesWhenInitialFetchFails(): void + { + // Regression: when the message fetch fails, the deletion-detection block + // used to re-call listMessages, which forced an IMAP reconnect and tripped + // OVH throttling (AUTHENTICATIONFAILED) — turning one folder failure into + // several GlitchTip events. listMessages must be called exactly once. + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepositoryInterface::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folder = new MailFolder(); + $folder->setPath('INBOX/RH/LUCILE NEAU'); + + $messageRepo = $this->createMock(MailMessageRepositoryInterface::class); + $messageRepo->method('findMaxUidInFolder')->willReturn(0); + $messageRepo->method('findLastNByFolder')->willReturn([]); + // The DB still holds messages: without the guard the deletion block would + // re-fetch the remote list to diff against these UIDs. + $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3]); + + $provider = $this->createMock(MailProviderInterface::class); + $provider->expects(self::once()) + ->method('listMessages') + ->willThrowException( + MailProviderException::operationFailed('listMessages', 'empty response') + ) + ; + + $folderRepo = $this->createMock(MailFolderRepositoryInterface::class); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::never())->method('remove'); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $this->makeLockFactory(), + logger: new NullLogger(), + managerRegistry: $this->createMock(ManagerRegistry::class), + ); + + $report = $service->syncFolder($folder); + + // Exactly one error recorded (the fetch failure), not a cascade. + self::assertCount(1, $report->errors); + self::assertSame(0, $report->deletedCount); + } + private function makeLockFactory(bool $acquired = true): LockFactory { $lock = $this->createMock(SharedLockInterface::class);