diff --git a/src/Mail/ImapMailProvider.php b/src/Mail/ImapMailProvider.php new file mode 100644 index 0000000..1354d1f --- /dev/null +++ b/src/Mail/ImapMailProvider.php @@ -0,0 +1,357 @@ +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, + ); + } +} diff --git a/tests/Unit/Mail/ImapMailProviderTest.php b/tests/Unit/Mail/ImapMailProviderTest.php new file mode 100644 index 0000000..3720d35 --- /dev/null +++ b/tests/Unit/Mail/ImapMailProviderTest.php @@ -0,0 +1,49 @@ +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))); + } +}