Files
provider_imap/lib/Client/Protocol/ResponseHandler.php
Sebastian Krupinski 5dafcbd90d feat: speed improvements
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
2026-03-03 22:08:30 -05:00

130 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Response\Line\CommandContinuation;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Line\Status\StatusType;
use Gricob\IMAP\Protocol\Response\Parser\Parser;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Protocol\Response\ResponseBuilder;
use Gricob\IMAP\Transport\ResponseStream;
use RuntimeException;
readonly class ResponseHandler
{
/**
* Literals larger than this threshold (in bytes) are streamed into a
* temporary file instead of being held as a PHP string. This prevents
* the Doctrine Lexer from running preg_split() over multi-megabyte bodies,
* which is the root cause of OOM errors on large mailboxes.
*/
private const LARGE_LITERAL_THRESHOLD = 524288; // 512 KB
public function __construct(private Parser $parser)
{
}
/**
* Reads the next complete IMAP response line from $stream.
*
* Large literals (>= LARGE_LITERAL_THRESHOLD bytes) are read in 8 KB
* chunks into php://temp resources instead of being appended to $raw,
* so the body content never reaches the lexer as a plain string.
*
* @return array{string, list<resource>} [$raw, $preloadedLiterals]
*/
private function readNextRaw(ResponseStream $stream): array
{
$raw = $stream->readLine();
$preloaded = [];
while (preg_match('/\{(?<bytes>\d+)}\r\n$/', $raw, $matches)) {
$literalSize = (int) $matches['bytes'];
if ($literalSize >= self::LARGE_LITERAL_THRESHOLD) {
// Stream into a temp file to avoid holding a huge string in
// memory. php://temp uses RAM up to 2 MB then spills to disk.
$tmp = fopen('php://temp', 'r+');
$remaining = $literalSize;
while ($remaining > 0) {
$chunk = $stream->read(min(8192, $remaining));
fwrite($tmp, $chunk);
$remaining -= strlen($chunk);
}
rewind($tmp);
$preloaded[] = $tmp;
// Keep the {N}\r\n header in $raw so the parser can read the
// literal size, but do NOT append the N bytes — the parser
// will pull them from the preloaded resource instead.
} else {
$raw .= $stream->read($literalSize);
}
$raw .= $stream->readLine();
}
return [$raw, $preloaded];
}
public function handle(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Response
{
$responseBuilder = new ResponseBuilder($statusTag);
do {
[$raw, $preloaded] = $this->readNextRaw($stream);
$line = $this->parser->parse($raw, $preloaded);
if ($line instanceof CommandContinuation) {
$continuationHandler->continue();
continue;
}
$responseBuilder->addLine($line);
} while (!$responseBuilder->hasStatus());
return $responseBuilder->build();
}
/**
* Streams parsed response lines one at a time as a Generator, yielding each
* untagged Line immediately as it arrives from the socket. The terminal
* Status line is NOT yielded; instead it is set as the generator return
* value so callers can retrieve it via $gen->getReturn() after exhaustion.
*
* @throws CommandFailed if the tagged status is NO or BAD
*
* @return Generator<int, Line, mixed, Status>
*/
public function stream(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Generator
{
$status = null;
do {
[$raw, $preloaded] = $this->readNextRaw($stream);
$line = $this->parser->parse($raw, $preloaded);
if ($line instanceof CommandContinuation) {
$continuationHandler->continue();
continue;
}
if ($line instanceof Status && $line->tag === $statusTag) {
$status = $line;
break;
}
yield $line;
} while (true);
if ($status->type !== StatusType::OK) {
throw CommandFailed::withStatus($status);
}
return $status;
}
}