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>
This commit is contained in:
31
migrations/Version20260520061736.php
Normal file
31
migrations/Version20260520061736.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260520061736 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'mail_message.message_id: drop global UNIQUE (un même Message-ID existe dans plusieurs dossiers IMAP), conserver un index simple';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX uniq_6c00b110537a1329');
|
||||||
|
$this->addSql('CREATE INDEX idx_mail_message_message_id ON mail_message (message_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX idx_mail_message_message_id');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_6c00b110537a1329 ON mail_message (message_id)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
#[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])]
|
#[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])]
|
||||||
#[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')]
|
#[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')]
|
||||||
#[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')]
|
#[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')]
|
||||||
|
#[ORM\Index(columns: ['message_id'], name: 'idx_mail_message_message_id')]
|
||||||
class MailMessage
|
class MailMessage
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
@@ -20,7 +21,7 @@ class MailMessage
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 500, unique: true)]
|
#[ORM\Column(length: 500)]
|
||||||
private string $messageId;
|
private string $messageId;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: MailFolder::class)]
|
#[ORM\ManyToOne(targetEntity: MailFolder::class)]
|
||||||
|
|||||||
@@ -21,19 +21,37 @@ use Webklex\PHPIMAP\IMAP;
|
|||||||
|
|
||||||
final class ImapMailProvider implements MailProviderInterface
|
final class ImapMailProvider implements MailProviderInterface
|
||||||
{
|
{
|
||||||
|
private ?Client $client = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailConfigurationRepository $configRepository,
|
private readonly MailConfigurationRepository $configRepository,
|
||||||
private readonly TokenEncryptor $tokenEncryptor,
|
private readonly TokenEncryptor $tokenEncryptor,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the reused IMAP connection. Call once at the end of a batch
|
||||||
|
* synchronisation to release the socket; HTTP requests can ignore it
|
||||||
|
* (the connection dies with the process).
|
||||||
|
*/
|
||||||
|
public function closeConnection(): void
|
||||||
|
{
|
||||||
|
if (null !== $this->client && $this->client->isConnected()) {
|
||||||
|
try {
|
||||||
|
$this->client->disconnect();
|
||||||
|
} catch (Throwable) {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->client = null;
|
||||||
|
}
|
||||||
|
|
||||||
public function testConnection(): int
|
public function testConnection(): int
|
||||||
{
|
{
|
||||||
$client = $this->getClient(requireEnabled: false);
|
$client = $this->getClient(requireEnabled: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$folders = $client->getFolders(false);
|
$folders = $client->getFolders(false);
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
return count($folders);
|
return count($folders);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -69,8 +87,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
@@ -106,8 +122,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
$result[] = $this->buildHeaderDto($message, withSnippet: false);
|
$result[] = $this->buildHeaderDto($message, withSnippet: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
@@ -148,8 +162,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
return new MailMessageDetailDto(
|
return new MailMessageDetailDto(
|
||||||
header: $header,
|
header: $header,
|
||||||
bodyHtml: $bodyHtml,
|
bodyHtml: $bodyHtml,
|
||||||
@@ -186,8 +198,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
} else {
|
} else {
|
||||||
$message->unsetFlag('Seen');
|
$message->unsetFlag('Seen');
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -218,8 +228,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
} else {
|
} else {
|
||||||
$message->unsetFlag('Flagged');
|
$message->unsetFlag('Flagged');
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -246,7 +254,6 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$message->moveToFolder($targetFolder);
|
$message->moveToFolder($targetFolder);
|
||||||
$client->disconnect();
|
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -274,14 +281,10 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
|
|
||||||
foreach ($message->getAttachments() as $att) {
|
foreach ($message->getAttachments() as $att) {
|
||||||
if ((string) ($att->part_number ?? '1') === $partNumber) {
|
if ((string) ($att->part_number ?? '1') === $partNumber) {
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
return (string) $att->getContent();
|
return (string) $att->getContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$client->disconnect();
|
|
||||||
|
|
||||||
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
|
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
|
||||||
} catch (MailProviderException $e) {
|
} catch (MailProviderException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
@@ -294,6 +297,10 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
|
|
||||||
private function getClient(bool $requireEnabled = true): Client
|
private function getClient(bool $requireEnabled = true): Client
|
||||||
{
|
{
|
||||||
|
if (null !== $this->client && $this->client->isConnected()) {
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
$config = $this->configRepository->findSingleton();
|
$config = $this->configRepository->findSingleton();
|
||||||
|
|
||||||
if (null === $config) {
|
if (null === $config) {
|
||||||
@@ -335,6 +342,8 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->client = $client;
|
||||||
|
|
||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ interface MailProviderInterface
|
|||||||
*/
|
*/
|
||||||
public function testConnection(): int;
|
public function testConnection(): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases any reused network connection held by the provider.
|
||||||
|
* Safe to call multiple times; a no-op if nothing is open.
|
||||||
|
*/
|
||||||
|
public function closeConnection(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the full folder tree of the configured mailbox.
|
* Returns the full folder tree of the configured mailbox.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Repository\MailFolderRepository;
|
|||||||
use App\Repository\MailMessageRepository;
|
use App\Repository\MailMessageRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -32,6 +33,7 @@ final class MailSyncService
|
|||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly LockFactory $lockFactory,
|
private readonly LockFactory $lockFactory,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly ManagerRegistry $managerRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function syncAll(): MailSyncReport
|
public function syncAll(): MailSyncReport
|
||||||
@@ -57,6 +59,7 @@ final class MailSyncService
|
|||||||
try {
|
try {
|
||||||
return $this->doSyncAll($startedAt);
|
return $this->doSyncAll($startedAt);
|
||||||
} finally {
|
} finally {
|
||||||
|
$this->provider->closeConnection();
|
||||||
$lock->release();
|
$lock->release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,9 +277,28 @@ final class MailSyncService
|
|||||||
$totalDeleted += $report->deletedCount;
|
$totalDeleted += $report->deletedCount;
|
||||||
++$totalFolders;
|
++$totalFolders;
|
||||||
$allErrors = array_merge($allErrors, $report->errors);
|
$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) {
|
} catch (Throwable $e) {
|
||||||
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
|
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
|
||||||
$allErrors[] = $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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Repository\MailFolderRepository;
|
|||||||
use App\Repository\MailMessageRepository;
|
use App\Repository\MailMessageRepository;
|
||||||
use App\Service\MailSyncService;
|
use App\Service\MailSyncService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
@@ -45,6 +46,7 @@ class MailSyncServiceTest extends TestCase
|
|||||||
entityManager: $em,
|
entityManager: $em,
|
||||||
lockFactory: $lockFactory,
|
lockFactory: $lockFactory,
|
||||||
logger: new NullLogger(),
|
logger: new NullLogger(),
|
||||||
|
managerRegistry: $this->createMock(ManagerRegistry::class),
|
||||||
);
|
);
|
||||||
|
|
||||||
$report = $service->syncAll();
|
$report = $service->syncAll();
|
||||||
@@ -77,6 +79,7 @@ class MailSyncServiceTest extends TestCase
|
|||||||
entityManager: $em,
|
entityManager: $em,
|
||||||
lockFactory: $lockFactory,
|
lockFactory: $lockFactory,
|
||||||
logger: new NullLogger(),
|
logger: new NullLogger(),
|
||||||
|
managerRegistry: $this->createMock(ManagerRegistry::class),
|
||||||
);
|
);
|
||||||
|
|
||||||
$report = $service->syncAll();
|
$report = $service->syncAll();
|
||||||
@@ -123,6 +126,7 @@ class MailSyncServiceTest extends TestCase
|
|||||||
entityManager: $em,
|
entityManager: $em,
|
||||||
lockFactory: $lockFactory,
|
lockFactory: $lockFactory,
|
||||||
logger: new NullLogger(),
|
logger: new NullLogger(),
|
||||||
|
managerRegistry: $this->createMock(ManagerRegistry::class),
|
||||||
);
|
);
|
||||||
|
|
||||||
$service->syncFolderStructure();
|
$service->syncFolderStructure();
|
||||||
@@ -161,6 +165,7 @@ class MailSyncServiceTest extends TestCase
|
|||||||
entityManager: $em,
|
entityManager: $em,
|
||||||
lockFactory: $lockFactory,
|
lockFactory: $lockFactory,
|
||||||
logger: new NullLogger(),
|
logger: new NullLogger(),
|
||||||
|
managerRegistry: $this->createMock(ManagerRegistry::class),
|
||||||
);
|
);
|
||||||
|
|
||||||
$report = $service->syncFolder($folder);
|
$report = $service->syncFolder($folder);
|
||||||
|
|||||||
Reference in New Issue
Block a user