Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/run-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions src/Connection/ConnectionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
17 changes: 17 additions & 0 deletions src/Connection/ImapConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
*/
Expand Down
14 changes: 14 additions & 0 deletions src/Enums/ImapSortKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace DirectoryTree\ImapEngine\Enums;

enum ImapSortKey: string
{
case Cc = 'CC';
case To = 'TO';
case Date = 'DATE';
case From = 'FROM';
case Size = 'SIZE';
case Arrival = 'ARRIVAL';
case Subject = 'SUBJECT';
}
43 changes: 38 additions & 5 deletions src/MessageQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,7 +68,7 @@ public function firstOrFail(): MessageInterface
*/
public function get(): MessageCollection
{
return $this->process($this->search());
return $this->process($this->sortKey ? $this->sort() : $this->search());
}

/**
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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.
*/
Expand Down
31 changes: 31 additions & 0 deletions src/MessageQueryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
73 changes: 73 additions & 0 deletions src/QueriesMessages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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');
}
}
28 changes: 28 additions & 0 deletions tests/Integration/MessagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
Loading