Skip to content

Commit 8ca11d5

Browse files
authored
Merge pull request #147 from DirectoryTree/sort
Implement IMAP SORT
2 parents e4bec48 + 33aba0c commit 8ca11d5

File tree

11 files changed

+321
-8
lines changed

11 files changed

+321
-8
lines changed

.github/workflows/run-integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
fail-fast: false
2525
matrix:
2626
os: [ ubuntu-latest ]
27-
php: [ 8.4, 8.3, 8.2, 8.1 ]
27+
php: [ 8.5, 8.4, 8.3, 8.2 ]
2828
dependency-version: [ prefer-stable ]
2929

3030
name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }}

.github/workflows/run-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
fail-fast: false
1717
matrix:
1818
os: [ ubuntu-latest ]
19-
php: [ 8.4, 8.3, 8.2, 8.1 ]
19+
php: [ 8.5, 8.4, 8.3, 8.2 ]
2020
dependency-version: [ prefer-stable ]
2121

2222
name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }}

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
},
2727
"require-dev": {
2828
"spatie/ray": "^1.0",
29-
"pestphp/pest": "^2.0|^3.0"
29+
"pestphp/pest": "^2.0|^3.0|^4.0"
3030
},
3131
"autoload": {
3232
"psr-4": {

src/Connection/ConnectionInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
77
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
88
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
9+
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
910
use Generator;
1011

1112
interface ConnectionInterface
@@ -111,6 +112,15 @@ public function capability(): UntaggedResponse;
111112
*/
112113
public function search(array $params): UntaggedResponse;
113114

115+
/**
116+
* Send a "SORT" command.
117+
*
118+
* Execute a sort request using RFC 5256.
119+
*
120+
* @see https://datatracker.ietf.org/doc/html/rfc5256
121+
*/
122+
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse;
123+
114124
/**
115125
* Send a "FETCH" command.
116126
*

src/Connection/ImapConnection.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
1515
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
1616
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
17+
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
1718
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
1819
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
1920
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
@@ -497,6 +498,22 @@ public function search(array $params): UntaggedResponse
497498
);
498499
}
499500

501+
/**
502+
* {@inheritDoc}
503+
*/
504+
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse
505+
{
506+
$sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value;
507+
508+
$this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag);
509+
510+
$this->assertTaggedResponse($tag);
511+
512+
return $this->result->responses()->untagged()->firstOrFail(
513+
fn (UntaggedResponse $response) => $response->type()->is('SORT')
514+
);
515+
}
516+
500517
/**
501518
* {@inheritDoc}
502519
*/

src/Enums/ImapSortKey.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace DirectoryTree\ImapEngine\Enums;
4+
5+
enum ImapSortKey: string
6+
{
7+
case Cc = 'CC';
8+
case To = 'TO';
9+
case Date = 'DATE';
10+
case From = 'FROM';
11+
case Size = 'SIZE';
12+
case Arrival = 'ARRIVAL';
13+
case Subject = 'SUBJECT';
14+
}

src/MessageQuery.php

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
1313
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
1414
use DirectoryTree\ImapEngine\Enums\ImapFlag;
15+
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
1516
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
1617
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
1718
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
@@ -67,7 +68,7 @@ public function firstOrFail(): MessageInterface
6768
*/
6869
public function get(): MessageCollection
6970
{
70-
return $this->process($this->search());
71+
return $this->process($this->sortKey ? $this->sort() : $this->search());
7172
}
7273

7374
/**
@@ -354,10 +355,15 @@ protected function populate(Collection $uids): MessageCollection
354355
*/
355356
protected function fetch(Collection $messages): array
356357
{
357-
$messages = match ($this->fetchOrder) {
358-
'asc' => $messages->sort(SORT_NUMERIC),
359-
'desc' => $messages->sortDesc(SORT_NUMERIC),
360-
};
358+
// Only apply client-side sorting when not using server-side sorting.
359+
// When sortKey is set, the IMAP SORT command already returns UIDs
360+
// in the correct order, so we should preserve that order.
361+
if (! $this->sortKey) {
362+
$messages = match ($this->fetchOrder) {
363+
'asc' => $messages->sort(SORT_NUMERIC),
364+
'desc' => $messages->sortDesc(SORT_NUMERIC),
365+
};
366+
}
361367

362368
$uids = $messages->forPage($this->page, $this->limit)->values();
363369

@@ -446,6 +452,33 @@ protected function search(): Collection
446452
));
447453
}
448454

455+
/**
456+
* Execute an IMAP UID SORT request using RFC 5256.
457+
*/
458+
protected function sort(): Collection
459+
{
460+
if (! in_array('SORT', $this->folder->mailbox()->capabilities())) {
461+
throw new ImapCapabilityException(
462+
'Unable to sort messages. IMAP server does not support SORT capability.'
463+
);
464+
}
465+
466+
if ($this->query->isEmpty()) {
467+
$this->query->all();
468+
}
469+
470+
$response = $this->connection()->sort(
471+
$this->sortKey,
472+
$this->sortDirection,
473+
[$this->query->toImap()]
474+
);
475+
476+
return new Collection(array_map(
477+
fn (Token $token) => $token->value,
478+
$response->tokensAfter(2)
479+
));
480+
}
481+
449482
/**
450483
* Get the UID for the given identifier.
451484
*/

src/MessageQueryInterface.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BackedEnum;
66
use DirectoryTree\ImapEngine\Collections\MessageCollection;
77
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
8+
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
89
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
910

1011
/**
@@ -152,6 +153,36 @@ public function oldest(): MessageQueryInterface;
152153
*/
153154
public function newest(): MessageQueryInterface;
154155

156+
/**
157+
* Set the sort key for server-side sorting (RFC 5256).
158+
*/
159+
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface;
160+
161+
/**
162+
* Get the sort key for server-side sorting.
163+
*/
164+
public function getSortKey(): ?ImapSortKey;
165+
166+
/**
167+
* Set the sort direction for server-side sorting.
168+
*/
169+
public function setSortDirection(string $direction): MessageQueryInterface;
170+
171+
/**
172+
* Get the sort direction for server-side sorting.
173+
*/
174+
public function getSortDirection(): string;
175+
176+
/**
177+
* Sort messages by a field using server-side sorting (RFC 5256).
178+
*/
179+
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface;
180+
181+
/**
182+
* Sort messages by a field in descending order using server-side sorting.
183+
*/
184+
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface;
185+
155186
/**
156187
* Count all available messages matching the current search criteria.
157188
*/

src/QueriesMessages.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace DirectoryTree\ImapEngine;
44

55
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
6+
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
67
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
78
use Illuminate\Support\Traits\Conditionable;
89

@@ -67,6 +68,18 @@ trait QueriesMessages
6768
*/
6869
protected array $passthru = ['toimap', 'isempty'];
6970

71+
/**
72+
* The sort key for server-side sorting (RFC 5256).
73+
*/
74+
protected ?ImapSortKey $sortKey = null;
75+
76+
/**
77+
* The sort direction for server-side sorting.
78+
*
79+
* @var 'asc'|'desc'
80+
*/
81+
protected string $sortDirection = 'asc';
82+
7083
/**
7184
* Handle dynamic method calls into the query builder.
7285
*/
@@ -372,4 +385,64 @@ public function newest(): MessageQueryInterface
372385
{
373386
return $this->setFetchOrder('desc');
374387
}
388+
389+
/**
390+
* {@inheritDoc}
391+
*/
392+
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface
393+
{
394+
if (is_string($key)) {
395+
$key = ImapSortKey::from(strtoupper($key));
396+
}
397+
398+
$this->sortKey = $key;
399+
400+
return $this;
401+
}
402+
403+
/**
404+
* {@inheritDoc}
405+
*/
406+
public function getSortKey(): ?ImapSortKey
407+
{
408+
return $this->sortKey;
409+
}
410+
411+
/**
412+
* {@inheritDoc}
413+
*/
414+
public function setSortDirection(string $direction): MessageQueryInterface
415+
{
416+
$direction = strtolower($direction);
417+
418+
if (in_array($direction, ['asc', 'desc'])) {
419+
$this->sortDirection = $direction;
420+
}
421+
422+
return $this;
423+
}
424+
425+
/**
426+
* {@inheritDoc}
427+
*/
428+
public function getSortDirection(): string
429+
{
430+
return $this->sortDirection;
431+
}
432+
433+
/**
434+
* {@inheritDoc}
435+
*/
436+
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface
437+
{
438+
return $this->setSortKey($key)->setSortDirection($direction);
439+
}
440+
441+
/**
442+
* {@inheritDoc}
443+
*/
444+
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface
445+
{
446+
return $this->sortBy($key, 'desc');
447+
}
375448
}

tests/Integration/MessagesTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,31 @@ function folder(): Folder
453453

454454
expect($folder->messages()->unseen()->count())->toBe(0);
455455
});
456+
457+
test('sort by subject', function () {
458+
$folder = folder();
459+
460+
$uid1 = $folder->messages()->append(
461+
new DraftMessage(
462+
from: 'foo@email.com',
463+
subject: 'AAA First alphabetically',
464+
text: 'hello world',
465+
),
466+
);
467+
468+
$uid2 = $folder->messages()->append(
469+
new DraftMessage(
470+
from: 'foo@email.com',
471+
subject: 'ZZZ Last alphabetically',
472+
text: 'hello world',
473+
),
474+
);
475+
476+
// Ascending order: AAA should come before ZZZ
477+
$messagesAsc = $folder->messages()->sortBy('subject', 'asc')->get();
478+
expect($messagesAsc->map(fn (Message $message) => $message->uid())->all())->toEqual([$uid1, $uid2]);
479+
480+
// Descending order: ZZZ should come before AAA
481+
$messagesDesc = $folder->messages()->sortBy('subject', 'desc')->get();
482+
expect($messagesDesc->map(fn (Message $message) => $message->uid())->all())->toEqual([$uid2, $uid1]);
483+
});

0 commit comments

Comments
 (0)