diff --git a/src/Connection/Protocols/ImapProtocol.php b/src/Connection/Protocols/ImapProtocol.php index 6871d57f..c5f8dec9 100644 --- a/src/Connection/Protocols/ImapProtocol.php +++ b/src/Connection/Protocols/ImapProtocol.php @@ -269,6 +269,20 @@ protected function decodeLine(Response $response, string $line): array { $line = substr($line, $pos + 1); continue; } + // Section identifiers like BODY[HEADER.FIELDS (Subject)] can + // legally contain spaces inside the bracketed section. Extend the token + // while it has unbalanced '[' so the entire bracketed section is + // captured as a single atom. + while (substr_count($token, '[') > substr_count($token, ']')) { + $next = strpos($line, ' ', $pos + 1); + if ($next === false) { + $token = rtrim($line); + $pos = strlen($line) - 1; + break; + } + $token = substr($line, 0, $next); + $pos = $next; + } while ($token[0] == '(') { $stack[] = $tokens; $tokens = []; @@ -779,6 +793,23 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, in $items = (array)$items; $itemList = $this->escapeList($items); + // Server responses always use the BODY[
] form for the data + // identifier, even when the request used BODY.PEEK[
]. + // Per RFC 3501, header names are case-insensitive, so we will convert + // to uppercase and check against that. + $normalizeItem = function ($item) { + if (!is_string($item)) { + return $item; + } + $item = preg_replace('/^BODY\.PEEK\[/i', 'BODY[', $item); + return strtoupper($item); + }; + + // Build a + // normalized list for response matching so neither difference + // causes the request to silently miss its own response. + $itemsForCompare = array_map($normalizeItem, $items); + $response = $this->sendRequest($this->buildUIDCommand("FETCH", $uid), [$set, $itemList], $tag); $result = []; $tokens = []; // define $tokens variable before first use @@ -815,9 +846,9 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, in // if we only want one item we return that one directly if (count($items) == 1) { - if ($tokens[2][0] == $items[0]) { + if (strtoupper((string)$tokens[2][0]) == $itemsForCompare[0]) { $data = $tokens[2][1]; - } elseif ($uid === IMAP::ST_UID && $tokens[2][2] == $items[0]) { + } elseif ($uid === IMAP::ST_UID && strtoupper((string)$tokens[2][2]) == $itemsForCompare[0]) { $data = $tokens[2][3]; } else { $expectedResponse = 0; @@ -825,7 +856,7 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, in $count = count($tokens[2]); // we start with 2, because 0 was already checked for ($i = 2; $i < $count; $i += 2) { - if ($tokens[2][$i] != $items[0]) { + if (strtoupper((string)$tokens[2][$i]) != $itemsForCompare[0]) { continue; } $data = $tokens[2][$i + 1]; diff --git a/tests/ImapProtocolTest.php b/tests/ImapProtocolTest.php index 30f3a200..1a2bb582 100644 --- a/tests/ImapProtocolTest.php +++ b/tests/ImapProtocolTest.php @@ -15,7 +15,9 @@ use PHPUnit\Framework\TestCase; use Webklex\PHPIMAP\Config; use Webklex\PHPIMAP\Connection\Protocols\ImapProtocol; +use Webklex\PHPIMAP\Connection\Protocols\Response; use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; +use Webklex\PHPIMAP\IMAP; class ImapProtocolTest extends TestCase { @@ -61,4 +63,117 @@ public function testImapProtocol(): void { 'peer_fingerprint' => ['md5' => 40], ], $protocol->getSslOptions()); } + + + /** + * Section identifiers like BODY[HEADER.FIELDS (Subject)] contain spaces + * inside the brackets. The tokenizer must capture the entire bracketed + * section as a single atom rather than splitting on the inner space. + * + * A real-world FETCH response uses a literal ({N}\r\n) for the + * payload; here we use a bare atom so the test can exercise just the + * tokenizer in isolation. + */ + public function testDecodeLinePreservesSpacesInsideBrackets(): void { + $protocol = $this->makeDecodingProtocol(); + $response = new Response(0); + $line = '* 5 FETCH (BODY[HEADER.FIELDS (Subject)] PAYLOAD)'; + $tokens = $protocol->callDecodeLine($response, $line); + + // Expect: [ '*', '5', 'FETCH', [ 'BODY[HEADER.FIELDS (Subject)]', 'PAYLOAD' ] ] + self::assertSame('*', $tokens[0]); + self::assertSame('5', $tokens[1]); + self::assertSame('FETCH', $tokens[2]); + self::assertIsArray($tokens[3]); + self::assertSame('BODY[HEADER.FIELDS (Subject)]', $tokens[3][0]); + self::assertSame('PAYLOAD', $tokens[3][1]); + } + + + /** + * BODY[HEADER] (no inner space) should still tokenize as a single atom. + * Regression guard against the bracket-balance fix breaking the simple case. + */ + public function testDecodeLineHandlesPlainBracketSection(): void { + $protocol = $this->makeDecodingProtocol(); + $response = new Response(0); + $line = '* 7 FETCH (BODY[HEADER] PAYLOAD)'; + $tokens = $protocol->callDecodeLine($response, $line); + + self::assertSame('*', $tokens[0]); + self::assertSame('7', $tokens[1]); + self::assertSame('FETCH', $tokens[2]); + self::assertIsArray($tokens[3]); + self::assertSame('BODY[HEADER]', $tokens[3][0]); + self::assertSame('PAYLOAD', $tokens[3][1]); + } + + + /** + * Multiple HEADER.FIELDS items in a multisection fetch response should + * each tokenize as one atom, with their payloads following. + */ + public function testDecodeLinePreservesMultipleBracketedSections(): void { + $protocol = $this->makeDecodingProtocol(); + $response = new Response(0); + $line = '* 9 FETCH (BODY[HEADER.FIELDS (Subject)] PAY1 BODY[HEADER.FIELDS (Message-Id)] PAY2)'; + $tokens = $protocol->callDecodeLine($response, $line); + + self::assertIsArray($tokens[3]); + self::assertSame('BODY[HEADER.FIELDS (Subject)]', $tokens[3][0]); + self::assertSame('PAY1', $tokens[3][1]); + self::assertSame('BODY[HEADER.FIELDS (Message-Id)]', $tokens[3][2]); + self::assertSame('PAY2', $tokens[3][3]); + } + + + /** + * Some servers (e.g., Dovecot) normalize HEADER.FIELDS field names to + * uppercase in the response (legal per RFC 3501 since header field + * names are case-insensitive). The fetch() comparison must therefore + * match case-insensitively or queries silently miss. + * + * Drives a mocked end-to-end fetch round-trip with a request whose + * field name (Message-Id) differs in case from the response (MESSAGE-ID). + */ + public function testFetchMatchesCaseInsensitiveHeaderFieldNames(): void { + $payload = "Message-ID: \r\n\r\n"; + $len = strlen($payload); + + $mock = new class($this->config, false) extends ImapProtocol { + /** @var list */ + public array $lines = []; + private int $pos = 0; + public function nextLine(Response $response): string { + $line = $this->lines[$this->pos++]; + $response->addResponse($line); + return $line; + } + public function sendRequest(string $command, array $tokens = [], ?string &$tag = null): Response { + $tag = 'TAG1'; + return Response::make(1, [], [], false); + } + }; + $mock->lines = [ + "* 5 FETCH (UID 42 BODY[HEADER.FIELDS (MESSAGE-ID)] {{$len}}\r\n", + $payload . ")\r\n", + "TAG1 OK FETCH completed\r\n", + ]; + + $r = $mock->fetch(['BODY.PEEK[HEADER.FIELDS (Message-Id)]'], [42], null, IMAP::ST_UID); + $data = $r->validatedData(); + + self::assertIsArray($data); + self::assertArrayHasKey(42, $data); + self::assertSame($payload, $data[42]); + } + + + private function makeDecodingProtocol(): ImapProtocol { + return new class($this->config, false) extends ImapProtocol { + public function callDecodeLine(Response $response, string $line): array { + return $this->decodeLine($response, $line); + } + }; + } } \ No newline at end of file