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, ); } }