diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml index 031a784..a36c290 100644 --- a/.github/workflows/run-integration-tests.yml +++ b/.github/workflows/run-integration-tests.yml @@ -2,6 +2,8 @@ name: run-integration-tests on: push: + branches: + - master pull_request: schedule: - cron: "0 0 * * *" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c82909c..f153891 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,42 +1,44 @@ name: run-tests on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" + push: + branches: + - master + pull_request: + schedule: + - cron: "0 0 * * *" jobs: - run-tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] - dependency-version: [ prefer-stable ] - - name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.composer/cache/files - key: dependencies-laravel-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - - - name: Install dependencies - run: | - composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction - - - name: Execute tests - run: vendor/bin/pest --testsuite Unit + run-tests: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + php: [ 8.4, 8.3, 8.2, 8.1 ] + dependency-version: [ prefer-stable ] + + name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.composer/cache/files + key: dependencies-laravel-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Install dependencies + run: | + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/pest --testsuite Unit diff --git a/src/Connection/ImapParser.php b/src/Connection/ImapParser.php index 0ee47e6..480c435 100644 --- a/src/Connection/ImapParser.php +++ b/src/Connection/ImapParser.php @@ -88,12 +88,12 @@ protected function parseUntaggedResponse(): UntaggedResponse $this->advance(); // Collect all tokens until the end-of-response marker. - while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) { + while ($this->currentToken && ! $this->currentToken instanceof Crlf) { $elements[] = $this->parseElement(); } // If the end-of-response marker (CRLF) is present, consume it. - if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) { + if ($this->currentToken && $this->currentToken instanceof Crlf) { $this->currentToken = null; } else { throw new ImapParserException('Unterminated untagged response'); @@ -116,12 +116,12 @@ protected function parseContinuationResponse(): ContinuationResponse $this->advance(); // Collect all tokens until the CRLF marker. - while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) { + while ($this->currentToken && ! $this->currentToken instanceof Crlf) { $elements[] = $this->parseElement(); } // Consume the CRLF marker if present. - if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) { + if ($this->currentToken && $this->currentToken instanceof Crlf) { $this->currentToken = null; } else { throw new ImapParserException('Unterminated continuation response'); @@ -144,12 +144,12 @@ protected function parseTaggedResponse(): TaggedResponse $this->advance(); // Collect tokens until the end-of-response marker is reached. - while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) { + while ($this->currentToken && ! $this->currentToken instanceof Crlf) { $tokens[] = $this->parseElement(); } // Consume the CRLF marker if present. - if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) { + if ($this->currentToken && $this->currentToken instanceof Crlf) { $this->currentToken = null; } else { throw new ImapParserException('Unterminated tagged response'); @@ -173,8 +173,14 @@ protected function parseBracketGroup(): ResponseCodeData while ( $this->currentToken && ! $this->currentToken instanceof ResponseCodeClose - && ! $this->isEndOfResponseToken($this->currentToken) ) { + // Skip CRLF tokens that may appear inside bracket groups. + if ($this->currentToken instanceof Crlf) { + $this->advance(); + + continue; + } + $elements[] = $this->parseElement(); } @@ -204,8 +210,14 @@ protected function parseList(): ListData while ( $this->currentToken && ! $this->currentToken instanceof ListClose - && ! $this->isEndOfResponseToken($this->currentToken) ) { + // Skip CRLF tokens that appear inside lists (after literals). + if ($this->currentToken instanceof Crlf) { + $this->advance(); + + continue; + } + $elements[] = $this->parseElement(); } @@ -255,12 +267,4 @@ protected function advance(): void { $this->currentToken = $this->tokenizer->nextToken(); } - - /** - * Determine if the given token marks the end of a response. - */ - protected function isEndOfResponseToken(Token $token): bool - { - return $token instanceof Crlf; - } } diff --git a/tests/Unit/Connection/ImapParserTest.php b/tests/Unit/Connection/ImapParserTest.php index 54bb026..115bfdf 100644 --- a/tests/Unit/Connection/ImapParserTest.php +++ b/tests/Unit/Connection/ImapParserTest.php @@ -267,3 +267,125 @@ "* 1 FETCH (BODY {14}\r\nHello World!\r\n)", ], ]); + +test('parses fetch response with body text then header', function () { + $stream = new FakeStream; + $stream->open(); + + // Simulating BODY[TEXT] before BODY[HEADER] + $stream->feedRaw([ + "* 1 FETCH (UID 123 FLAGS (\\Seen) BODY[TEXT] {13}\r\n", + "Hello World\r\n", + " BODY[HEADER] {23}\r\n", + "Subject: Test Message\r\n", + ")\r\n", + ]); + + $tokenizer = new ImapTokenizer($stream); + $parser = new ImapParser($tokenizer); + + $response = $parser->next(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + + // Get the ListData at index 3 (the FETCH data) + $data = $response->tokenAt(3); + expect($data)->toBeInstanceOf(ListData::class); + + // Verify we can lookup UID + $uid = $data->lookup('UID'); + expect($uid)->not->toBeNull(); + expect($uid->value)->toBe('123'); + + // Verify we can lookup FLAGS + $flags = $data->lookup('FLAGS'); + expect($flags)->not->toBeNull(); + + // Verify we can lookup both BODY sections with correct content + $text = $data->lookup('[TEXT]'); + expect($text)->not->toBeNull(); + expect($text->value)->toBe("Hello World\r\n"); + + $header = $data->lookup('[HEADER]'); + expect($header)->not->toBeNull(); + expect($header->value)->toBe("Subject: Test Message\r\n"); +}); + +test('parses fetch response with body header then text', function () { + $stream = new FakeStream; + $stream->open(); + + // Simulating BODY[HEADER] before BODY[TEXT] + $stream->feedRaw([ + "* 1 FETCH (UID 456 FLAGS (\\Seen) BODY[HEADER] {26}\r\n", + "From: sender@example.com\r\n", + " BODY[TEXT] {20}\r\n", + "Message body here.\r\n", + ")\r\n", + ]); + + $tokenizer = new ImapTokenizer($stream); + $parser = new ImapParser($tokenizer); + + $response = $parser->next(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + + // Get the ListData at index 3 (the FETCH data) + $data = $response->tokenAt(3); + expect($data)->toBeInstanceOf(ListData::class); + + // Verify we can lookup UID + $uid = $data->lookup('UID'); + expect($uid)->not->toBeNull(); + expect($uid->value)->toBe('456'); + + // Verify we can lookup FLAGS + $flags = $data->lookup('FLAGS'); + expect($flags)->not->toBeNull(); + expect($flags->tokens())->toHaveCount(1); + expect($flags->tokenAt(0)->value)->toBe('\\Seen'); + + // Verify we can lookup both BODY sections with correct content + $header = $data->lookup('[HEADER]'); + expect($header)->not->toBeNull(); + expect($header->value)->toBe("From: sender@example.com\r\n"); + + $text = $data->lookup('[TEXT]'); + expect($text)->not->toBeNull(); + expect($text->value)->toBe("Message body here.\r\n"); +}); + +test('parses fetch response with all metadata and body parts', function () { + $stream = new FakeStream; + $stream->open(); + + // Full FETCH response with all common fields + $stream->feedRaw([ + "* 1 FETCH (UID 789 RFC822.SIZE 1024 FLAGS (\\Seen \\Flagged) BODY[TEXT] {25}\r\n", + "This is the email body.\r\n", + " BODY[HEADER] {46}\r\n", + "To: recipient@example.com\r\nSubject: Re: Test\r\n", + ")\r\n", + ]); + + $tokenizer = new ImapTokenizer($stream); + $parser = new ImapParser($tokenizer); + + $response = $parser->next(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + + $data = $response->tokenAt(3); + expect($data)->toBeInstanceOf(ListData::class); + + $flags = $data->lookup('FLAGS')->tokens(); + + expect($flags)->toHaveCount(2); + expect($flags[0]->value)->toBe('\\Seen'); + expect($flags[1]->value)->toBe('\\Flagged'); + expect($data->lookup('UID')?->value)->toBe('789'); + expect($data->lookup('RFC822.SIZE')?->value)->toBe('1024'); + expect($data->lookup('[TEXT]')->value)->toBe("This is the email body.\r\n"); + expect($data->lookup('[HEADER]')->value)->toBe("To: recipient@example.com\r\nSubject: Re: Test\r\n"); +});