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 */ 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; } }