diff --git a/lib/Horde/Imap/Client/Ids.php b/lib/Horde/Imap/Client/Ids.php index 2735c699..1192cd2e 100644 --- a/lib/Horde/Imap/Client/Ids.php +++ b/lib/Horde/Imap/Client/Ids.php @@ -62,6 +62,21 @@ class Horde_Imap_Client_Ids implements Countable, Iterator, Serializable */ protected $_ids = []; + /** + * Compact, sorted ID intervals. When set, this is authoritative and + * $_ids remains empty until the flat list is explicitly requested. + * + * @var array|null + */ + protected $_intervals = null; + + /** + * Iterator state while traversing compact intervals. + * + * @var array + */ + protected $_iterState = []; + /** * Are IDs message sequence numbers? * @@ -97,6 +112,9 @@ public function __get($name) return ($this->_ids === self::ALL); case 'ids': + if ($this->_intervals !== null) { + $this->_expandIntervals(); + } return is_array($this->_ids) ? $this->_ids : []; @@ -105,10 +123,28 @@ public function __get($name) return ($this->_ids === self::LARGEST); case 'max': + if ($this->_intervals !== null) { + if (!$this->_intervals) { + return false; + } + return max(array_column($this->_intervals, 1)); + } + if (is_string($this->_ids)) { + return null; + } $this->sort(); return end($this->_ids); case 'min': + if ($this->_intervals !== null) { + if (!$this->_intervals) { + return false; + } + return min(array_column($this->_intervals, 0)); + } + if (is_string($this->_ids)) { + return null; + } $this->sort(); return reset($this->_ids); @@ -142,6 +178,14 @@ public function __get($name) } elseif ($this->search_res) { return '$'; } + if (is_string($this->_ids)) { + return $this->_ids; + } + if ($this->_intervals !== null) { + return $this->_intervalsToSequenceString( + $name == 'tostring_sort' + ); + } return strval($this->_toSequenceString($name == 'tostring_sort')); } } @@ -166,7 +210,43 @@ public function add($ids) if (is_string($ids) && in_array($ids, [self::ALL, self::SEARCH_RES, self::LARGEST])) { $this->_ids = $ids; + $this->_intervals = null; + } elseif (is_string($ids) + && in_array(trim($ids), ['1:*', '*', '$'], true)) { + $this->_ids = match (trim($ids)) { + '1:*' => self::ALL, + '*' => self::LARGEST, + '$' => self::SEARCH_RES, + }; + $this->_intervals = null; + } elseif (is_string($ids) && str_contains($ids, '*')) { + $sequenceSet = trim($ids); + if (!$this->_isValidWildcardSequenceString($sequenceSet)) { + throw new InvalidArgumentException( + 'Invalid IMAP sequence set.' + ); + } + $this->_ids = $sequenceSet; + $this->_intervals = null; } elseif ($add = $this->_resolveIds($ids)) { + $isIntervals = isset($add[0]) && is_array($add[0]); + if ($isIntervals || ($this->_intervals !== null)) { + $existing = $this->_intervals + ?? $this->_flatToIntervals( + is_array($this->_ids) ? $this->_ids : [] + ); + $incoming = $isIntervals + ? $add + : $this->_flatToIntervals($add); + $this->_intervals = $this->duplicates + ? array_merge($existing, $incoming) + : $this->_mergeIntervals( + array_merge($existing, $incoming) + ); + $this->_ids = []; + $this->_sorted = true; + return; + } if (is_array($this->_ids) && !empty($this->_ids)) { foreach ($add as $val) { $this->_ids[] = (int) $val; @@ -195,9 +275,32 @@ public function add($ids) */ public function remove($ids) { + if (is_string($this->_ids)) { + return; + } if (!$this->isEmpty() && ($remove = $this->_resolveIds($ids))) { - $this->_ids = array_diff($this->_ids, array_unique($remove)); + $removeIsIntervals = isset($remove[0]) && is_array($remove[0]); + if ($this->_intervals !== null || $removeIsIntervals) { + $source = $this->_intervals + ?? $this->_flatToIntervals( + is_array($this->_ids) ? $this->_ids : [] + ); + $removeIntervals = $removeIsIntervals + ? $remove + : $this->_flatToIntervals($remove); + $removeIntervals = $this->_mergeIntervals($removeIntervals); + $this->_intervals = $this->duplicates + ? $this->_subtractIntervalList($source, $removeIntervals) + : $this->_subtractIntervals( + $this->_mergeIntervals($source), + $removeIntervals + ); + $this->_ids = []; + $this->_sorted = true; + } else { + $this->_ids = array_diff($this->_ids, array_unique($remove)); + } } } @@ -208,6 +311,9 @@ public function remove($ids) */ public function isEmpty() { + if ($this->_intervals !== null) { + return !$this->_intervals; + } return (is_array($this->_ids) && !count($this->_ids)); } @@ -216,6 +322,9 @@ public function isEmpty() */ public function reverse() { + if ($this->_intervals !== null) { + $this->_expandIntervals(); + } if (is_array($this->_ids)) { $this->_ids = array_reverse($this->_ids); } @@ -226,6 +335,9 @@ public function reverse() */ public function sort() { + if ($this->_intervals !== null) { + return; + } if (!$this->_sorted && is_array($this->_ids)) { $this->_sort($this->_ids); $this->_sorted = true; @@ -271,11 +383,14 @@ public function split($length) * @param mixed $ids Either Horde_Imap_Client_Ids object, array, or * sequence string. * - * @return array An array of IDs. + * @return array An array of IDs or compact [start, end] intervals. */ protected function _resolveIds($ids) { if ($ids instanceof Horde_Imap_Client_Ids) { + if ($ids->_intervals !== null) { + return $ids->_intervals; + } return $ids->ids; } elseif (is_array($ids)) { return $ids; @@ -347,31 +462,181 @@ protected function _toSequenceString($sort = true) * * @param string $str The IMAP message sequence string. * - * @return array An array of indices. + * @return array An array of IDs or compact [start, end] intervals. */ protected function _fromSequenceString($str) { - $ids = []; $str = trim($str); if (!strlen($str)) { - return $ids; + return []; } $idarray = explode(',', $str); + $hasRange = strpos($str, ':') !== false; + $ids = []; foreach ($idarray as $val) { - $range = explode(':', $val); + $range = explode(':', $val, 2); if (isset($range[1])) { - for ($i = min($range), $j = max($range); $i <= $j; ++$i) { - $ids[] = (int) $i; + if (!ctype_digit($range[0]) || !ctype_digit($range[1])) { + throw new InvalidArgumentException( + 'Open-ended or invalid IMAP ID ranges cannot be expanded without mailbox context.' + ); } + $start = (int) $range[0]; + $end = (int) $range[1]; + $ids[] = ($start <= $end) + ? [$start, $end] + : [$end, $start]; } else { $ids[] = (int) $val; } } - return $ids; + return $hasRange + ? array_map( + static fn($id) => is_array($id) ? $id : [$id, $id], + $ids + ) + : $ids; + } + + /** + * Check a sequence set containing the unresolved `*` endpoint. + */ + protected function _isValidWildcardSequenceString($str) + { + foreach (explode(',', $str) as $part) { + $range = explode(':', $part, 2); + foreach ($range as $endpoint) { + if ($endpoint !== '*' && !ctype_digit($endpoint)) { + return false; + } + } + } + + return $str !== ''; + } + + /** + * Expand compact intervals only when a caller explicitly requests ids. + */ + protected function _expandIntervals() + { + if ($this->_intervals === null) { + return; + } + + $this->_ids = []; + foreach ($this->_intervals as [$start, $end]) { + for ($id = $start; $id <= $end; ++$id) { + $this->_ids[] = $id; + } + } + $this->_intervals = null; + $this->_sorted = true; + } + + /** + * Convert flat IDs to single-value intervals. + * + * @return array List of [start, end] pairs. + */ + protected function _flatToIntervals(array $ids) + { + return array_map( + static fn($id) => [(int) $id, (int) $id], + $ids + ); + } + + /** + * Merge overlapping or adjacent intervals. + * + * @return array Sorted, non-overlapping [start, end] pairs. + */ + protected function _mergeIntervals(array $intervals) + { + if (!$intervals) { + return []; + } + + usort($intervals, static fn($a, $b) => $a[0] <=> $b[0]); + $merged = [$intervals[0]]; + foreach (array_slice($intervals, 1) as [$start, $end]) { + $last = count($merged) - 1; + if ($start <= $merged[$last][1] + 1) { + $merged[$last][1] = max($merged[$last][1], $end); + } else { + $merged[] = [$start, $end]; + } + } + + return $merged; + } + + /** + * Subtract sorted removal intervals from sorted source intervals. + * + * @return array Remaining [start, end] pairs. + */ + protected function _subtractIntervals(array $source, array $remove) + { + $result = []; + foreach ($source as [$start, $end]) { + foreach ($remove as [$removeStart, $removeEnd]) { + if ($removeEnd < $start || $removeStart > $end) { + continue; + } + if ($removeStart > $start) { + $result[] = [$start, $removeStart - 1]; + } + $start = max($start, $removeEnd + 1); + if ($start > $end) { + break; + } + } + if ($start <= $end) { + $result[] = [$start, $end]; + } + } + + return $result; + } + + /** + * Subtract ranges while retaining duplicate intervals. + */ + protected function _subtractIntervalList(array $source, array $remove) + { + $result = []; + foreach ($source as $interval) { + array_push( + $result, + ...$this->_subtractIntervals([$interval], $remove) + ); + } + + return $result; + } + + /** + * Format compact intervals as an IMAP sequence string. + */ + protected function _intervalsToSequenceString($sort = false) + { + $intervals = $this->_intervals; + if ($sort) { + usort($intervals, static fn($a, $b) => $a[0] <=> $b[0]); + } + + return implode(',', array_map( + static fn($interval) => ($interval[0] === $interval[1]) + ? (string) $interval[0] + : $interval[0] . ':' . $interval[1], + $intervals + )); } /* Countable methods. */ @@ -381,6 +646,12 @@ protected function _fromSequenceString($str) #[ReturnTypeWillChange] public function count() { + if ($this->_intervals !== null) { + return array_sum(array_map( + static fn($interval) => $interval[1] - $interval[0] + 1, + $this->_intervals + )); + } return is_array($this->_ids) ? count($this->_ids) : 0; @@ -393,6 +664,12 @@ public function count() #[ReturnTypeWillChange] public function current() { + if ($this->_intervals !== null) { + return isset($this->_iterState['idx']) + && isset($this->_intervals[$this->_iterState['idx']]) + ? $this->_iterState['pos'] + : null; + } return is_array($this->_ids) ? current($this->_ids) : null; @@ -403,6 +680,12 @@ public function current() #[ReturnTypeWillChange] public function key() { + if ($this->_intervals !== null) { + return isset($this->_iterState['idx']) + && isset($this->_intervals[$this->_iterState['idx']]) + ? $this->_iterState['key'] + : null; + } return is_array($this->_ids) ? key($this->_ids) : null; @@ -413,6 +696,22 @@ public function key() #[ReturnTypeWillChange] public function next() { + if ($this->_intervals !== null) { + if (!isset($this->_iterState['idx'])) { + return; + } + ++$this->_iterState['key']; + ++$this->_iterState['pos']; + if ($this->_iterState['pos'] + > $this->_intervals[$this->_iterState['idx']][1]) { + ++$this->_iterState['idx']; + if (isset($this->_intervals[$this->_iterState['idx']])) { + $this->_iterState['pos'] + = $this->_intervals[$this->_iterState['idx']][0]; + } + } + return; + } if (is_array($this->_ids)) { next($this->_ids); } @@ -423,6 +722,17 @@ public function next() #[ReturnTypeWillChange] public function rewind() { + if ($this->_intervals !== null) { + $this->_iterState = []; + if ($this->_intervals) { + $this->_iterState = [ + 'idx' => 0, + 'pos' => $this->_intervals[0][0], + 'key' => 0, + ]; + } + return; + } if (is_array($this->_ids)) { reset($this->_ids); } @@ -461,15 +771,18 @@ public function __serialize() 's' => $this->_sequence, 'is' => $this->_sorted, 'ids' => $this->_ids, + 'ir' => $this->_intervals, ]; } public function __unserialize(array $data) { - $this->duplicates = $data['d']; - $this->_sequence = $data['s']; - $this->_sorted = $data['is']; - $this->_ids = $data['ids']; + $this->duplicates = !empty($data['d']); + $this->_sequence = !empty($data['s']); + $this->_sorted = !empty($data['is']); + $this->_ids = $data['ids'] ?? []; + $this->_intervals = $data['ir'] ?? null; + $this->_iterState = []; } } diff --git a/src/Auth/SaslAuthenticator.php b/src/Auth/SaslAuthenticator.php index 0ab1de52..42eb4ce1 100644 --- a/src/Auth/SaslAuthenticator.php +++ b/src/Auth/SaslAuthenticator.php @@ -155,7 +155,7 @@ private function negotiate( $binding = $mechanismName->usesChannelBinding() ? $this->binding : null; $mechanism = $this->factory->create($mechanismName, $credentials, $binding); - } catch (UnsupportedMechanismException | PolicyViolationException | DowngradeDetectedException $e) { + } catch (UnsupportedMechanismException|PolicyViolationException|DowngradeDetectedException $e) { $this->dispatch(new AuthenticationFailed($e->getMessage())); throw new AuthenticationException($e->getMessage(), 0, $e); @@ -219,7 +219,7 @@ private function runExchange( $awaitingAdditionalData = true; } } - } catch (MechanismException | AuthenticationFailedException $e) { + } catch (MechanismException|AuthenticationFailedException $e) { $channel->cancel(); $this->dispatch(new AuthenticationFailed($e->getMessage())); diff --git a/src/ImapClient.php b/src/ImapClient.php index 4287d105..80211a46 100644 --- a/src/ImapClient.php +++ b/src/ImapClient.php @@ -43,7 +43,7 @@ /** * A modern IMAP client (RFC 3501/RFC 9051) built on `Horde\Socket\Client` and `Horde\Sasl` * - * Reuses {@see Auth\ImapAuthChannel} for SASL `AUTHENTICATE` + * Reuses {@see ImapAuthChannel} for SASL `AUTHENTICATE` * and {@see ImapCapabilityNegotiator} for `CAPABILITY`/ `ENABLE`. * `LOGIN` is the one native fallback this class handles directly for * servers that only advertise SASL mechanisms this library's policy @@ -209,7 +209,7 @@ public function logout(): void try { $this->interaction->send('LOGOUT'); - } catch (ImapProtocolException | ServerResponseException) { + } catch (ImapProtocolException|ServerResponseException) { // The server is going away regardless; nothing more to do. } finally { $this->cache?->flush(); @@ -1634,7 +1634,7 @@ public function listACLRights(string $mailbox, string $identifier): ImapAclRight // data: LISTRIGHTS mailbox identifier required optional... $rights = array_values(array_filter( array_slice($response->data, 3), - static fn ($value): bool => is_string($value), + static fn($value): bool => is_string($value), )); $required = $rights === [] ? '' : array_shift($rights); @@ -2431,7 +2431,7 @@ private function buildExtendedListCommand(string $pattern, MailboxListMode $mode if ($statusItems !== []) { $returnOptions->add(new ImapWireAtom('STATUS')); $returnOptions->add(new ImapWireList(array_map( - static fn (string $item): ImapWireEncodable => new ImapWireAtom($item), + static fn(string $item): ImapWireEncodable => new ImapWireAtom($item), $statusItems, ))); } @@ -2546,7 +2546,7 @@ private function parseListResponse( $rawAttributes = array_values(array_filter( $response->data[1], - static fn ($value): bool => is_string($value), + static fn($value): bool => is_string($value), )); $delimiter = is_string($response->data[2]) ? $response->data[2] : null; @@ -2598,8 +2598,8 @@ private function listEntryFilteredOut(MailboxListMode $mode, array $attributes, $subscribed = isset($attributes['\\subscribed']) || strcasecmp($mailbox, 'INBOX') === 0; return match ($mode) { - MailboxListMode::SubscribedExists => - isset($attributes['\\nonexistent']) || !$subscribed, + MailboxListMode::SubscribedExists + => isset($attributes['\\nonexistent']) || !$subscribed, MailboxListMode::Unsubscribed => $subscribed, default => false, }; @@ -2769,7 +2769,7 @@ private function searchReturnOptions(array $results): array } return array_map( - static fn (string $atom): ImapWireEncodable => new ImapWireAtom($atom), + static fn(string $atom): ImapWireEncodable => new ImapWireAtom($atom), array_keys($atoms), ); } @@ -3367,6 +3367,3 @@ private function hasTryCreate(ServerResponseException $e): bool return $e->responseCode !== null && strtoupper($e->responseCode->name) === 'TRYCREATE'; } } - - - diff --git a/src/ImapConnection.php b/src/ImapConnection.php index f5f45aa1..5e93faa0 100644 --- a/src/ImapConnection.php +++ b/src/ImapConnection.php @@ -16,6 +16,7 @@ use Horde\Imap\Client\Exception\ServerResponseException; use Horde\Socket\Client\ClientInterface; +use Throwable; /** * The IMAP line/literal protocol (RFC 3501 §2.2, §4.3) spoken over a @@ -94,12 +95,12 @@ public function sendCommand(ImapCommand $command, bool $nonSynchronizingLiterals foreach ($segment->stream->chunks() as $chunk) { $this->client->write($chunk); } - } catch (\Throwable $e) { + } catch (Throwable $e) { // The announced literal may be incomplete. Never allow a // subsequent command to be interpreted as its payload. try { $this->client->close(); - } catch (\Throwable) { + } catch (Throwable) { // Preserve the original read/write failure. } throw $e; diff --git a/src/ImapFetchQuery.php b/src/ImapFetchQuery.php index cadc6fd4..5562cac7 100644 --- a/src/ImapFetchQuery.php +++ b/src/ImapFetchQuery.php @@ -14,6 +14,8 @@ namespace Horde\Imap\Client; +use InvalidArgumentException; + /** * A fluent `fetch()` query for {@see ImapClient}. * @@ -189,7 +191,7 @@ public function binaryBodyPart(int|string $id, ?int $start = null, ?int $length $section = $this->binarySection($id); if (($start !== null && $start < 0) || ($length !== null && $length < 1) || ($start !== null && $length === null)) { - throw new \InvalidArgumentException('A BINARY partial requires a nonnegative start and a positive length.'); + throw new InvalidArgumentException('A BINARY partial requires a nonnegative start and a positive length.'); } $this->wantBinary = true; $this->addItem(($peek ? 'BINARY.PEEK[' : 'BINARY[') . $section . ']' . $this->partialSuffix($start, $length)); @@ -214,7 +216,7 @@ private function binarySection(int|string $id): string { $section = (string) $id; if ($section !== '' && !preg_match('/^[1-9][0-9]*(?:\.[1-9][0-9]*)*$/D', $section)) { - throw new \InvalidArgumentException('BINARY requires an empty section or a numeric MIME part id.'); + throw new InvalidArgumentException('BINARY requires an empty section or a numeric MIME part id.'); } return $section; } diff --git a/src/ImapFetchResult.php b/src/ImapFetchResult.php index 38f120b2..d2410426 100644 --- a/src/ImapFetchResult.php +++ b/src/ImapFetchResult.php @@ -34,8 +34,8 @@ * * OO representations reuse the `horde/mime` and `horde/mail` value * objects rather than inventing parallels: {@see getStructure()} returns - * a {@see \Horde\Mime\Part} tree, {@see getHeaders()} a - * {@see \Horde\Mime\Headers\HeaderCollection}, and {@see getEnvelope()} + * a {@see Part} tree, {@see getHeaders()} a + * {@see HeaderCollection}, and {@see getEnvelope()} * an {@see ImapEnvelope} whose addresses are `Horde\Mail\Rfc822` types. * * @author Ralf Lang diff --git a/src/ImapIdSet.php b/src/ImapIdSet.php index 08525fbd..c696b10e 100644 --- a/src/ImapIdSet.php +++ b/src/ImapIdSet.php @@ -14,8 +14,9 @@ namespace Horde\Imap\Client; -use ArrayIterator; +use Generator; use Iterator; +use InvalidArgumentException; /** * A range-aware set of IMAP message identifiers (UIDs or sequence numbers). @@ -28,7 +29,7 @@ * * A set is one of two things: * - * - an explicit list of integer IDs or + * - an explicit list of integer IDs, stored as compact ranges, or * - a single {@see ImapIdSetToken} special set (`1:*`, `*`, or `$`). * * The `$sequence` flag distinguishes message-sequence numbers (positional, @@ -47,11 +48,12 @@ final class ImapIdSet implements MessageIdSet { /** - * Explicit IDs in first-seen order. Empty when this is a special set. + * Explicit IDs as inclusive ranges in first-seen order. Ranges may + * overlap; public list views deduplicate them. * - * @var list + * @var list */ - private readonly array $ids; + private readonly array $ranges; /** * The special token this set represents or null for an explicit list. @@ -71,13 +73,13 @@ public function __construct( ) { if ($ids instanceof ImapIdSetToken) { $this->token = $ids; - $this->ids = []; + $this->ranges = []; return; } $this->token = null; - $this->ids = self::normalize(self::resolve($ids)); + $this->ranges = self::resolveRanges($ids); } /** @@ -121,7 +123,7 @@ public function isSequence(): bool public function isEmpty(): bool { - return $this->token === null && $this->ids === []; + return $this->token === null && $this->ranges === []; } /** @@ -129,7 +131,11 @@ public function isEmpty(): bool */ public function min(): ?int { - return $this->ids === [] ? null : min($this->ids); + if ($this->ranges === []) { + return null; + } + + return min(array_column($this->ranges, 0)); } /** @@ -137,35 +143,63 @@ public function min(): ?int */ public function max(): ?int { - return $this->ids === [] ? null : max($this->ids); + if ($this->ranges === []) { + return null; + } + + return max(array_column($this->ranges, 1)); } /** * Return a new set with $ids added (union). Adding to a special set * replaces the token with the resulting explicit list. * - * @param iterable|string|int $ids + * @param iterable|string|int|self $ids */ - public function add(iterable|string|int $ids): self + public function add(iterable|string|int|self $ids): self { - return new self([...$this->ids, ...self::resolve($ids)], $this->sequence); + $ranges = $this->token === null ? $this->ranges : []; + $ranges = [...$ranges, ...self::resolveRanges($ids)]; + + return new self(self::rangesToSequenceString($ranges), $this->sequence); } /** * Return a new set with $ids removed. Removing from a special set is a * no-op (there is no concrete list to subtract from). * - * @param iterable|string|int $ids + * @param iterable|string|int|self $ids */ - public function remove(iterable|string|int $ids): self + public function remove(iterable|string|int|self $ids): self { if ($this->token !== null) { return $this; } - $drop = self::normalize(self::resolve($ids)); + $drop = self::mergeRanges(self::resolveRanges($ids)); + $remaining = []; + foreach ($this->ranges as [$start, $end]) { + foreach ($drop as [$dropStart, $dropEnd]) { + if ($dropEnd < $start || $dropStart > $end) { + continue; + } + if ($dropStart > $start) { + $remaining[] = [$start, $dropStart - 1]; + } + $start = max($start, $dropEnd + 1); + if ($start > $end) { + break; + } + } + if ($start <= $end) { + $remaining[] = [$start, $end]; + } + } - return new self(array_values(array_diff($this->ids, $drop)), $this->sequence); + return new self( + self::rangesToSequenceString($remaining), + $this->sequence, + ); } /** @@ -173,12 +207,24 @@ public function remove(iterable|string|int $ids): self */ public function toArray(): array { - return $this->ids; + $ids = []; + foreach ($this->ranges as [$start, $end]) { + for ($id = $start; $id <= $end; ++$id) { + $ids[$id] = $id; + } + } + + return array_values($ids); } public function count(): int { - return count($this->ids); + $count = 0; + foreach (self::mergeRanges($this->ranges) as [$start, $end]) { + $count += $end - $start + 1; + } + + return $count; } /** @@ -186,7 +232,13 @@ public function count(): int */ public function getIterator(): Iterator { - return new ArrayIterator($this->ids); + return (static function (array $ranges): Generator { + foreach (self::mergeRanges($ranges) as [$start, $end]) { + for ($id = $start; $id <= $end; ++$id) { + yield $id; + } + } + })($this->ranges); } /** @@ -201,110 +253,131 @@ public function __toString(): string return $this->token->toWire(); } - return self::toSequenceString($this->ids); + $parts = []; + foreach (self::mergeRanges($this->ranges) as [$start, $end]) { + $parts[] = self::rangePart($start, $end); + } + + return implode(',', $parts); } /** * Resolve a constructor/add/remove argument into a flat list of ints. * - * @param iterable|string|int $ids + * @param iterable|string|int|self $ids * * @return list */ - private static function resolve(iterable|string|int $ids): array + private static function resolveRanges(iterable|string|int $ids): array { + if ($ids instanceof self) { + return $ids->ranges; + } + if (is_int($ids)) { - return [$ids]; + return [[$ids, $ids]]; } if (is_string($ids)) { - return self::fromWireList($ids); + return self::parseWireRanges($ids); } $out = []; foreach ($ids as $id) { - $out[] = (int) $id; + $id = (int) $id; + $out[] = [$id, $id]; } return $out; } /** - * Deduplicate while preserving first-seen order. - * - * @param list $ids - * - * @return list - */ - private static function normalize(array $ids): array - { - return array_keys(array_flip($ids)); - } - - /** - * Parse an IMAP sequence string into an int list with ranges expanded. + * Parse an IMAP sequence string into compact inclusive ranges. * - * @return list + * @return list */ - private static function fromWireList(string $str): array + private static function parseWireRanges(string $str): array { $str = trim($str); if ($str === '') { return []; } - $ids = []; - + $ranges = []; foreach (explode(',', $str) as $part) { $range = explode(':', $part, 2); if (isset($range[1])) { + if (!ctype_digit($range[0]) || !ctype_digit($range[1])) { + throw new InvalidArgumentException( + 'IMAP ID ranges must have numeric endpoints.' + ); + } $start = (int) $range[0]; $end = (int) $range[1]; - if ($start > $end) { - [$start, $end] = [$end, $start]; - } - for ($i = $start; $i <= $end; ++$i) { - $ids[] = $i; - } + $ranges[] = ($start <= $end) + ? [$start, $end] + : [$end, $start]; } else { - $ids[] = (int) $part; + if (!ctype_digit($part)) { + throw new InvalidArgumentException( + 'IMAP IDs must be decimal integers.' + ); + } + $id = (int) $part; + $ranges[] = [$id, $id]; } } - return $ids; + return $ranges; } /** - * Compress a sorted, contiguity-collapsed IMAP sequence string. + * Merge overlapping or adjacent ranges into ascending, unique ranges. * - * @param list $ids + * @param list $ranges + * + * @return list */ - private static function toSequenceString(array $ids): string + private static function mergeRanges(array $ranges): array { - if ($ids === []) { - return ''; + if ($ranges === []) { + return []; } - sort($ids, SORT_NUMERIC); - - $out = []; - $start = $prev = $ids[0]; - - foreach (array_slice($ids, 1) as $id) { - if ($id === $prev + 1) { - $prev = $id; - continue; + usort($ranges, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + $merged = [$ranges[0]]; + foreach (array_slice($ranges, 1) as [$start, $end]) { + $last = count($merged) - 1; + if ($start <= $merged[$last][1] + 1) { + $merged[$last][1] = max($merged[$last][1], $end); + } else { + $merged[] = [$start, $end]; } - - $out[] = self::rangePart($start, $prev); - $start = $prev = $id; } - $out[] = self::rangePart($start, $prev); + return $merged; + } - return implode(',', $out); + /** + * Serialize ranges into a compact numeric IMAP sequence string. + * + * @param list $ranges + */ + private static function rangesToSequenceString(array $ranges): string + { + $parts = []; + foreach ($ranges as [$start, $end]) { + $parts[] = self::rangePart($start, $end); + } + + return implode(',', $parts); } + /** + * Compress a sorted, contiguity-collapsed IMAP sequence string. + * + * @param list $ids + */ /** * Render a single range or singleton such as `5` vs `5:9`. */ diff --git a/src/ImapInteraction.php b/src/ImapInteraction.php index 6f864ea9..df3b8156 100644 --- a/src/ImapInteraction.php +++ b/src/ImapInteraction.php @@ -16,6 +16,7 @@ use Horde\Imap\Client\Exception\ImapProtocolException; use Horde\Imap\Client\Exception\ServerResponseException; +use Throwable; /** * Sends one IMAP command and collects its full response cycle. Every @@ -83,7 +84,7 @@ public function run(ImapCommand $command, bool $nonSynchronizingLiterals = false $this->pipeline->enqueue($command); try { $untagged = $this->connection->sendCommand($command, $nonSynchronizingLiterals); - } catch (\Throwable $e) { + } catch (Throwable $e) { // Rejected announcements and failed stream writes complete this // attempt too; don't retain its command and borrowed resources. $this->pipeline->complete($command->tag); diff --git a/src/ImapSearchParser.php b/src/ImapSearchParser.php index d78f62b3..3f30223c 100644 --- a/src/ImapSearchParser.php +++ b/src/ImapSearchParser.php @@ -42,8 +42,7 @@ private function __construct() {} */ public static function parse(array $untagged, bool $sequence): ImapSearchResult { - /** @var list $matches */ - $matches = []; + $match = new ImapIdSet([], $sequence); $count = null; $min = null; $max = null; @@ -61,31 +60,30 @@ public static function parse(array $untagged, bool $sequence): ImapSearchResult // A SORT reply (RFC 5256) shares the classic SEARCH shape: a // space-separated id list, but in the server's sort order. if ($keyword === 'SEARCH' || $keyword === 'SORT') { + $lineMatches = []; foreach (array_slice($response->data, 1) as $token) { if (is_string($token) && ctype_digit($token)) { - $matches[] = (int) $token; + $lineMatches[] = (int) $token; } } + $match = $match->add($lineMatches); continue; } if ($keyword === 'ESEARCH') { $usedEsearch = true; - self::parseEsearch($response->data, $sequence, $matches, $count, $min, $max, $modseq, $relevancy); + self::parseEsearch($response->data, $sequence, $match, $count, $min, $max, $modseq, $relevancy); } } - $match = new ImapIdSet($matches, $sequence); - // Classic SEARCH does not report COUNT/MIN/MAX. Derive them from // the matching set so the caller sees a uniform result regardless // of which wire form the server used. if (!$usedEsearch) { - $ids = $match->toArray(); - $count = count($ids); - $min = $ids === [] ? null : min($ids); - $max = $ids === [] ? null : max($ids); + $count = $match->count(); + $min = $match->min(); + $max = $match->max(); } return new ImapSearchResult( @@ -101,16 +99,15 @@ public static function parse(array $untagged, bool $sequence): ImapSearchResult /** * @param list $data The whole ESEARCH response tokens, * starting with the `ESEARCH` word. - * @param list $matches + * @param ImapIdSet $match * @param list $relevancy * - * @param-out list $matches * @param-out list $relevancy */ private static function parseEsearch( array $data, bool $sequence, - array &$matches, + ImapIdSet &$match, ?int &$count, ?int &$min, ?int &$max, @@ -142,9 +139,9 @@ private static function parseEsearch( switch (strtoupper($name)) { case 'ALL': if (is_string($value)) { - foreach (ImapIdSet::fromSequenceString($value, $sequence)->toArray() as $id) { - $matches[] = $id; - } + $match = $match->add( + ImapIdSet::fromSequenceString($value, $sequence) + ); } break; diff --git a/src/ImapTokenizer.php b/src/ImapTokenizer.php index 745e4c94..38c2c1b8 100644 --- a/src/ImapTokenizer.php +++ b/src/ImapTokenizer.php @@ -16,6 +16,7 @@ use Horde\Imap\Client\Exception\ImapProtocolException; use Horde\Socket\Client\ClientInterface; +use Throwable; /** * Parses one full IMAP response line (RFC 3501 §4) off a @@ -224,10 +225,10 @@ private function resolvePendingLiteral(string $text): ?string } $data .= $chunk; } - } catch (\Throwable $e) { + } catch (Throwable $e) { try { $this->client->close(); - } catch (\Throwable) { + } catch (Throwable) { // Preserve the original read failure. } throw $e; diff --git a/src/Pop3AuthChannel.php b/src/Pop3AuthChannel.php index 7d47055f..ab9accd5 100644 --- a/src/Pop3AuthChannel.php +++ b/src/Pop3AuthChannel.php @@ -24,7 +24,7 @@ * Wire-equivalent to IMAP's `AUTHENTICATE`: `AUTH * [initial-response]`, then `+ ` continuations, then a * final `+OK`/`-ERR`. This is a real consumer of - * {@see \Horde\Imap\Client\Auth\SaslAuthenticator} as opposed to the IMAP-shaped + * {@see Auth\SaslAuthenticator} as opposed to the IMAP-shaped * fake used in tests), proving the channel abstraction isn't IMAP-specific. * * @author Ralf Lang diff --git a/src/Pop3Client.php b/src/Pop3Client.php index b480ce0e..3374caad 100644 --- a/src/Pop3Client.php +++ b/src/Pop3Client.php @@ -29,6 +29,7 @@ use Horde\Socket\Client\Exception\ConnectionException as SocketConnectionException; use Horde\Socket\Client\SecureMode as SocketSecureMode; use Psr\EventDispatcher\EventDispatcherInterface; +use Exception; /** * A modern POP3 client (RFC 1939, RFC 2449, RFC 5034, RFC 6856) built on @@ -278,7 +279,7 @@ public function store(string $mailbox, array $options): MessageIdSet $this->requireInbox($mailbox); $this->connect(); - $flagValue = static fn (SystemFlag|string $flag): string => $flag instanceof SystemFlag + $flagValue = static fn(SystemFlag|string $flag): string => $flag instanceof SystemFlag ? $flag->value : $flag; @@ -667,7 +668,7 @@ private function parseDateHeader(string $header): ?DateTimeImmutable if (stripos($line, 'Date:') === 0) { try { return new DateTimeImmutable(trim(substr($line, 5))); - } catch (\Exception) { + } catch (Exception) { return null; } } @@ -676,4 +677,3 @@ private function parseDateHeader(string $header): ?DateTimeImmutable return null; } } - diff --git a/test/Unit/IdsTest.php b/test/Unit/IdsTest.php index 19e6cd28..aa648ee4 100644 --- a/test/Unit/IdsTest.php +++ b/test/Unit/IdsTest.php @@ -115,6 +115,48 @@ public static function sequenceParsingProvider() ]; } + public function testLargeRangeStaysCompactUntilIterated(): void + { + $memoryBefore = memory_get_usage(); + $ids = new Horde_Imap_Client_Ids('1:1000000'); + + $this->assertLessThan(1024 * 1024, memory_get_usage() - $memoryBefore); + $this->assertSame(1000000, count($ids)); + $this->assertSame('1:1000000', $ids->tostring); + + $ids->rewind(); + $this->assertSame(1, $ids->current()); + $this->assertSame(0, $ids->key()); + $ids->next(); + $this->assertSame(2, $ids->current()); + $this->assertSame(1, $ids->key()); + + $restored = unserialize(serialize($ids)); + $this->assertSame(1000000, count($restored)); + $this->assertSame('1:1000000', $restored->tostring); + } + + public function testWildcardSequenceSetsDoNotExpand(): void + { + $all = new Horde_Imap_Client_Ids('1:*', true); + $this->assertTrue($all->all); + $this->assertSame('1:*', $all->tostring); + + $largest = new Horde_Imap_Client_Ids('*', true); + $this->assertTrue($largest->largest); + $this->assertSame('*', $largest->tostring); + } + + public function testOpenEndedPartialRangeIsPreservedWithoutExpansion(): void + { + $ids = new Horde_Imap_Client_Ids('5:*', true); + + $this->assertTrue($ids->special); + $this->assertSame('5:*', $ids->tostring); + $this->assertSame(0, count($ids)); + $this->assertSame([], $ids->ids); + } + #[DataProvider('rangeGenerationProvider')] public function testRangeGeneration($in, $expected) { @@ -266,6 +308,11 @@ public function testRemove() 70, count($ids) ); + + $ids->remove('1:100'); + $this->assertTrue($ids->isEmpty()); + $this->assertFalse($ids->min); + $this->assertFalse($ids->max); } #[DataProvider('minAndMaxProvider')] diff --git a/test/Unit/SocketBinaryAppendTest.php b/test/Unit/SocketBinaryAppendTest.php index be776935..dfc001ea 100644 --- a/test/Unit/SocketBinaryAppendTest.php +++ b/test/Unit/SocketBinaryAppendTest.php @@ -22,6 +22,7 @@ use Horde_Imap_Client_Tokenize; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; #[CoversNothing] class SocketBinaryAppendTest extends TestCase @@ -107,7 +108,7 @@ public function testNonSeekableBinaryStreamIsRejectedBeforeSending(): void try { $socket->appendMessages([['data' => $stream, 'binary' => true]]); self::fail('Expected non-seekable stream rejection'); - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { self::assertSame([], $socket->commands); } } finally { diff --git a/test/Unit/Src/ArrayCache.php b/test/Unit/Src/ArrayCache.php index e9c1a88e..dad5d3c5 100644 --- a/test/Unit/Src/ArrayCache.php +++ b/test/Unit/Src/ArrayCache.php @@ -15,6 +15,7 @@ namespace Horde\Imap\Client\Test\Unit\Src; use Psr\SimpleCache\CacheInterface; +use DateInterval; /** * A minimal array-backed PSR-16 cache for exercising @@ -30,7 +31,7 @@ public function get(string $key, mixed $default = null): mixed return $this->data[$key] ?? $default; } - public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + public function set(string $key, mixed $value, int|DateInterval|null $ttl = null): bool { $this->data[$key] = $value; @@ -61,7 +62,7 @@ public function getMultiple(iterable $keys, mixed $default = null): iterable return $out; } - public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + public function setMultiple(iterable $values, int|DateInterval|null $ttl = null): bool { foreach ($values as $key => $value) { $this->data[$key] = $value; diff --git a/test/Unit/Src/Auth/FakeServerChannel.php b/test/Unit/Src/Auth/FakeServerChannel.php index 86a21158..45a4fc0d 100644 --- a/test/Unit/Src/Auth/FakeServerChannel.php +++ b/test/Unit/Src/Auth/FakeServerChannel.php @@ -103,7 +103,7 @@ private function advance(string $clientMessage): ChannelEvent { try { $challenge = $this->server->step(Response::bytes($clientMessage)); - } catch (AuthenticationFailedException | MechanismException $e) { + } catch (AuthenticationFailedException|MechanismException $e) { return ChannelEvent::failure(text: $e->getMessage()); } diff --git a/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php b/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php index 9d085b88..1f24a4bf 100644 --- a/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php +++ b/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php @@ -23,6 +23,7 @@ use Horde\Socket\Client\StreamStatus; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Throwable; #[CoversClass(SocketChannelBindingProvider::class)] class SocketChannelBindingProviderTest extends TestCase @@ -30,13 +31,13 @@ class SocketChannelBindingProviderTest extends TestCase private function client( bool $supportsTlsServerEndPoint = true, ?string $bindingData = 'fake-cert-hash', - ?\Throwable $bindingException = null, + ?Throwable $bindingException = null, ): ClientInterface { return new class ($supportsTlsServerEndPoint, $bindingData, $bindingException) implements ClientInterface { public function __construct( private readonly bool $supportsTlsServerEndPoint, private readonly ?string $bindingData, - private readonly ?\Throwable $bindingException, + private readonly ?Throwable $bindingException, ) {} public function isConnected(): bool diff --git a/test/Unit/Src/ImapBinaryFetchTest.php b/test/Unit/Src/ImapBinaryFetchTest.php index 76693222..4e68b110 100644 --- a/test/Unit/Src/ImapBinaryFetchTest.php +++ b/test/Unit/Src/ImapBinaryFetchTest.php @@ -26,6 +26,7 @@ use Horde\Socket\Client\ClientInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; #[CoversClass(ImapFetchQuery::class)] #[CoversClass(ImapFetchParser::class)] @@ -89,7 +90,7 @@ public function testInvalidSectionsCannotInjectFetchItems(): void try { (new ImapFetchQuery())->$method($section); self::fail('Expected invalid section rejection'); - } catch (\InvalidArgumentException) { + } catch (InvalidArgumentException) { self::assertTrue(true); } } @@ -102,7 +103,7 @@ public function testInvalidPartialsAreRejected(): void try { (new ImapFetchQuery())->binaryBodyPart(1, $start, $length); self::fail('Expected invalid partial rejection'); - } catch (\InvalidArgumentException) { + } catch (InvalidArgumentException) { self::assertTrue(true); } } @@ -207,11 +208,16 @@ public function testUnknownCteIsPropagatedWithoutRawFallback(): void public function testBinaryQueriesCannotBeSatisfiedByMetadataCache(): void { - $socket = $this->socket('BINARY', - '* OK [UIDVALIDITY 42] valid', 'A1 OK [READ-WRITE] selected', - '* 1 FETCH (UID 7 FLAGS ())', 'A2 OK done', - '* 1 FETCH (UID 7 FLAGS () BINARY[1] "hello")', 'A3 OK done', - '* 1 FETCH (UID 7 FLAGS () BINARY.SIZE[1] 5)', 'A4 OK done' + $socket = $this->socket( + 'BINARY', + '* OK [UIDVALIDITY 42] valid', + 'A1 OK [READ-WRITE] selected', + '* 1 FETCH (UID 7 FLAGS ())', + 'A2 OK done', + '* 1 FETCH (UID 7 FLAGS () BINARY[1] "hello")', + 'A3 OK done', + '* 1 FETCH (UID 7 FLAGS () BINARY.SIZE[1] 5)', + 'A4 OK done' ); $cache = new ImapCacheStore(new ArrayCache(), 'imap.example.test', 993, 'test'); $client = $this->client($socket, $cache); diff --git a/test/Unit/Src/ImapClientSearchTest.php b/test/Unit/Src/ImapClientSearchTest.php index 7e4ec83f..fb6ef972 100644 --- a/test/Unit/Src/ImapClientSearchTest.php +++ b/test/Unit/Src/ImapClientSearchTest.php @@ -27,6 +27,7 @@ use Horde\Sasl\Negotiation\SaslPolicy; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use stdClass; /** * SEARCH/ESEARCH parsing and {@see ImapClient::search()}. @@ -230,7 +231,7 @@ public function testSearchRejectsNonSearchQuery(): void $this->expectException(ImapProtocolException::class); - $client->search('INBOX', new \stdClass()); + $client->search('INBOX', new stdClass()); } public function testSearchWrapsServerRejection(): void diff --git a/test/Unit/Src/ImapIdSetTest.php b/test/Unit/Src/ImapIdSetTest.php index d406b8a7..b8ec3ac8 100644 --- a/test/Unit/Src/ImapIdSetTest.php +++ b/test/Unit/Src/ImapIdSetTest.php @@ -16,12 +16,17 @@ use Horde\Imap\Client\ImapIdSet; use Horde\Imap\Client\ImapIdSetToken; +use Horde\Imap\Client\ImapResponseParser; +use Horde\Imap\Client\ImapSearchParser; +use Horde\Imap\Client\ImapTokenizer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; #[CoversClass(ImapIdSet::class)] #[CoversClass(ImapIdSetToken::class)] +#[CoversClass(ImapSearchParser::class)] class ImapIdSetTest extends TestCase { public function testEmptySet(): void @@ -134,6 +139,52 @@ public function testConstructFromSequenceString(): void self::assertSame('2,4:6', (string) $ids); } + public function testLargeRangeIsStoredCompactly(): void + { + $memoryBefore = memory_get_usage(); + $ids = ImapIdSet::fromSequenceString('1:5000000'); + + self::assertLessThan(1024 * 1024, memory_get_usage() - $memoryBefore); + self::assertSame(5000000, $ids->count()); + self::assertSame(1, $ids->min()); + self::assertSame(5000000, $ids->max()); + self::assertSame('1:5000000', (string) $ids); + } + + public function testAddingAndRemovingRangesStaysCompact(): void + { + $ids = (new ImapIdSet('1:5'))->add('4:8'); + self::assertSame(8, $ids->count()); + self::assertSame('1:8', (string) $ids); + + $remaining = $ids->remove('3:6'); + self::assertSame(4, $remaining->count()); + self::assertSame('1:2,7:8', (string) $remaining); + } + + public function testLargeEsearchRangeIsNotExpandedWhileParsing(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* ESEARCH UID COUNT 5000000 MIN 1 MAX 5000000 ALL 1:5000000'), + ); + $tokens = (new ImapTokenizer($socket))->readLine(); + $response = ImapResponseParser::parse($tokens); + $memoryBefore = memory_get_usage(); + + $result = ImapSearchParser::parse([$response], false); + + self::assertLessThan(1024 * 1024, memory_get_usage() - $memoryBefore); + self::assertSame(5000000, $result->count); + self::assertSame(5000000, $result->match->count()); + self::assertSame('1:5000000', (string) $result->match); + } + + public function testWildcardRangeDoesNotSilentlyBecomeZero(): void + { + $this->expectException(InvalidArgumentException::class); + ImapIdSet::fromSequenceString('5:*'); + } + public function testConstructFromSingleInt(): void { $ids = new ImapIdSet(42); diff --git a/test/Unit/Src/ImapWireStreamTest.php b/test/Unit/Src/ImapWireStreamTest.php index 82ad9399..7e814026 100644 --- a/test/Unit/Src/ImapWireStreamTest.php +++ b/test/Unit/Src/ImapWireStreamTest.php @@ -21,6 +21,7 @@ use Horde\Socket\Client\ClientInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; #[CoversClass(ImapWireStream::class)] class ImapWireStreamTest extends TestCase @@ -164,7 +165,7 @@ public function testWriteFailureClosesConnectionAndRestoresBorrowedStream(): voi $socket->expects(self::exactly(2))->method('write')->willReturnCallback( static function (string $data) use (&$writes): void { if (++$writes === 2) { - throw new \RuntimeException('Injected write failure'); + throw new RuntimeException('Injected write failure'); } } ); @@ -172,7 +173,7 @@ static function (string $data) use (&$writes): void { try { (new ImapConnection($socket))->sendCommand(new ImapCommand('A1', 'APPEND', ['INBOX', $literal])); self::fail('Expected write error'); - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { self::assertSame('Injected write failure', $e->getMessage()); self::assertSame(4, ftell($stream)); self::assertIsResource($stream); diff --git a/test/Unit/Src/InMemoryPop3Socket.php b/test/Unit/Src/InMemoryPop3Socket.php index 3bef32a4..5b8cc7b3 100644 --- a/test/Unit/Src/InMemoryPop3Socket.php +++ b/test/Unit/Src/InMemoryPop3Socket.php @@ -7,6 +7,7 @@ use Horde\Socket\Client\ChannelBinding\ChannelBindingType; use Horde\Socket\Client\ClientInterface; use Horde\Socket\Client\StreamStatus; +use RuntimeException; /** * An in-memory {@see ClientInterface} fed with a fixed queue of lines to @@ -59,9 +60,7 @@ public function startTls(): bool return true; } - public function close(): void - { - } + public function close(): void {} public function getStatus(): StreamStatus { @@ -71,7 +70,7 @@ public function getStatus(): StreamStatus public function gets(int $size): string { if ($this->queue === []) { - throw new \RuntimeException('InMemoryPop3Socket: read past the end of the scripted queue.'); + throw new RuntimeException('InMemoryPop3Socket: read past the end of the scripted queue.'); } return array_shift($this->queue) . "\r\n"; diff --git a/test/Unit/Src/Pop3ClientTest.php b/test/Unit/Src/Pop3ClientTest.php index d7ea5113..b755ecc9 100644 --- a/test/Unit/Src/Pop3ClientTest.php +++ b/test/Unit/Src/Pop3ClientTest.php @@ -29,6 +29,7 @@ use Horde\Sasl\Negotiation\SaslPolicy; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use stdClass; #[CoversClass(Pop3Client::class)] class Pop3ClientTest extends TestCase @@ -98,7 +99,7 @@ public function testGetCapabilityIsCachedAfterFirstCall(): void $client->getCapability(); $client->getCapability(); - self::assertSame(1, count(array_filter($socket->written, static fn (string $line): bool => $line === 'CAPA'))); + self::assertSame(1, count(array_filter($socket->written, static fn(string $line): bool => $line === 'CAPA'))); } public function testLoginViaSaslPlain(): void @@ -358,7 +359,7 @@ public function testFetchRejectsNonPop3FetchQuery(): void $this->expectException(Pop3ProtocolException::class); - iterator_to_array($client->fetch('INBOX', new Pop3IdSet([1], true), new \stdClass())); + iterator_to_array($client->fetch('INBOX', new Pop3IdSet([1], true), new stdClass())); } public function testFetchRejectsMailboxesOtherThanInbox(): void