Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions src/Connection/Protocols/ImapProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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[<section>] form for the data
// identifier, even when the request used BODY.PEEK[<section>].
// 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
Expand Down Expand Up @@ -815,17 +846,17 @@ 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;
// maybe the server send another field we didn't wanted
$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];
Expand Down
115 changes: 115 additions & 0 deletions tests/ImapProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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<bytes>) 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: <abc@example.com>\r\n\r\n";
$len = strlen($payload);

$mock = new class($this->config, false) extends ImapProtocol {
/** @var list<string> */
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);
}
};
}
}