feat: implement download

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-05-23 20:18:58 -04:00
parent 640e3aa811
commit 9cdebd82b8
15 changed files with 336 additions and 172 deletions

View File

@@ -42,7 +42,14 @@ final class ProtocolReader
public function readResponse(): ResponseInterface
{
$raw = $this->readRawResponse();
$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.');
@@ -85,16 +92,72 @@ final class ProtocolReader
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
}
private function readRawResponse(): string
/**
* 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
{
$raw = $this->connection->readLine();
while (true) {
$line = $this->connection->readLine();
$trimmed = $this->trimTrailingLineEnding($line);
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
$raw .= $this->connection->readBytes($literalLength);
$raw .= $this->connection->readLine();
// 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
}
}
return $this->trimTrailingLineEnding($raw);
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