From 10c053550cdb79f68baeab82ee42fa652ae8e99e Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 26 Jan 2026 11:16:55 -0500 Subject: [PATCH 01/12] Add server side sort support --- src/Connection/ConnectionInterface.php | 10 ++++++ src/Connection/ImapConnection.php | 17 ++++++++++ src/Enums/ImapSortKey.php | 14 ++++++++ src/MessageQuery.php | 45 +++++++++++++++++++++++++- src/Testing/FakeMessageQuery.php | 8 +++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/Enums/ImapSortKey.php 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 @@ +sortKey = is_string($key) ? ImapSortKey::from(strtoupper($key)) : $key; + $this->sortDirection = strtolower($direction); + + return $this; + } + /** * Count all available messages matching the current search criteria. */ @@ -67,7 +89,7 @@ public function firstOrFail(): MessageInterface */ public function get(): MessageCollection { - return $this->process($this->search()); + return $this->process($this->sortKey ? $this->sort() : $this->search()); } /** @@ -446,6 +468,27 @@ protected function search(): Collection )); } + /** + * Execute an IMAP UID SORT request using RFC 5256. + */ + protected function sort(): Collection + { + 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/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index bcabbee..5a51c1a 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -223,4 +223,12 @@ public function copy(string $folder): int { return count($this->folder->getMessages()); } + + /** + * {@inheritDoc} + */ + public function sortBy(string|\DirectoryTree\ImapEngine\Enums\ImapSortKey $key, string $direction = 'asc'): static + { + return $this; + } } From 253546cb1b3bb7c1970b5ce7f14f128879a6d465 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 26 Jan 2026 11:47:19 -0500 Subject: [PATCH 02/12] Add tests --- tests/Unit/MessageQueryTest.php | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 16dc948..8a2ab6e 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -4,6 +4,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Enums\ImapFlag; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\MessageQuery; @@ -476,3 +477,79 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($count)->toBe(0); }); + +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', + '* SORT 3 1 2', + 'TAG2 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy('date')->get(); + + $stream->assertWritten('TAG2 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', + '* SORT 2 1 3', + 'TAG2 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy('date', 'desc')->get(); + + $stream->assertWritten('TAG2 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', + '* SORT 1 2 3', + 'TAG2 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->sortBy(ImapSortKey::Subject)->get(); + + $stream->assertWritten('TAG2 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', + '* SORT 5 3', + 'TAG2 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->unseen()->sortBy('arrival', 'desc')->get(); + + $stream->assertWritten('TAG2 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); +}); From 5f92021fd896661c85995c1f0c4517fde575e685 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 26 Jan 2026 15:21:13 -0500 Subject: [PATCH 03/12] Add sortByDesc --- src/MessageQuery.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 4507b7e..97a4dc2 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -48,7 +48,7 @@ public function __construct( /** * Set the server-side sort criteria using RFC 5256 SORT extension. */ - public function sortBy(string|ImapSortKey $key, string $direction = 'asc'): static + public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): static { $this->sortKey = is_string($key) ? ImapSortKey::from(strtoupper($key)) : $key; $this->sortDirection = strtolower($direction); @@ -56,6 +56,14 @@ public function sortBy(string|ImapSortKey $key, string $direction = 'asc'): stat return $this; } + /** + * Set the server-side sort criteria using RFC 5256 SORT extension (in descending order). + */ + public function sortByDesc(ImapSortKey|string $key): static + { + return $this->sortBy($key, 'desc'); + } + /** * Count all available messages matching the current search criteria. */ From cc5734650bcf8ff44bd21d0e7fcad81555b6f259 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 26 Jan 2026 15:50:31 -0500 Subject: [PATCH 04/12] Import enum --- src/Testing/FakeMessageQuery.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index 5a51c1a..508c1c0 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -6,6 +6,7 @@ use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -227,7 +228,7 @@ public function copy(string $folder): int /** * {@inheritDoc} */ - public function sortBy(string|\DirectoryTree\ImapEngine\Enums\ImapSortKey $key, string $direction = 'asc'): static + public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): static { return $this; } From 1369ae87d6cd4381aa76c37705aed5e4065f8323 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 26 Jan 2026 16:03:24 -0500 Subject: [PATCH 05/12] Move server-side sorting to queries messages trait --- src/MessageQuery.php | 30 ------------- src/MessageQueryInterface.php | 31 ++++++++++++++ src/QueriesMessages.php | 73 ++++++++++++++++++++++++++++++++ src/Testing/FakeMessageQuery.php | 9 ---- 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 97a4dc2..7761627 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -12,7 +12,6 @@ use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapFlag; -use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Exceptions\RuntimeException; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -27,16 +26,6 @@ class MessageQuery implements MessageQueryInterface { use QueriesMessages; - /** - * The sort key to use for server-side sorting. - */ - protected ?ImapSortKey $sortKey = null; - - /** - * The sort direction (asc or desc). - */ - protected string $sortDirection = 'asc'; - /** * Constructor. */ @@ -45,25 +34,6 @@ public function __construct( protected ImapQueryBuilder $query, ) {} - /** - * Set the server-side sort criteria using RFC 5256 SORT extension. - */ - public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): static - { - $this->sortKey = is_string($key) ? ImapSortKey::from(strtoupper($key)) : $key; - $this->sortDirection = strtolower($direction); - - return $this; - } - - /** - * Set the server-side sort criteria using RFC 5256 SORT extension (in descending order). - */ - public function sortByDesc(ImapSortKey|string $key): static - { - return $this->sortBy($key, 'desc'); - } - /** * Count all available messages matching the current search criteria. */ 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..7ea46d0 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::tryFrom(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/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index 508c1c0..bcabbee 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -6,7 +6,6 @@ use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; -use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -224,12 +223,4 @@ public function copy(string $folder): int { return count($this->folder->getMessages()); } - - /** - * {@inheritDoc} - */ - public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): static - { - return $this; - } } From 565f28c0ec91530fcfacdcaa201904f9749e7823 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Tue, 27 Jan 2026 09:15:32 -0500 Subject: [PATCH 06/12] Only allow sorting server has capability --- src/MessageQuery.php | 7 ++++++ tests/Unit/MessageQueryTest.php | 42 ++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 7761627..d313293 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -12,6 +12,7 @@ use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapFlag; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Exceptions\RuntimeException; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -451,6 +452,12 @@ protected function search(): Collection */ 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(); } diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 8a2ab6e..6bcdf76 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -5,6 +5,7 @@ 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; @@ -485,8 +486,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->feed([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', '* SORT 3 1 2', - 'TAG2 OK SORT completed', + 'TAG3 OK SORT completed', ]); $mailbox = Mailbox::make(); @@ -494,7 +497,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date')->get(); - $stream->assertWritten('TAG2 UID SORT (DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); }); test('sortBy sends correct sort command with descending order', function () { @@ -504,8 +507,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->feed([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', '* SORT 2 1 3', - 'TAG2 OK SORT completed', + 'TAG3 OK SORT completed', ]); $mailbox = Mailbox::make(); @@ -513,7 +518,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date', 'desc')->get(); - $stream->assertWritten('TAG2 UID SORT (REVERSE DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) UTF-8 ALL'); }); test('sortBy works with ImapSortKey enum', function () { @@ -523,8 +528,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->feed([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', '* SORT 1 2 3', - 'TAG2 OK SORT completed', + 'TAG3 OK SORT completed', ]); $mailbox = Mailbox::make(); @@ -532,7 +539,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy(ImapSortKey::Subject)->get(); - $stream->assertWritten('TAG2 UID SORT (SUBJECT) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (SUBJECT) UTF-8 ALL'); }); test('sortBy combined with search criteria', function () { @@ -542,8 +549,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->feed([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', '* SORT 5 3', - 'TAG2 OK SORT completed', + 'TAG3 OK SORT completed', ]); $mailbox = Mailbox::make(); @@ -551,5 +560,22 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->unseen()->sortBy('arrival', 'desc')->get(); - $stream->assertWritten('TAG2 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); + $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); From 103d7d571ff8691d5e37abffc451935dc05c2167 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 29 Jan 2026 11:22:27 -0500 Subject: [PATCH 07/12] Preserve server-side sort order when sortKey is set --- src/MessageQuery.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/MessageQuery.php b/src/MessageQuery.php index d313293..ab5a640 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -355,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(); From 425d80fc700dc4e7ae752c20850212fa0ff3dfee Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 29 Jan 2026 11:22:33 -0500 Subject: [PATCH 08/12] Add sort integration test --- tests/Integration/MessagesTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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]); +}); From 7395e8369461de687eebe89d64fb31e7db3697cf Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 29 Jan 2026 11:59:49 -0500 Subject: [PATCH 09/12] Fix pest requirement --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 9399dc3a3de5ea9621da079253943a9f8efc3bc0 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 29 Jan 2026 12:03:08 -0500 Subject: [PATCH 10/12] Drop PHP 8.1 tests and test on 8.5 --- .github/workflows/run-integration-tests.yml | 2 +- .github/workflows/run-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 }} From a87471625fbd05ec9499df1aaaa4e02cbe600819 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 30 Jan 2026 17:26:31 -0500 Subject: [PATCH 11/12] Throw an exception if the string sort key is incorrect --- src/QueriesMessages.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/QueriesMessages.php b/src/QueriesMessages.php index 7ea46d0..1c45b72 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -392,7 +392,7 @@ public function newest(): MessageQueryInterface public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface { if (is_string($key)) { - $key = ImapSortKey::tryFrom(strtoupper($key)); + $key = ImapSortKey::from(strtoupper($key)); } $this->sortKey = $key; From 33aba0ceb5dfe931707d3e1a0ea26e091cd4e65f Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 30 Jan 2026 17:26:35 -0500 Subject: [PATCH 12/12] Add test --- tests/Unit/MessageQueryTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 6bcdf76..88ba964 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -479,6 +479,10 @@ 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();