Files
provider_imap/lib/Client/Protocol/ProtocolReader.php
2026-05-23 20:18:58 -04:00

184 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
use KTXM\ProviderImap\Client\Protocol\Response\ResponseInterface;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
use Psr\Log\LoggerInterface;
final class ProtocolReader
{
public function __construct(
private readonly ConnectionInterface $connection,
private readonly ?LoggerInterface $logger = null,
) {}
public function readGreeting(): GreetingResponse
{
$raw = $this->trimTrailingLineEnding($this->connection->readLine());
if (!str_starts_with($raw, '* ')) {
throw new ImapException(sprintf('Expected IMAP greeting, got: %s', $raw));
}
$parts = preg_split('/\s+/', substr($raw, 2), 2) ?: [];
$status = strtoupper($parts[0] ?? '');
$text = $parts[1] ?? '';
$this->logger?->debug('IMAP greeting received: {raw}', [
'status' => $status,
'raw' => $raw,
]);
return new GreetingResponse($status, $text, $raw);
}
public function readResponse(): ResponseInterface
{
$raw = $this->connection->readLine();
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
$raw .= $this->connection->readBytes($literalLength);
$raw .= $this->connection->readLine();
}
$raw = $this->trimTrailingLineEnding($raw);
if ($raw === '') {
throw new ImapException('Received empty IMAP response line.');
}
if (str_starts_with($raw, '* ')) {
$parts = preg_split('/\s+/', substr($raw, 2), 2) ?: [];
$label = strtoupper($parts[0] ?? '');
$this->logger?->debug('IMAP untagged response received: {raw}', [
'label' => $label,
'raw' => $raw,
]);
return new UntaggedResponse(
$label,
$parts[1] ?? '',
$raw,
);
}
if (str_starts_with($raw, '+')) {
$this->logger?->debug('IMAP continuation response received: {raw}', [
'raw' => $raw,
]);
return new ContinuationResponse(ltrim(substr($raw, 1)), $raw);
}
$parts = preg_split('/\s+/', $raw, 3) ?: [];
if (count($parts) < 2) {
throw new ImapException(sprintf('Malformed tagged IMAP response: %s', $raw));
}
$status = strtoupper($parts[1]);
$this->logger?->debug('IMAP tagged response received: {raw}', [
'tag' => $parts[0],
'status' => $status,
'raw' => $raw,
]);
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
}
/**
* Read responses until an untagged FETCH response containing a literal marker is found,
* returning the literal byte count WITHOUT consuming the literal bytes from the socket.
* Returns null if the tagged OK/NO/BAD for $tag arrives before any literal is detected.
*
* ⚠️ After a non-null return the literal bytes MUST be consumed (via streamLiteral())
* before any further reads are made on this reader.
*
* @return array{literalLength: int, prefixLine: string}|null
*/
public function readUntilFetchLiteral(string $tag): ?array
{
while (true) {
$line = $this->connection->readLine();
$trimmed = $this->trimTrailingLineEnding($line);
// Literal marker at the end of the line — stop before consuming the bytes
$literalLength = $this->trailingLiteralLength($line);
if ($literalLength !== null) {
return ['literalLength' => $literalLength, 'prefixLine' => $trimmed];
}
// Tagged completion for our command
if (str_starts_with($trimmed, $tag . ' ')) {
$parts = preg_split('/\s+/', $trimmed, 3) ?: [];
$status = strtoupper($parts[1] ?? '');
if ($status === 'NO' || $status === 'BAD') {
throw new ImapException(sprintf('FETCH command failed: %s', $trimmed));
}
return null; // Tagged OK without ever finding a literal → UID not found
}
// Any other untagged response — discard and continue
}
}
public function readToEnd(string $tag): TaggedResponse
{
while (true) {
$response = $this->readResponse();
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
if (!$response->isOk()) {
throw new ImapException(sprintf('FETCH failed: %s', $response->text()));
}
return $response;
}
}
}
/**
* Yield the literal bytes already waiting in the socket as chunks.
* After the generator is fully exhausted this method reads the one trailing
* line that closes the FETCH parenthesised response (e.g. ")\r\n").
*
* Contract: the caller MUST exhaust this generator before issuing any further
* reads on this reader.
*
* @return \Generator<string>
*/
public function streamLiteral(int $length, int $chunkSize = 8192): \Generator
{
yield from $this->connection->readBytesChunked($length, $chunkSize);
// Consume the closing portion of the FETCH parenthesised list (e.g. ")\r\n")
$this->connection->readLine();
}
private function trailingLiteralLength(string $raw): ?int
{
if (preg_match('/\{(\d+)\}\r?\n$/', $raw, $matches) !== 1) {
return null;
}
return (int) $matches[1];
}
private function trimTrailingLineEnding(string $raw): string
{
if (str_ends_with($raw, "\r\n")) {
return substr($raw, 0, -2);
}
if (str_ends_with($raw, "\n")) {
return substr($raw, 0, -1);
}
return $raw;
}
}