diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 2b64114..2cf5dd4 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -8,7 +8,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; -use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\ImapSort; use Generator; interface ConnectionInterface @@ -121,7 +121,7 @@ public function search(array $params): UntaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc5256 */ - public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse; + public function sort(ImapSort $sort, array $params): UntaggedResponse; /** * Send a "FETCH" command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 381398c..826dc42 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -16,13 +16,13 @@ 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; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; +use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\Support\Str; use Exception; use Generator; @@ -509,11 +509,9 @@ public function search(array $params): UntaggedResponse /** * {@inheritDoc} */ - public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse + public function sort(ImapSort $sort, array $params): UntaggedResponse { - $sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value; - - $this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag); + $this->send('UID SORT', ["({$sort->toImap()})", 'UTF-8', ...$params], tag: $tag); $this->assertTaggedResponse($tag); diff --git a/src/Enums/SortDirection.php b/src/Enums/SortDirection.php new file mode 100644 index 0000000..09a08cc --- /dev/null +++ b/src/Enums/SortDirection.php @@ -0,0 +1,9 @@ +mailbox->capabilities())) { + if (! $this->mailbox->hasCapability('IDLE')) { throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.'); } @@ -183,7 +183,7 @@ public function select(bool $force = false): void */ public function quota(): array { - if (! in_array('QUOTA', $this->mailbox->capabilities())) { + if (! $this->mailbox->hasCapability('QUOTA')) { throw new ImapCapabilityException( 'Unable to fetch mailbox quotas. IMAP server does not support QUOTA capability.' ); diff --git a/src/HasCapabilities.php b/src/HasCapabilities.php new file mode 100644 index 0000000..c398009 --- /dev/null +++ b/src/HasCapabilities.php @@ -0,0 +1,24 @@ +capabilities() as $supported) { + $supported = strtoupper($supported); + + if ($supported === $capability || str_starts_with($supported, "{$capability}=")) { + return true; + } + } + + return false; + } +} diff --git a/src/ImapSort.php b/src/ImapSort.php new file mode 100644 index 0000000..a2701ce --- /dev/null +++ b/src/ImapSort.php @@ -0,0 +1,42 @@ + + */ + public array $criteria; + + /** + * Constructor. + */ + public function __construct(SortCriterion $criterion, SortCriterion ...$criteria) + { + $this->criteria = [$criterion, ...$criteria]; + } + + /** + * Add a sort criterion. + */ + public function add(SortCriterion $criterion): static + { + $this->criteria[] = $criterion; + + return $this; + } + + /** + * Get the IMAP SORT criteria. + */ + public function toImap(): string + { + return implode(' ', array_map( + fn (SortCriterion $criterion) => $criterion->toImap(), + $this->criteria, + )); + } +} diff --git a/src/Mailbox.php b/src/Mailbox.php index 3661686..ef4c206 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -12,6 +12,8 @@ class Mailbox implements MailboxInterface { + use HasCapabilities; + /** * The mailbox configuration. */ diff --git a/src/MailboxInterface.php b/src/MailboxInterface.php index fba71c1..0c0792b 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -51,6 +51,11 @@ public function folders(): FolderRepositoryInterface; */ public function capabilities(): array; + /** + * Determine if the mailbox supports the given capability. + */ + public function hasCapability(string $capability): bool; + /** * Select the given folder. */ diff --git a/src/Message.php b/src/Message.php index 16647c4..db78a17 100644 --- a/src/Message.php +++ b/src/Message.php @@ -187,9 +187,7 @@ public function copy(string $folder): ?int { $mailbox = $this->folder->mailbox(); - $capabilities = $mailbox->capabilities(); - - if (! in_array('UIDPLUS', $capabilities)) { + if (! $mailbox->hasCapability('UIDPLUS')) { throw new ImapCapabilityException( 'Unable to copy message. IMAP server does not support UIDPLUS capability' ); @@ -209,15 +207,13 @@ public function move(string $folder, bool $expunge = false): ?int { $mailbox = $this->folder->mailbox(); - $capabilities = $mailbox->capabilities(); - switch (true) { - case in_array('MOVE', $capabilities): + case $mailbox->hasCapability('MOVE'): $response = $mailbox->connection()->move($folder, $this->uid); return MessageResponseParser::getUidFromCopy($response); - case in_array('UIDPLUS', $capabilities): + case $mailbox->hasCapability('UIDPLUS'): $uid = $this->copy($folder); $this->delete($expunge); diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 6077b31..0327b66 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\Enums\SortDirection; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\MessageData\FetchItem; @@ -33,7 +34,9 @@ class MessageQuery implements MessageQueryInterface public function __construct( protected FolderInterface $folder, protected ImapQueryBuilder $query, - ) {} + ) { + $this->ordering = new UidOrder(SortDirection::Descending); + } /** * Count all available messages matching the current search criteria. @@ -68,7 +71,7 @@ public function firstOrFail(): MessageInterface */ public function get(): MessageCollection { - return $this->process($this->sortKey ? $this->sort() : $this->search()); + return $this->process($this->orderedUids()); } /** @@ -103,8 +106,8 @@ public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = $startChunk = max($startChunk, 1); $chunkSize = max($chunkSize, 1); - // Get all search result tokens once. - $messages = $this->search(); + // Get all ordered result tokens once. + $messages = $this->orderedUids(); // Calculate how many chunks there are $totalChunks = (int) ceil($messages->count() / $chunkSize); @@ -337,13 +340,10 @@ protected function populate(Collection $uids): MessageCollection */ protected function fetch(Collection $messages): array { - // 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), + if ($this->ordering instanceof UidOrder) { + $messages = match ($this->ordering->direction) { + SortDirection::Ascending => $messages->sort(SORT_NUMERIC), + SortDirection::Descending => $messages->sortDesc(SORT_NUMERIC), }; } @@ -360,11 +360,28 @@ protected function fetch(Collection $messages): array ])->all(); } - return $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) { + $fetched = $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) { $data = FetchedMessageData::fromResponse($response); return [$data->uid() => $data]; - })->all(); + }); + + return $uids + ->map(fn (string|int $uid) => $fetched->get($uid)) + ->filter() + ->mapWithKeys(fn (FetchedMessageData $data) => [$data->uid() => $data]) + ->all(); + } + + /** + * Get the ordered message UIDs. + */ + protected function orderedUids(): Collection + { + return match (true) { + $this->ordering instanceof UidOrder => $this->search(), + $this->ordering instanceof ImapSort => $this->sort($this->ordering), + }; } /** @@ -390,9 +407,9 @@ protected function search(): Collection /** * Execute an IMAP UID SORT request using RFC 5256. */ - protected function sort(): Collection + protected function sort(ImapSort $sort): Collection { - if (! in_array('SORT', $this->folder->mailbox()->capabilities())) { + if (! $this->folder->mailbox()->hasCapability('SORT')) { throw new ImapCapabilityException( 'Unable to sort messages. IMAP server does not support SORT capability.' ); @@ -403,8 +420,7 @@ protected function sort(): Collection } $response = $this->connection()->sort( - $this->sortKey, - $this->sortDirection, + $sort, [$this->query->toImap()] ); diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 21ec393..ccf5cad 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -8,6 +8,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\MessageData\FetchItem; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -57,64 +58,21 @@ public function without(FetchItem ...$items): static; public function only(FetchItem ...$items): static; /** - * Set the fetch order. + * Order messages locally by UID, replacing any server-side sort criteria. */ - public function setFetchOrder(string $fetchOrder): MessageQueryInterface; + public function orderByUid( + SortDirection|string $direction = SortDirection::Ascending, + ): static; /** - * Get the fetch order. - */ - public function getFetchOrder(): string; - - /** - * Set the fetch order to 'ascending'. - */ - public function setFetchOrderAsc(): MessageQueryInterface; - - /** - * Set the fetch order to 'descending'. - */ - public function setFetchOrderDesc(): MessageQueryInterface; - - /** - * Set the fetch order to show oldest messages first (ascending). - */ - public function oldest(): MessageQueryInterface; - - /** - * Set the fetch order to show newest messages first (descending). - */ - 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. + * Add a server-side sort criterion using RFC 5256. + * + * Subsequent calls are used as tie-breakers in the order they are added. */ - public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface; + public function sortBy( + ImapSortKey|string $key, + SortDirection|string $direction = SortDirection::Ascending, + ): static; /** * Count all available messages matching the current search criteria. diff --git a/src/QueriesMessages.php b/src/QueriesMessages.php index c245a6c..15ca026 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -4,6 +4,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\MessageData\FetchItem; use DirectoryTree\ImapEngine\Support\ForwardsCalls; use Illuminate\Support\Traits\Conditionable; @@ -35,29 +36,15 @@ trait QueriesMessages protected array $fetchItems = []; /** - * The fetch order. - * - * @var 'asc'|'desc' + * The message ordering strategy. */ - protected string $fetchOrder = 'desc'; + protected UidOrder|ImapSort $ordering; /** * The methods that should be returned from query builder. */ 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. */ @@ -156,68 +143,17 @@ public function only(FetchItem ...$items): static return $this->with(...$items); } - /** {@inheritDoc} */ - public function setFetchOrder(string $fetchOrder): MessageQueryInterface - { - $fetchOrder = strtolower($fetchOrder); - - if (in_array($fetchOrder, ['asc', 'desc'])) { - $this->fetchOrder = $fetchOrder; - } - - return $this; - } - /** * {@inheritDoc} */ - public function getFetchOrder(): string - { - return $this->fetchOrder; - } - - /** - * {@inheritDoc} - */ - public function setFetchOrderAsc(): MessageQueryInterface - { - return $this->setFetchOrder('asc'); - } - - /** - * {@inheritDoc} - */ - public function setFetchOrderDesc(): MessageQueryInterface - { - return $this->setFetchOrder('desc'); - } - - /** - * {@inheritDoc} - */ - public function oldest(): MessageQueryInterface - { - return $this->setFetchOrder('asc'); - } - - /** - * {@inheritDoc} - */ - 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; + public function orderByUid( + SortDirection|string $direction = SortDirection::Ascending, + ): static { + $this->ordering = new UidOrder( + is_string($direction) + ? SortDirection::from(strtolower($direction)) + : $direction, + ); return $this; } @@ -225,46 +161,26 @@ public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface /** * {@inheritDoc} */ - public function getSortKey(): ?ImapSortKey - { - return $this->sortKey; - } + public function sortBy( + ImapSortKey|string $key, + SortDirection|string $direction = SortDirection::Ascending, + ): static { + $key = is_string($key) + ? ImapSortKey::from(strtoupper($key)) + : $key; - /** - * {@inheritDoc} - */ - public function setSortDirection(string $direction): MessageQueryInterface - { - $direction = strtolower($direction); + $direction = is_string($direction) + ? SortDirection::from(strtolower($direction)) + : $direction; + + $criterion = new SortCriterion($key, $direction); - if (in_array($direction, ['asc', 'desc'])) { - $this->sortDirection = $direction; + if ($this->ordering instanceof ImapSort) { + $this->ordering->add($criterion); + } else { + $this->ordering = new ImapSort($criterion); } 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/SortCriterion.php b/src/SortCriterion.php new file mode 100644 index 0000000..d0de925 --- /dev/null +++ b/src/SortCriterion.php @@ -0,0 +1,28 @@ +direction) { + SortDirection::Ascending => $this->key->value, + SortDirection::Descending => "REVERSE {$this->key->value}", + }; + } +} diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index e1ce41c..f7d2275 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -6,10 +6,13 @@ use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; +use DirectoryTree\ImapEngine\HasCapabilities; use DirectoryTree\ImapEngine\MailboxInterface; class FakeMailbox implements MailboxInterface { + use HasCapabilities; + /** * The currently selected folder. */ diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index 34e8cc4..8c99c72 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -8,10 +8,13 @@ use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; use DirectoryTree\ImapEngine\QueriesMessages; +use DirectoryTree\ImapEngine\UidOrder; class FakeMessageQuery implements MessageQueryInterface { @@ -23,16 +26,18 @@ class FakeMessageQuery implements MessageQueryInterface public function __construct( protected FakeFolder $folder, protected ImapQueryBuilder $query = new ImapQueryBuilder - ) {} + ) { + $this->ordering = new UidOrder(SortDirection::Descending); + } /** * {@inheritDoc} */ public function get(): MessageCollection { - return new MessageCollection( + return $this->applyOrdering(new MessageCollection( $this->folder->getMessages() - ); + )); } /** @@ -66,11 +71,9 @@ public function firstOrFail(): MessageInterface */ public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult { - $uid = 1; - - if ($lastMessage = $this->get()->last()) { - $uid = $lastMessage->uid() + 1; - } + $uid = (int) collect($this->folder->getMessages())->max( + fn (FakeMessage $message) => $message->uid() + ) + 1; $this->folder->addMessage( new FakeMessage($uid, $flags === null ? [] : $flags, $message) @@ -79,6 +82,44 @@ public function append(string $message, mixed $flags = null, ?DateTimeInterface return new AppendResult(uid: $uid); } + /** + * Apply the selected ordering strategy. + */ + protected function applyOrdering(MessageCollection $messages): MessageCollection + { + if ($this->ordering instanceof UidOrder) { + return $messages->sortBy( + fn (MessageInterface $message) => $message->uid(), + descending: $this->ordering->direction === SortDirection::Descending, + )->values(); + } + + foreach (array_reverse($this->ordering->criteria) as $criterion) { + $messages = $messages->sortBy( + fn (MessageInterface $message) => $this->sortValue($message, $criterion->key), + descending: $criterion->direction === SortDirection::Descending, + ); + } + + return $messages->values(); + } + + /** + * Get a message's value for the given sort key. + */ + protected function sortValue(MessageInterface $message, ImapSortKey $key): mixed + { + return match ($key) { + ImapSortKey::Cc => head($message->cc())?->email() ?? '', + ImapSortKey::To => head($message->to())?->email() ?? '', + ImapSortKey::Date => $message->date()?->getTimestamp() ?? 0, + ImapSortKey::From => $message->from()?->email() ?? '', + ImapSortKey::Size => $message->size(), + ImapSortKey::Arrival => $message->uid(), + ImapSortKey::Subject => $message->subject() ?? '', + }; + } + /** * {@inheritDoc} */ diff --git a/src/UidOrder.php b/src/UidOrder.php new file mode 100644 index 0000000..b27cfa1 --- /dev/null +++ b/src/UidOrder.php @@ -0,0 +1,15 @@ +hasCapability('imap4rev1'))->toBeTrue(); + expect($mailbox->hasCapability('AUTH'))->toBeTrue(); + expect($mailbox->hasCapability('AUTH=PLAIN'))->toBeTrue(); + expect($mailbox->hasCapability('AUTH=LOGIN'))->toBeFalse(); + expect($mailbox->hasCapability('START'))->toBeFalse(); }); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 433ac3d..bf0f3c1 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\Enums\SortDirection; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; @@ -152,29 +153,58 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->assertWritten('TAG3 UID EXPUNGE 1:3'); }); -test('oldest sets fetch order to asc', function () { - $query = query(); +test('orderByUid returns messages in ascending UID order', function () { + $stream = new FakeStream; + $stream->open(); - $query->oldest(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 3 1 2', + 'TAG2 OK UID SEARCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); - expect($query->getFetchOrder())->toBe('asc'); + $uids = query($mailbox)->orderByUid()->get()->map( + fn ($message) => $message->uid() + )->all(); + + expect($uids)->toBe([1, 2, 3]); }); -test('newest sets fetch order to desc', function () { - $query = query(); +test('orderByUid returns messages in descending UID order', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 2 3 1', + 'TAG2 OK UID SEARCH completed', + ]); - $query->newest(); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $uids = query($mailbox)->orderByUid(SortDirection::Descending)->get()->map( + fn ($message) => $message->uid() + )->all(); - expect($query->getFetchOrder())->toBe('desc'); + expect($uids)->toBe([3, 2, 1]); }); -test('oldest and newest return query instance for chaining', function () { +test('orderByUid returns query instance for chaining', function () { $query = query(); - expect($query->oldest())->toBe($query); - expect($query->newest())->toBe($query); + expect($query->orderByUid())->toBe($query); }); +test('orderByUid fails with incorrect string direction', function () { + query()->orderByUid('invalid'); +})->throws(ValueError::class); + test('each breaks when callback returns false', function () { $stream = new FakeStream; $stream->open(); @@ -585,6 +615,10 @@ function query(?Mailbox $mailbox = null): MessageQuery query()->sortBy('invalid'); })->throws(ValueError::class); +test('sortBy fails with incorrect string direction', function () { + query()->sortBy('date', 'invalid'); +})->throws(ValueError::class); + test('sortBy sends correct sort command with ascending order', function () { $stream = new FakeStream; $stream->open(); @@ -596,6 +630,39 @@ function query(?Mailbox $mailbox = null): MessageQuery 'TAG2 OK CAPABILITY completed', '* SORT 3 1 2', 'TAG3 OK SORT completed', + '* 1 FETCH (UID 1 FLAGS ())', + '* 2 FETCH (UID 2 FLAGS ())', + '* 3 FETCH (UID 3 FLAGS ())', + 'TAG4 OK UID FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $uids = query($mailbox) + ->orderByUid(SortDirection::Descending) + ->sortBy('date') + ->with(MessageData::flags()) + ->get() + ->map(fn ($message) => $message->uid()) + ->all(); + + $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); + + expect($uids)->toBe([3, 1, 2]); +}); + +test('sortBy recognizes extended SORT capabilities', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT=DISPLAY', + 'TAG2 OK CAPABILITY completed', + '* SORT 1', + 'TAG3 OK SORT completed', ]); $mailbox = Mailbox::make(); @@ -622,11 +689,59 @@ function query(?Mailbox $mailbox = null): MessageQuery $mailbox = Mailbox::make(); $mailbox->connect(new ImapConnection($stream)); - query($mailbox)->sortBy('date', 'desc')->get(); + query($mailbox)->sortBy('date', SortDirection::Descending)->get(); $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) UTF-8 ALL'); }); +test('sortBy sends multiple sort criteria in priority 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('subject') + ->sortBy('date', SortDirection::Descending) + ->get(); + + $stream->assertWritten('TAG3 UID SORT (SUBJECT REVERSE DATE) UTF-8 ALL'); +}); + +test('orderByUid replaces server sorting', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 3 1 2', + 'TAG2 OK UID SEARCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $uids = query($mailbox) + ->sortBy('date') + ->orderByUid() + ->get() + ->map(fn ($message) => $message->uid()) + ->all(); + + expect($uids)->toBe([1, 2, 3]); +}); + test('sortBy works with ImapSortKey enum', function () { $stream = new FakeStream; $stream->open(); @@ -664,7 +779,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $mailbox = Mailbox::make(); $mailbox->connect(new ImapConnection($stream)); - query($mailbox)->unseen()->sortBy('arrival', 'desc')->get(); + query($mailbox)->unseen()->sortBy('arrival', SortDirection::Descending)->get(); $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); }); diff --git a/tests/Unit/Testing/FakeMailboxTest.php b/tests/Unit/Testing/FakeMailboxTest.php index c257505..126ac87 100644 --- a/tests/Unit/Testing/FakeMailboxTest.php +++ b/tests/Unit/Testing/FakeMailboxTest.php @@ -15,6 +15,8 @@ expect($mailbox->config('host'))->toBe('imap.example.com'); expect($mailbox->config('username'))->toBe('user1'); expect($mailbox->capabilities())->toBe(['IMAP4rev1', 'STARTTLS']); + expect($mailbox->hasCapability('imap4rev1'))->toBeTrue(); + expect($mailbox->hasCapability('START'))->toBeFalse(); }); test('it returns config values correctly', function () { diff --git a/tests/Unit/Testing/FakeMessageQueryTest.php b/tests/Unit/Testing/FakeMessageQueryTest.php index 697173d..a223756 100644 --- a/tests/Unit/Testing/FakeMessageQueryTest.php +++ b/tests/Unit/Testing/FakeMessageQueryTest.php @@ -1,6 +1,8 @@ toHaveCount(2); }); +test('it orders messages by uid', function () { + $folder = new FakeFolder('INBOX', messages: [ + new FakeMessage(2), + new FakeMessage(1), + new FakeMessage(3), + ]); + + $query = new FakeMessageQuery($folder); + + $ascending = $query + ->orderByUid() + ->get() + ->map(fn (FakeMessage $message) => $message->uid()) + ->all(); + + $descending = $query + ->orderByUid(SortDirection::Descending) + ->get() + ->map(fn (FakeMessage $message) => $message->uid()) + ->all(); + + expect($ascending)->toBe([1, 2, 3]) + ->and($descending)->toBe([3, 2, 1]); +}); + +test('it applies server sort criteria', function () { + $folder = new FakeFolder('INBOX', messages: [ + new FakeMessage(1, contents: "Subject: Zebra\r\n\r\n"), + new FakeMessage(2, contents: "Subject: Apple\r\n\r\n"), + ]); + + $query = new FakeMessageQuery($folder); + + $uids = $query + ->sortBy(ImapSortKey::Subject) + ->get() + ->map(fn (FakeMessage $message) => $message->uid()) + ->all(); + + expect($uids)->toBe([2, 1]); +}); + test('it counts messages correctly', function () { $folder = new FakeFolder('INBOX', messages: [ new FakeMessage(1), @@ -53,7 +97,7 @@ $first = $query->first(); expect($first)->toBeInstanceOf(FakeMessage::class); - expect($first->uid())->toBe(1); + expect($first->uid())->toBe(2); }); test('it returns null when no messages exist for first()', function () { @@ -195,8 +239,8 @@ } }, 2); // Use chunk size of 2 - // Should process messages 1, 2, and 3, then break - expect($processedUids)->toBe([1, 2, 3]); + // Should process messages 5, 4, and 3, then break + expect($processedUids)->toBe([5, 4, 3]); }); test('chunk breaks when callback returns false', function () { @@ -240,7 +284,7 @@ }); // Should process all messages - expect($processedUids)->toBe([1, 2, 3]); + expect($processedUids)->toBe([3, 2, 1]); }); test('chunk processes all chunks when callback never returns false', function () {