generated from Nodarx/template
184 lines
6.2 KiB
PHP
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;
|
|
}
|
|
} |