feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface
This commit is contained in:
357
src/Mail/ImapMailProvider.php
Normal file
357
src/Mail/ImapMailProvider.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?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;
|
||||
|
||||
final class ImapMailProvider implements MailProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
|
||||
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()->leaveUnread()->get();
|
||||
|
||||
$result = [];
|
||||
$items = array_slice($messages->toArray(), $offset, $limit);
|
||||
|
||||
foreach ($items as $message) {
|
||||
$result[] = $this->buildHeaderDto($message);
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
|
||||
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()->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,
|
||||
);
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
|
||||
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()->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
if ($read) {
|
||||
$message->setFlag('Seen');
|
||||
} else {
|
||||
$message->unsetFlag('Seen');
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
} 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()->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
if ($flagged) {
|
||||
$message->setFlag('Flagged');
|
||||
} else {
|
||||
$message->unsetFlag('Flagged');
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
} 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()->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
$message->moveToFolder($targetFolder);
|
||||
$client->disconnect();
|
||||
} 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()->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) {
|
||||
$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;
|
||||
} 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(): Client
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config || !$config->isEnabled()) {
|
||||
throw MailProviderException::connectionFailed('Mail configuration is missing or 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
|
||||
}
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function buildHeaderDto(mixed $message): MailMessageHeaderDto
|
||||
{
|
||||
$from = $message->getFrom()->first();
|
||||
$fromAddress = null !== $from ? (string) $from->mail : '';
|
||||
$fromName = null !== $from ? ($from->personal ?? null) : 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 = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable();
|
||||
|
||||
$snippet = null;
|
||||
$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: $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
tests/Unit/Mail/ImapMailProviderTest.php
Normal file
49
tests/Unit/Mail/ImapMailProviderTest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Mail;
|
||||
|
||||
use App\Entity\MailConfiguration;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\ImapMailProvider;
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Service\TokenEncryptor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ImapMailProviderTest extends TestCase
|
||||
{
|
||||
public function testThrowsWhenConfigDisabled(): void
|
||||
{
|
||||
$config = new MailConfiguration();
|
||||
$config->setEnabled(false);
|
||||
|
||||
$repo = $this->createMock(MailConfigurationRepository::class);
|
||||
$repo->method('findSingleton')->willReturn($config);
|
||||
|
||||
$provider = new ImapMailProvider($repo, $this->makeEncryptor(), new NullLogger());
|
||||
|
||||
$this->expectException(MailProviderException::class);
|
||||
$provider->listFolders();
|
||||
}
|
||||
|
||||
public function testThrowsWhenConfigMissing(): void
|
||||
{
|
||||
$repo = $this->createMock(MailConfigurationRepository::class);
|
||||
$repo->method('findSingleton')->willReturn(null);
|
||||
|
||||
$provider = new ImapMailProvider($repo, $this->makeEncryptor(), new NullLogger());
|
||||
|
||||
$this->expectException(MailProviderException::class);
|
||||
$provider->listFolders();
|
||||
}
|
||||
|
||||
private function makeEncryptor(): TokenEncryptor
|
||||
{
|
||||
return new TokenEncryptor(sodium_bin2hex(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user