fix: message streaming

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-29 15:42:29 -04:00
parent 5b27535723
commit 826940087d
4 changed files with 36 additions and 18 deletions
+2 -2
View File
@@ -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.');
}
}
}
+10 -10
View File
@@ -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<string, mixed>
*/
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,
)));
}
}
}
+15 -4
View File
@@ -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;
}
}
}
@@ -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;
}
}
}