diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml index a36c290..2513f37 100644 --- a/.github/workflows/run-integration-tests.yml +++ b/.github/workflows/run-integration-tests.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] + php: [ 8.5, 8.4, 8.3, 8.2 ] dependency-version: [ prefer-stable ] name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f153891..01273a6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] + php: [ 8.5, 8.4, 8.3, 8.2 ] dependency-version: [ prefer-stable ] name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} diff --git a/composer.json b/composer.json index 0609c0e..0967fa7 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ }, "require-dev": { "spatie/ray": "^1.0", - "pestphp/pest": "^2.0|^3.0" + "pestphp/pest": "^2.0|^3.0|^4.0" }, "autoload": { "psr-4": { diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 65cbff0..f04ce09 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -6,6 +6,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use Generator; interface ConnectionInterface @@ -111,6 +112,15 @@ public function capability(): UntaggedResponse; */ public function search(array $params): UntaggedResponse; + /** + * Send a "SORT" command. + * + * Execute a sort request using RFC 5256. + * + * @see https://datatracker.ietf.org/doc/html/rfc5256 + */ + public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse; + /** * Send a "FETCH" command. * diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 629b60a..5e1cc75 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -14,6 +14,7 @@ use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface; use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException; @@ -497,6 +498,22 @@ public function search(array $params): UntaggedResponse ); } + /** + * {@inheritDoc} + */ + public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse + { + $sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value; + + $this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag); + + $this->assertTaggedResponse($tag); + + return $this->result->responses()->untagged()->firstOrFail( + fn (UntaggedResponse $response) => $response->type()->is('SORT') + ); + } + /** * {@inheritDoc} */ diff --git a/src/Enums/ImapSortKey.php b/src/Enums/ImapSortKey.php new file mode 100644 index 0000000..9f77f47 --- /dev/null +++ b/src/Enums/ImapSortKey.php @@ -0,0 +1,14 @@ +process($this->search()); + return $this->process($this->sortKey ? $this->sort() : $this->search()); } /** @@ -354,10 +355,15 @@ protected function populate(Collection $uids): MessageCollection */ protected function fetch(Collection $messages): array { - $messages = match ($this->fetchOrder) { - 'asc' => $messages->sort(SORT_NUMERIC), - 'desc' => $messages->sortDesc(SORT_NUMERIC), - }; + // Only apply client-side sorting when not using server-side sorting. + // When sortKey is set, the IMAP SORT command already returns UIDs + // in the correct order, so we should preserve that order. + if (! $this->sortKey) { + $messages = match ($this->fetchOrder) { + 'asc' => $messages->sort(SORT_NUMERIC), + 'desc' => $messages->sortDesc(SORT_NUMERIC), + }; + } $uids = $messages->forPage($this->page, $this->limit)->values(); @@ -446,6 +452,33 @@ protected function search(): Collection )); } + /** + * Execute an IMAP UID SORT request using RFC 5256. + */ + protected function sort(): Collection + { + if (! in_array('SORT', $this->folder->mailbox()->capabilities())) { + throw new ImapCapabilityException( + 'Unable to sort messages. IMAP server does not support SORT capability.' + ); + } + + if ($this->query->isEmpty()) { + $this->query->all(); + } + + $response = $this->connection()->sort( + $this->sortKey, + $this->sortDirection, + [$this->query->toImap()] + ); + + return new Collection(array_map( + fn (Token $token) => $token->value, + $response->tokensAfter(2) + )); + } + /** * Get the UID for the given identifier. */ diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 7338ea0..2fda905 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -5,6 +5,7 @@ use BackedEnum; use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; /** @@ -152,6 +153,36 @@ public function oldest(): MessageQueryInterface; */ public function newest(): MessageQueryInterface; + /** + * Set the sort key for server-side sorting (RFC 5256). + */ + public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface; + + /** + * Get the sort key for server-side sorting. + */ + public function getSortKey(): ?ImapSortKey; + + /** + * Set the sort direction for server-side sorting. + */ + public function setSortDirection(string $direction): MessageQueryInterface; + + /** + * Get the sort direction for server-side sorting. + */ + public function getSortDirection(): string; + + /** + * Sort messages by a field using server-side sorting (RFC 5256). + */ + public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface; + + /** + * Sort messages by a field in descending order using server-side sorting. + */ + public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface; + /** * Count all available messages matching the current search criteria. */ diff --git a/src/QueriesMessages.php b/src/QueriesMessages.php index e9f3f27..1c45b72 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Support\ForwardsCalls; use Illuminate\Support\Traits\Conditionable; @@ -67,6 +68,18 @@ trait QueriesMessages */ protected array $passthru = ['toimap', 'isempty']; + /** + * The sort key for server-side sorting (RFC 5256). + */ + protected ?ImapSortKey $sortKey = null; + + /** + * The sort direction for server-side sorting. + * + * @var 'asc'|'desc' + */ + protected string $sortDirection = 'asc'; + /** * Handle dynamic method calls into the query builder. */ @@ -372,4 +385,64 @@ public function newest(): MessageQueryInterface { return $this->setFetchOrder('desc'); } + + /** + * {@inheritDoc} + */ + public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface + { + if (is_string($key)) { + $key = ImapSortKey::from(strtoupper($key)); + } + + $this->sortKey = $key; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getSortKey(): ?ImapSortKey + { + return $this->sortKey; + } + + /** + * {@inheritDoc} + */ + public function setSortDirection(string $direction): MessageQueryInterface + { + $direction = strtolower($direction); + + if (in_array($direction, ['asc', 'desc'])) { + $this->sortDirection = $direction; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getSortDirection(): string + { + return $this->sortDirection; + } + + /** + * {@inheritDoc} + */ + public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface + { + return $this->setSortKey($key)->setSortDirection($direction); + } + + /** + * {@inheritDoc} + */ + public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface + { + return $this->sortBy($key, 'desc'); + } } diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 096abf4..1aee80f 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -453,3 +453,31 @@ function folder(): Folder expect($folder->messages()->unseen()->count())->toBe(0); }); + +test('sort by subject', function () { + $folder = folder(); + + $uid1 = $folder->messages()->append( + new DraftMessage( + from: 'foo@email.com', + subject: 'AAA First alphabetically', + text: 'hello world', + ), + ); + + $uid2 = $folder->messages()->append( + new DraftMessage( + from: 'foo@email.com', + subject: 'ZZZ Last alphabetically', + text: 'hello world', + ), + ); + + // Ascending order: AAA should come before ZZZ + $messagesAsc = $folder->messages()->sortBy('subject', 'asc')->get(); + expect($messagesAsc->map(fn (Message $message) => $message->uid())->all())->toEqual([$uid1, $uid2]); + + // Descending order: ZZZ should come before AAA + $messagesDesc = $folder->messages()->sortBy('subject', 'desc')->get(); + expect($messagesDesc->map(fn (Message $message) => $message->uid())->all())->toEqual([$uid2, $uid1]); +}); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 16dc948..88ba964 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -4,6 +4,8 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Enums\ImapFlag; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\MessageQuery; @@ -476,3 +478,108 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($count)->toBe(0); }); + +test('sortBy fails with incorrect string key', function () { + query()->sortBy('invalid'); +})->throws(ValueError::class); + +test('sortBy sends correct sort command with ascending order', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', + '* SORT 3 1 2', + 'TAG3 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy('date')->get(); + + $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); +}); + +test('sortBy sends correct sort command with descending order', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', + '* SORT 2 1 3', + 'TAG3 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy('date', 'desc')->get(); + + $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) UTF-8 ALL'); +}); + +test('sortBy works with ImapSortKey enum', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', + '* SORT 1 2 3', + 'TAG3 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy(ImapSortKey::Subject)->get(); + + $stream->assertWritten('TAG3 UID SORT (SUBJECT) UTF-8 ALL'); +}); + +test('sortBy combined with search criteria', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', + '* SORT 5 3', + 'TAG3 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->unseen()->sortBy('arrival', 'desc')->get(); + + $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); +}); + +test('sortBy throws exception when SORT capability is not available', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1', + 'TAG2 OK CAPABILITY completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy('date', 'desc')->get(); +})->throws(ImapCapabilityException::class);