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:
2026-05-20 08:21:02 +02:00
parent c6fa5a534e
commit c75dfa0371
6 changed files with 91 additions and 17 deletions

View File

@@ -21,19 +21,37 @@ use Webklex\PHPIMAP\IMAP;
final class ImapMailProvider implements MailProviderInterface
{
private ?Client $client = null;
public function __construct(
private readonly MailConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
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
{
$client = $this->getClient(requireEnabled: false);
try {
$folders = $client->getFolders(false);
$client->disconnect();
return count($folders);
} catch (Throwable $e) {
@@ -69,8 +87,6 @@ final class ImapMailProvider implements MailProviderInterface
);
}
$client->disconnect();
return $result;
} catch (MailProviderException $e) {
throw $e;
@@ -106,8 +122,6 @@ final class ImapMailProvider implements MailProviderInterface
$result[] = $this->buildHeaderDto($message, withSnippet: false);
}
$client->disconnect();
return $result;
} catch (MailProviderException $e) {
throw $e;
@@ -148,8 +162,6 @@ final class ImapMailProvider implements MailProviderInterface
);
}
$client->disconnect();
return new MailMessageDetailDto(
header: $header,
bodyHtml: $bodyHtml,
@@ -186,8 +198,6 @@ final class ImapMailProvider implements MailProviderInterface
} else {
$message->unsetFlag('Seen');
}
$client->disconnect();
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
@@ -218,8 +228,6 @@ final class ImapMailProvider implements MailProviderInterface
} else {
$message->unsetFlag('Flagged');
}
$client->disconnect();
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
@@ -246,7 +254,6 @@ final class ImapMailProvider implements MailProviderInterface
}
$message->moveToFolder($targetFolder);
$client->disconnect();
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
@@ -274,14 +281,10 @@ final class ImapMailProvider implements MailProviderInterface
foreach ($message->getAttachments() as $att) {
if ((string) ($att->part_number ?? '1') === $partNumber) {
$client->disconnect();
return (string) $att->getContent();
}
}
$client->disconnect();
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
} catch (MailProviderException $e) {
throw $e;
@@ -294,6 +297,10 @@ final class ImapMailProvider implements MailProviderInterface
private function getClient(bool $requireEnabled = true): Client
{
if (null !== $this->client && $this->client->isConnected()) {
return $this->client;
}
$config = $this->configRepository->findSingleton();
if (null === $config) {
@@ -335,6 +342,8 @@ final class ImapMailProvider implements MailProviderInterface
}
}
$this->client = $client;
return $client;
}