From 826940087df5a2fb877f3bbea718937cbebd8cb0 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 29 Jun 2026 15:42:29 -0400 Subject: [PATCH] fix: message streaming Signed-off-by: Sebastian Krupinski --- lib/Client/Command/FetchResponseParser.php | 4 ++-- lib/Client/MessageParser.php | 20 +++++++++---------- lib/Client/Protocol/ProtocolReader.php | 19 ++++++++++++++---- .../Protocol/Response/UntaggedResponse.php | 11 ++++++++-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/Client/Command/FetchResponseParser.php b/lib/Client/Command/FetchResponseParser.php index 78e1489..836e7c5 100644 --- a/lib/Client/Command/FetchResponseParser.php +++ b/lib/Client/Command/FetchResponseParser.php @@ -39,7 +39,7 @@ final class FetchResponseParser public function parseMany(ResponseStream $responses): Generator { foreach ($responses as $response) { - if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) { + if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->raw())) { yield MessageParser::parse($response->raw()); continue; } @@ -55,4 +55,4 @@ final class FetchResponseParser throw new ImapException('FETCH did not receive a tagged completion response.'); } -} \ No newline at end of file +} diff --git a/lib/Client/MessageParser.php b/lib/Client/MessageParser.php index 1e8970d..57f7300 100644 --- a/lib/Client/MessageParser.php +++ b/lib/Client/MessageParser.php @@ -8,19 +8,21 @@ use DateTimeInterface; final class MessageParser { - public static function isFetchMessage(string $payload): bool + public static function isFetchMessage(string $raw): bool { - return str_contains(strtoupper($payload), 'FETCH ('); + return preg_match('/^\*\s+\d+\s+FETCH\s+\(/i', $raw) === 1; } public static function parse(string $raw): Message { - if (!preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.*)\)$/is', $raw, $matches)) { + if (preg_match('/^\*\s+(\d+)\s+FETCH\s+\(/iA', $raw, $matches) !== 1 + || !str_ends_with($raw, ')')) { throw new ImapException('Unable to parse FETCH response: ' . $raw); } $sequence = (int) $matches[1]; - $attributes = self::parseAttributes($matches[2]); + $offset = strlen($matches[0]); + $attributes = self::parseAttributes($raw, $offset, strlen($raw) - 1); $uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw); $envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null; $bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null; @@ -52,15 +54,13 @@ final class MessageParser /** * @return array */ - private static function parseAttributes(string $payload): array + private static function parseAttributes(string $payload, int &$offset, int $end): array { $attributes = []; - $offset = 0; - $length = strlen($payload); - while ($offset < $length) { + while ($offset < $end) { self::skipWhitespace($payload, $offset); - if ($offset >= $length) { + if ($offset >= $end) { break; } @@ -784,4 +784,4 @@ final class MessageParser $value, ))); } -} \ No newline at end of file +} diff --git a/lib/Client/Protocol/ProtocolReader.php b/lib/Client/Protocol/ProtocolReader.php index e71845d..ac03d25 100644 --- a/lib/Client/Protocol/ProtocolReader.php +++ b/lib/Client/Protocol/ProtocolReader.php @@ -56,16 +56,27 @@ final class ProtocolReader } if (str_starts_with($raw, '* ')) { - $parts = preg_split('/\s+/', substr($raw, 2), 2) ?: []; - $label = strtoupper($parts[0] ?? ''); + // Keep the payload as a slice of the raw response. FETCH payloads can + // contain large message literals, so eagerly splitting here would keep + // a second full copy alive while the message is parsed. + $labelEnd = 2; + while (isset($raw[$labelEnd]) && !ctype_space($raw[$labelEnd])) { + $labelEnd++; + } + $label = strtoupper(substr($raw, 2, $labelEnd - 2)); + $payloadOffset = $labelEnd; + while (isset($raw[$payloadOffset]) && ctype_space($raw[$payloadOffset])) { + $payloadOffset++; + } $this->logger?->debug('IMAP untagged response received: {raw}', [ 'label' => $label, 'raw' => $raw, ]); return new UntaggedResponse( $label, - $parts[1] ?? '', + '', $raw, + $payloadOffset, ); } @@ -181,4 +192,4 @@ final class ProtocolReader return $raw; } -} \ No newline at end of file +} diff --git a/lib/Client/Protocol/Response/UntaggedResponse.php b/lib/Client/Protocol/Response/UntaggedResponse.php index 8b7cf17..d0f637f 100644 --- a/lib/Client/Protocol/Response/UntaggedResponse.php +++ b/lib/Client/Protocol/Response/UntaggedResponse.php @@ -10,6 +10,7 @@ final class UntaggedResponse implements ResponseInterface private readonly string $label, private readonly string $payload, private readonly string $raw, + private readonly ?int $payloadOffset = null, ) {} public function label(): string @@ -19,6 +20,12 @@ final class UntaggedResponse implements ResponseInterface public function payload(): string { + // ProtocolReader records an offset for potentially large responses and + // only materializes the payload for command parsers that actually need it. + if ($this->payloadOffset !== null) { + return substr($this->raw, $this->payloadOffset); + } + return $this->payload; } @@ -27,7 +34,7 @@ final class UntaggedResponse implements ResponseInterface */ public function payloadTokens(): array { - $payload = trim($this->payload); + $payload = trim($this->payload()); if ($payload === '') { return []; @@ -40,4 +47,4 @@ final class UntaggedResponse implements ResponseInterface { return $this->raw; } -} \ No newline at end of file +}