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>
404 lines
14 KiB
PHP
404 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mail;
|
|
|
|
use App\Mail\Dto\MailAttachmentDto;
|
|
use App\Mail\Dto\MailFolderDto;
|
|
use App\Mail\Dto\MailMessageDetailDto;
|
|
use App\Mail\Dto\MailMessageHeaderDto;
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Repository\MailConfigurationRepository;
|
|
use App\Service\TokenEncryptor;
|
|
use DateTimeImmutable;
|
|
use Psr\Log\LoggerInterface;
|
|
use SodiumException;
|
|
use Throwable;
|
|
use Webklex\PHPIMAP\Client;
|
|
use Webklex\PHPIMAP\ClientManager;
|
|
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);
|
|
|
|
return count($folders);
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('ImapMailProvider::testConnection failed: '.$e->getMessage());
|
|
|
|
throw MailProviderException::connectionFailed($e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function listFolders(): array
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folders = $client->getFolders(false);
|
|
$result = [];
|
|
|
|
foreach ($folders as $folder) {
|
|
$path = $folder->path;
|
|
$parentPath = null;
|
|
$delimiter = $folder->delimiter ?? '.';
|
|
$lastDelim = strrpos($path, $delimiter);
|
|
if (false !== $lastDelim && $lastDelim > 0) {
|
|
$parentPath = substr($path, 0, $lastDelim);
|
|
}
|
|
|
|
$result[] = new MailFolderDto(
|
|
path: $path,
|
|
displayName: $folder->name,
|
|
parentPath: $parentPath,
|
|
unreadCount: (int) ($folder->status['unseen'] ?? 0),
|
|
totalCount: (int) ($folder->status['messages'] ?? 0),
|
|
);
|
|
}
|
|
|
|
return $result;
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage());
|
|
|
|
throw MailProviderException::operationFailed('listFolders', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function listMessages(string $folderPath, int $limit, int $offset): array
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('listMessages', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$messages = $folder->query()
|
|
->whereAll()
|
|
->setFetchBody(false)
|
|
->leaveUnread()
|
|
->setSequence(IMAP::ST_UID)
|
|
->get()
|
|
;
|
|
|
|
$result = [];
|
|
$items = array_slice($messages->toArray(), $offset, $limit);
|
|
|
|
foreach ($items as $message) {
|
|
$result[] = $this->buildHeaderDto($message, withSnippet: false);
|
|
}
|
|
|
|
return $result;
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('listMessages', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('fetchMessage', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
|
|
|
if (null === $message) {
|
|
throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath));
|
|
}
|
|
|
|
$header = $this->buildHeaderDto($message);
|
|
$bodyHtml = $message->getHTMLBody(false) ?: null;
|
|
$bodyText = $message->getTextBody() ?: null;
|
|
$attachments = [];
|
|
|
|
foreach ($message->getAttachments() as $att) {
|
|
$attachments[] = new MailAttachmentDto(
|
|
partNumber: (string) ($att->part_number ?? '1'),
|
|
filename: $att->getName() ?? 'attachment',
|
|
mimeType: $att->getMimeType() ?? 'application/octet-stream',
|
|
size: $att->getSize() ?? 0,
|
|
);
|
|
}
|
|
|
|
return new MailMessageDetailDto(
|
|
header: $header,
|
|
bodyHtml: $bodyHtml,
|
|
bodyText: $bodyText,
|
|
attachments: $attachments,
|
|
);
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('fetchMessage', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function markRead(string $folderPath, int $uid, bool $read): void
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('markRead', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
|
|
|
if (null === $message) {
|
|
throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid));
|
|
}
|
|
|
|
if ($read) {
|
|
$message->setFlag('Seen');
|
|
} else {
|
|
$message->unsetFlag('Seen');
|
|
}
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('markRead', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function markFlagged(string $folderPath, int $uid, bool $flagged): void
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('markFlagged', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
|
|
|
if (null === $message) {
|
|
throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid));
|
|
}
|
|
|
|
if ($flagged) {
|
|
$message->setFlag('Flagged');
|
|
} else {
|
|
$message->unsetFlag('Flagged');
|
|
}
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('markFlagged', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function moveMessage(string $folderPath, int $uid, string $targetFolder): void
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('moveMessage', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
|
|
|
if (null === $message) {
|
|
throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid));
|
|
}
|
|
|
|
$message->moveToFolder($targetFolder);
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('moveMessage', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string
|
|
{
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$folder = $client->getFolder($folderPath);
|
|
if (null === $folder) {
|
|
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Folder %s not found', $folderPath));
|
|
}
|
|
|
|
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
|
|
|
if (null === $message) {
|
|
throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid));
|
|
}
|
|
|
|
foreach ($message->getAttachments() as $att) {
|
|
if ((string) ($att->part_number ?? '1') === $partNumber) {
|
|
return (string) $att->getContent();
|
|
}
|
|
}
|
|
|
|
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
|
|
} catch (MailProviderException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage()));
|
|
|
|
throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function getClient(bool $requireEnabled = true): Client
|
|
{
|
|
if (null !== $this->client && $this->client->isConnected()) {
|
|
return $this->client;
|
|
}
|
|
|
|
$config = $this->configRepository->findSingleton();
|
|
|
|
if (null === $config) {
|
|
throw MailProviderException::connectionFailed('Mail configuration is missing');
|
|
}
|
|
|
|
if ($requireEnabled && !$config->isEnabled()) {
|
|
throw MailProviderException::connectionFailed('Mail configuration is disabled');
|
|
}
|
|
|
|
if (null === $config->getEncryptedPassword()) {
|
|
throw MailProviderException::connectionFailed('No password configured');
|
|
}
|
|
|
|
$password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword());
|
|
|
|
try {
|
|
$manager = new ClientManager();
|
|
$client = $manager->make([
|
|
'host' => $config->getImapHost(),
|
|
'port' => $config->getImapPort(),
|
|
'encryption' => $config->getImapEncryption(),
|
|
'validate_cert' => true,
|
|
'username' => $config->getUsername(),
|
|
'password' => $password,
|
|
'protocol' => 'imap',
|
|
]);
|
|
|
|
$client->connect();
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('IMAP connection failed: '.$e->getMessage());
|
|
|
|
throw MailProviderException::connectionFailed($e->getMessage());
|
|
} finally {
|
|
try {
|
|
sodium_memzero($password);
|
|
} catch (SodiumException) {
|
|
// ignore: interned strings can't be zeroed
|
|
}
|
|
}
|
|
|
|
$this->client = $client;
|
|
|
|
return $client;
|
|
}
|
|
|
|
private function buildHeaderDto(mixed $message, bool $withSnippet = true): MailMessageHeaderDto
|
|
{
|
|
$from = $message->getFrom()->first();
|
|
$fromAddress = null !== $from ? (string) $from->mail : '';
|
|
$fromName = null !== $from && null !== $from->personal ? (string) $from->personal : null;
|
|
|
|
$toAddresses = [];
|
|
foreach ($message->getTo() as $addr) {
|
|
$toAddresses[] = (string) $addr->mail;
|
|
}
|
|
|
|
$ccAddresses = null;
|
|
$cc = $message->getCc();
|
|
if (null !== $cc && $cc->count() > 0) {
|
|
$ccAddresses = [];
|
|
foreach ($cc as $addr) {
|
|
$ccAddresses[] = (string) $addr->mail;
|
|
}
|
|
}
|
|
|
|
$sentAt = new DateTimeImmutable();
|
|
$dateAttr = $message->getDate();
|
|
if (null !== $dateAttr) {
|
|
try {
|
|
$sentAt = DateTimeImmutable::createFromInterface($dateAttr->toDate());
|
|
} catch (Throwable) {
|
|
// keep default when the header date is missing or unparsable
|
|
}
|
|
}
|
|
|
|
$snippet = null;
|
|
if ($withSnippet) {
|
|
$text = $message->getTextBody();
|
|
if (null !== $text && '' !== $text) {
|
|
$snippet = mb_substr(strip_tags($text), 0, 200);
|
|
}
|
|
}
|
|
|
|
return new MailMessageHeaderDto(
|
|
uid: (int) $message->getUid(),
|
|
messageId: (string) $message->getMessageId(),
|
|
subject: '' !== (string) $message->getSubject() ? (string) $message->getSubject() : null,
|
|
fromAddress: $fromAddress,
|
|
fromName: $fromName,
|
|
toAddresses: $toAddresses,
|
|
ccAddresses: $ccAddresses,
|
|
sentAt: $sentAt,
|
|
isRead: $message->hasFlag('Seen'),
|
|
isFlagged: $message->hasFlag('Flagged'),
|
|
hasAttachments: $message->hasAttachments(),
|
|
snippet: $snippet,
|
|
);
|
|
}
|
|
}
|