diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml index 2513f37..15b584e 100644 --- a/.github/workflows/run-integration-tests.yml +++ b/.github/workflows/run-integration-tests.yml @@ -12,13 +12,13 @@ jobs: run-tests: runs-on: ${{ matrix.os }} - services: - greenmail: - image: greenmail/standalone:latest - env: - GREENMAIL_OPTS: -Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose - ports: - - 3143:3143 + env: + MAILBOX_HOST: 127.0.0.1 + MAILBOX_PORT: ${{ matrix.port }} + MAILBOX_SERVER: ${{ matrix.server }} + MAILBOX_USERNAME: user@localhost.com + MAILBOX_PASSWORD: password + MAILBOX_ENCRYPTION: "" strategy: fail-fast: false @@ -26,13 +26,24 @@ jobs: os: [ ubuntu-latest ] php: [ 8.5, 8.4, 8.3, 8.2 ] dependency-version: [ prefer-stable ] + server: [ greenmail ] + port: [ 3143 ] + include: + - os: ubuntu-latest + php: 8.4 + dependency-version: prefer-stable + server: dovecot + port: 4143 - name: ${{ matrix.os }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} + name: ${{ matrix.server }} - P${{ matrix.php }} - ${{ matrix.dependency-version }} steps: - name: Checkout code uses: actions/checkout@v4 + - name: Start IMAP server + run: docker compose up -d ${{ matrix.server }} + - name: Cache dependencies uses: actions/cache@v4 with: @@ -48,5 +59,16 @@ jobs: run: | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + - name: Wait for IMAP server + run: php tests/wait-for-mailbox.php + - name: Execute tests run: vendor/bin/pest --testsuite Integration + + - name: Show IMAP server logs + if: failure() + run: docker compose logs --no-color ${{ matrix.server }} + + - name: Stop IMAP server + if: always() + run: docker compose down --volumes diff --git a/composer.json b/composer.json index cd7ba7e..30cd4e9 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", + "php-64bit": "*", "symfony/mime": ">=6.0", "nesbot/carbon": ">=2.0", "illuminate/collections": ">=9.0", diff --git a/docker-compose.yml b/docker-compose.yml index 630f270..138889d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,16 @@ services: greenmail: - image: greenmail/standalone:latest + image: greenmail/standalone:2.1.14 environment: - GREENMAIL_OPTS=-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose ports: - - "3143:3143" + - "127.0.0.1:3143:3143" + + dovecot: + image: dovecot/dovecot:2.4.3 + environment: + USER_PASSWORD: password + volumes: + - ./tests/docker/dovecot.conf:/etc/dovecot/conf.d/99-test.conf:ro + ports: + - "127.0.0.1:4143:31143" diff --git a/readme.md b/readme.md index 3ce8e46..84a621c 100755 --- a/readme.md +++ b/readme.md @@ -16,3 +16,61 @@

View Documentation

+ +### Partial body fetching + +Fetch a byte range of a message section without downloading the whole section: + +```php +$messages = $folder->messages() + ->with(MessageData::text()->partial(0, 4096)->peek()) + ->get(); + +$preview = $messages->first()->data()->get('BODY[TEXT]<0>'); + +$chunk = $message->bodyPart('2', offset: 65536, length: 65536); +``` + +Offsets are zero-based and refer to transfer-encoded bytes. The server may return +fewer bytes at the end of a section, or an empty string beyond its end. A missing +message or section returns `null`. Partial data does not replace the complete body. + +Attachments obtained through `attachments(fetch: true)` download in 64 KiB chunks +as their streams are read. Base64 and quoted-printable decoding continues across +chunk boundaries. Decoded content is cached in a temporary stream that spills to +disk beyond 2 MiB, allowing seeks and repeated reads without fetching again. + +```php +$attachment = $message->attachments(fetch: true)[0]; +$attachment->save('/path/to/attachment.pdf'); +``` + +`save()` copies stream chunks directly to the destination. Calling `contents()` or +casting a stream to a string still allocates the requested contents in memory. +The decoded stream size is unknown (`null`) until the section has been fully read. + +### Iterating over synchronization results + +`FetchResult::messages()` and `StoreResult::messages()` return collections of +fetched message data. `FetchResult::vanished()` returns a `VanishedCollection`. +Result constructors accept collections rather than arrays. + +`uids()`, `vanishedUids()`, and `modified()` return lazy collections. Iterate over +them directly to process identifiers without first building the complete arrays: + +```php +foreach ($changes->vanished() as $vanished) { + foreach ($vanished->uids() as $uid) { + // Remove the message from your local mailbox. + } +} + +foreach ($store->modified() as $uid) { + // Reconcile a message whose conditional flag update was rejected. +} +``` + +Call `->all()` when you need an array; this allocates memory for every returned +identifier. `vanishedUids()` combines groups and tracks previously yielded UIDs +to remove duplicates. Iterate over each group’s `uids()` for bounded-memory +processing of large ranges. diff --git a/src/AppendResult.php b/src/AppendResult.php new file mode 100644 index 0000000..8b85b68 --- /dev/null +++ b/src/AppendResult.php @@ -0,0 +1,60 @@ +tokenAt(2); + + if (! $data instanceof ResponseCodeData) { + return new static; + } + + $code = $data->first(); + + if (! $code instanceof Token || ! $code->is('APPENDUID')) { + return new static; + } + + return new static( + uidValidity: (int) $data->tokenAt(1)->value, + uid: (int) $data->tokenAt(2)->value, + ); + } + + /** + * Get the mailbox UID validity value. + */ + public function uidValidity(): ?int + { + return $this->uidValidity; + } + + /** + * Get the appended message UID. + */ + public function uid(): ?int + { + return $this->uid; + } +} diff --git a/src/Attachment.php b/src/Attachment.php index ea9952c..eda631f 100644 --- a/src/Attachment.php +++ b/src/Attachment.php @@ -2,6 +2,7 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Support\LazyBodyPartStream; use GuzzleHttp\Psr7\Utils; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; @@ -63,7 +64,7 @@ public static function lazy(Message $message): array $part->id(), $part->contentType(), $part->disposition()?->type()?->value, - new Support\LazyBodyPartStream($message, $part), + new LazyBodyPartStream($message, $part), ); } @@ -137,7 +138,23 @@ public function contentStream(): StreamInterface */ public function save(string $path): false|int { - return file_put_contents($path, $this->contents()); + if ($this->contentStream->isSeekable()) { + $this->contentStream->rewind(); + } + + if (! $resource = fopen($path, 'wb')) { + return false; + } + + $destination = Utils::streamFor($resource); + + try { + Utils::copyToStream($this->contentStream, $destination); + + return $destination->tell(); + } finally { + $destination->close(); + } } /** diff --git a/src/Authentication.php b/src/Authentication.php new file mode 100644 index 0000000..7243a08 --- /dev/null +++ b/src/Authentication.php @@ -0,0 +1,54 @@ +authenticator->initial(); + + $sent = $initial && $response !== null; + + $exchange = $this->connection->authenticate( + $this->authenticator->mechanism(), + $sent ? $response : null, + ); + + while ($exchange->valid()) { + $challenge = $exchange->current(); + + try { + if (! $sent && $response !== null) { + $answer = $response; + $sent = true; + } else { + $answer = $this->authenticator->respond($challenge); + } + } catch (Throwable $e) { + $this->connection->disconnect(); + + throw $e; + } + + $exchange->send($answer); + } + + return $exchange->getReturn(); + } +} diff --git a/src/Authentication/XOAuth2.php b/src/Authentication/XOAuth2.php new file mode 100644 index 0000000..cbdda8a --- /dev/null +++ b/src/Authentication/XOAuth2.php @@ -0,0 +1,40 @@ +user\1auth=Bearer $this->token\1\1"; + } + + /** + * {@inheritDoc} + */ + public function respond(string $challenge): string + { + return ''; + } +} diff --git a/src/AuthenticatorInterface.php b/src/AuthenticatorInterface.php new file mode 100644 index 0000000..7f68aa9 --- /dev/null +++ b/src/AuthenticatorInterface.php @@ -0,0 +1,21 @@ + + */ + protected array $items; + + /** + * Constructor. + * + * @param array $items + */ + protected function __construct(array $items) + { + $this->items = $items; + } + + /** + * Create a capability collection from the given items. + */ + public static function from(Capability ...$capabilities): static + { + $items = []; + + foreach ($capabilities as $capability) { + $items[$capability->name()] = $capability; + } + + return new static($items); + } + + /** + * Get the capability items. + * + * @return array + */ + public function items(): array + { + return $this->items; + } + + /** + * Get all supported capabilities. + */ + public function all(): array + { + return array_keys($this->items); + } + + /** + * Determine if the exact capability exists. + */ + public function has(string $capability): bool + { + return isset($this->items[strtoupper($capability)]); + } + + /** + * Determine if the capability is supported. + */ + public function supports(string $capability): bool + { + return (bool) $this->find($capability); + } + + /** + * Determine if the capability is enabled. + */ + public function enabled(string $capability): bool + { + return ($this->items[strtoupper($capability)] ?? null)?->enabled() ?? false; + } + + /** + * Find a supported capability. + */ + protected function find(string $capability): ?Capability + { + foreach ($this->items as $item) { + if ($item->matches($capability)) { + return $item; + } + } + + return null; + } +} diff --git a/src/Capability.php b/src/Capability.php new file mode 100644 index 0000000..60b05d0 --- /dev/null +++ b/src/Capability.php @@ -0,0 +1,49 @@ +name; + } + + /** + * Determine if the capability matches the given name. + */ + public function matches(string $capability): bool + { + $capability = strtoupper($capability); + + return $this->name === $capability + || str_starts_with($this->name, "{$capability}="); + } + + /** + * Determine if the capability is enabled. + */ + public function enabled(): bool + { + return $this->enabled; + } +} diff --git a/src/Collections/FetchedResponseCollection.php b/src/Collections/FetchedResponseCollection.php new file mode 100644 index 0000000..6ac7337 --- /dev/null +++ b/src/Collections/FetchedResponseCollection.php @@ -0,0 +1,66 @@ + + */ +class FetchedResponseCollection extends Collection +{ + /** + * Filter the responses to those belonging to the given message set. + */ + public function forMessageSet(string $set, ImapIdentifier $identifier): static + { + $matcher = new MessageSetMatcher($set); + + return $this->filter(function (FetchedResponse $fetch) use ($matcher, $identifier) { + if ($identifier === ImapIdentifier::Uid && ! $fetch->data()->has('UID')) { + return false; + } + + $number = $identifier === ImapIdentifier::Uid + ? $fetch->data()->uid() + : $fetch->sequenceNumber(); + + return $matcher->contains($number); + }); + } + + /** + * Filter the responses to those containing all requested data items. + */ + public function withItems(array $items): static + { + return $this->filter(function (FetchedResponse $fetch) use ($items) { + foreach ($items as $item) { + $key = str_replace(['BODY.PEEK[', 'BINARY.PEEK['], ['BODY[', 'BINARY['], strtoupper($item)); + $key = preg_replace('/<(\d+)\.\d+>$/', '<$1>', $key); + + if (! $fetch->data()->has($key)) { + return false; + } + } + + return true; + }); + } + + /** + * Get the fetched message data. + * + * @return Collection + */ + public function messages(): Collection + { + return $this->toBase()->map( + fn (FetchedResponse $fetch) => $fetch->data() + )->values(); + } +} diff --git a/src/Collections/FolderCollection.php b/src/Collections/FolderCollection.php index 650bd9e..732bff2 100644 --- a/src/Collections/FolderCollection.php +++ b/src/Collections/FolderCollection.php @@ -2,10 +2,28 @@ namespace DirectoryTree\ImapEngine\Collections; +use DirectoryTree\ImapEngine\Enums\ImapSpecialUse; use DirectoryTree\ImapEngine\FolderInterface; use Illuminate\Support\Collection; /** * @template-extends Collection */ -class FolderCollection extends Collection {} +class FolderCollection extends Collection +{ + /** + * Find a folder by its special use. + */ + public function findBySpecialUse(ImapSpecialUse $specialUse): ?FolderInterface + { + return $this->first(function (FolderInterface $folder) use ($specialUse) { + foreach ($folder->attributes() as $attribute) { + if (strcasecmp($attribute, $specialUse->value) === 0) { + return true; + } + } + + return false; + }); + } +} diff --git a/src/Collections/ResponseCollection.php b/src/Collections/ResponseCollection.php index 7f66400..794cb7e 100644 --- a/src/Collections/ResponseCollection.php +++ b/src/Collections/ResponseCollection.php @@ -5,6 +5,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; +use DirectoryTree\ImapEngine\Connection\Tokens\Token; use Illuminate\Support\Collection; /** @@ -45,4 +46,30 @@ public function continuation(): self { return $this->whereInstanceOf(ContinuationResponse::class); } + + /** + * Filter the collection to only untagged FETCH responses. + * + * @return self + */ + public function fetches(): self + { + return $this->untagged()->filter(function (UntaggedResponse $response) { + $type = $response->tokenAt(2); + + return $type instanceof Token && $type->is('FETCH'); + }); + } + + /** + * Filter the collection to only untagged VANISHED responses. + * + * @return self + */ + public function vanished(): self + { + return $this->untagged()->filter( + fn (UntaggedResponse $response) => $response->type()->is('VANISHED') + ); + } } diff --git a/src/Collections/VanishedCollection.php b/src/Collections/VanishedCollection.php new file mode 100644 index 0000000..89250a1 --- /dev/null +++ b/src/Collections/VanishedCollection.php @@ -0,0 +1,30 @@ + + */ +class VanishedCollection extends Collection +{ + /** + * Filter vanished UIDs to those belonging to the given message set. + */ + public function forMessageSet(string $set): static + { + $matcher = new MessageSetMatcher($set); + + return $this->map(function (Vanished $vanished) use ($matcher) { + return new Vanished( + $vanished->uids()->filter($matcher->contains(...))->values(), + $vanished->earlier(), + ); + })->reject( + fn (Vanished $vanished) => $vanished->uids()->isEmpty() + )->values(); + } +} diff --git a/src/Connection/CommandArgument.php b/src/Connection/CommandArgument.php new file mode 100644 index 0000000..5d9a233 --- /dev/null +++ b/src/Connection/CommandArgument.php @@ -0,0 +1,149 @@ + is_array($atom) + ? static::atoms($atom) + : static::atom($atom), + $atoms, + ); + } + + /** + * Make an IMAP flag. + */ + public static function flag(string $flag): string + { + return str_starts_with($flag, '\\') + ? '\\'.static::atom(substr($flag, 1)) + : static::atom($flag); + } + + /** + * Make a SASL mechanism name. + */ + public static function mechanism(string $mechanism): string + { + if (! preg_match('/\A[A-Z0-9_-]{1,20}\z/', $mechanism)) { + throw new InvalidArgumentException('Invalid SASL mechanism.'); + } + + return $mechanism; + } + + /** + * Make a parenthesized list of strings or NIL values, preserving literal boundaries. + * + * @param array $values + */ + public static function literalList(array $values): array + { + if (! $values) { + return ['()']; + } + + $tokens = array_map( + fn (?string $value) => is_null($value) ? 'NIL' : static::literal($value), + array_values($values) + ); + + $last = count($tokens) - 1; + + if (is_array($tokens[0])) { + $tokens[0][0] = '('.$tokens[0][0]; + } else { + $tokens[0] = '('.$tokens[0]; + } + + if (is_array($tokens[$last])) { + $tokens[$last][1] .= ')'; + } else { + $tokens[$last] .= ')'; + } + + return $tokens; + } +} diff --git a/src/Connection/CommandPart.php b/src/Connection/CommandPart.php new file mode 100644 index 0000000..7ec4b5d --- /dev/null +++ b/src/Connection/CommandPart.php @@ -0,0 +1,36 @@ +value = $value; + } + + /** + * Get the command part value. + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index f04ce09..db4dd00 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -2,11 +2,23 @@ namespace DirectoryTree\ImapEngine\Connection; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\ResponseCollection; +use DirectoryTree\ImapEngine\Connection\Responses\Data\Data; +use DirectoryTree\ImapEngine\Connection\Responses\Response; 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\Connection\Streams\StreamInterface; +use DirectoryTree\ImapEngine\Connection\Tokens\Token; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; +use DirectoryTree\ImapEngine\Fetch\ModifierInterface as FetchModifierInterface; +use DirectoryTree\ImapEngine\FetchResult; +use DirectoryTree\ImapEngine\ImapSort; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result as SelectionResult; +use DirectoryTree\ImapEngine\Store\ModifierInterface as StoreModifierInterface; +use DirectoryTree\ImapEngine\StoreResult; use Generator; interface ConnectionInterface @@ -28,6 +40,25 @@ public function disconnect(): void; */ public function connected(): bool; + /** + * Read and parse the next reply from the stream. + */ + public function read(): Data|Token|Response|null; + + /** + * Write a protocol line with CRLF, optionally redacting it from logs. + * + * This bypasses command tagging and the active IDLE guard. + */ + public function write(string $data, bool $sensitive = false): void; + + /** + * Get the underlying stream for low-level transport access. + * + * Direct IO bypasses parsing, logging, and connection state management. + */ + public function stream(): StreamInterface; + /** * Send a "LOGIN" command. * @@ -49,11 +80,16 @@ public function logout(): void; /** * Send an "AUTHENTICATE" command. * - * Authenticate the current session. + * Authenticate using a SASL mechanism. Initial data requires SASL-IR support. + * Yields decoded challenges and accepts replies through Generator::send(). + * Sending null cancels the exchange. * + * @return Generator + * + * @see https://datatracker.ietf.org/doc/html/rfc4959 * @see https://datatracker.ietf.org/doc/html/rfc9051#name-authenticate-command */ - public function authenticate(string $user, string $token): TaggedResponse; + public function authenticate(string $mechanism, ?string $initial = null): Generator; /** * Send a "STARTTLS" command. @@ -65,34 +101,34 @@ public function authenticate(string $user, string $token): TaggedResponse; public function startTls(): void; /** - * Send an "IDLE" command. + * Send an "IDLE" command and return the session managing its responses. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-idle-command */ - public function idle(int $timeout): Generator; + public function idle(): IdleSession; /** - * Send a "DONE" command. + * Send a "NOOP" command. * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13 + * @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command */ - public function done(): void; + public function noop(): TaggedResponse; /** - * Send a "NOOP" command. + * Send an "ENABLE" command. * - * @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command + * @see https://datatracker.ietf.org/doc/html/rfc5161 */ - public function noop(): TaggedResponse; + public function enable(string ...$capabilities): ResponseCollection; /** - * Send a "EXPUNGE" command. + * Send an "EXPUNGE" or "UID EXPUNGE" command. * - * Apply session saved changes to the server. + * Permanently remove deleted messages, optionally restricted to the given UIDs. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command */ - public function expunge(): ResponseCollection; + public function expunge(array|int|string|null $uids = null): ResponseCollection; /** * Send a "CAPABILITY" command. @@ -106,101 +142,43 @@ public function capability(): UntaggedResponse; /** * Send a "SEARCH" command. * - * Execute a search request. + * Execute a search request, returning UIDs by default. + * The charset is omitted by default and must remain omitted after enabling UTF8=ACCEPT. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command */ - public function search(array $params): UntaggedResponse; + public function search(array $criteria, ?string $charset = null, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse; /** * Send a "SORT" command. * - * Execute a sort request using RFC 5256. + * Execute a sort request using RFC 5256, returning UIDs by default. * * @see https://datatracker.ietf.org/doc/html/rfc5256 */ - public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse; + public function sort(ImapSort $sort, array $criteria, string $charset = 'UTF-8', ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse; /** - * Send a "FETCH" command. + * Send an "ID" command. * * Exchange identification information. * - * @see https://datatracker.ietf.org/doc/html/rfc2971. - */ - public function id(?array $ids = null): UntaggedResponse; - - /** - * Send a "FETCH UID" command. - * - * Fetch message UIDs using the given message numbers. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command - */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection; - - /** - * Send a "FETCH BODY[TEXT]" command. + * @param array|null $parameters * - * Fetch message text contents. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 - */ - public function bodyText(int|array $ids, bool $peek = true): ResponseCollection; - - /** - * Send a "FETCH BODY[HEADER]" command. - * - * Fetch message headers. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 - */ - public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection; - - /** - * Send a "FETCH BODYSTRUCTURE" command. - * - * Fetch message body structure. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 - */ - public function bodyStructure(int|array $ids): ResponseCollection; - - /** - * Send a "FETCH BODY[i]" command. - * - * Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 - */ - public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection; - - /** - * Send a "FETCH FLAGS" command. - * - * Fetch a message flags. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17 + * @see https://datatracker.ietf.org/doc/html/rfc2971. */ - public function flags(int|array $ids): ResponseCollection; + public function id(?array $parameters = null): UntaggedResponse; /** * Send a "FETCH" command. * - * Fetch one or more items for one or more messages. + * Fetch one or more items or an ALL, FAST, or FULL macro. + * Message sets accept an ID, an array of IDs, or a sequence string such as '1:*'. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-fetch-command + * @see https://datatracker.ietf.org/doc/html/rfc7162#section-3.1.4 */ - public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection; - - /** - * Send a "RFC822.SIZE" command. - * - * Fetch message sizes for one or more messages. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21 - */ - public function size(int|array $ids): ResponseCollection; + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifierInterface ...$modifiers): FetchResult; /** * Send an IMAP command. @@ -214,7 +192,7 @@ public function send(string $name, array $tokens = [], ?string &$tag = null): vo * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-select-command */ - public function select(string $folder): ResponseCollection; + public function select(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult; /** * Send a "EXAMINE" command. @@ -223,16 +201,19 @@ public function select(string $folder): ResponseCollection; * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command */ - public function examine(string $folder): ResponseCollection; + public function examine(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult; /** * Send a "LIST" command. * - * Get a list of available folders. + * Get folders and any additional responses requested by return options. + * Selection options and multiple patterns require LIST-EXTENDED support. * + * @see https://datatracker.ietf.org/doc/html/rfc5258 + * @see https://datatracker.ietf.org/doc/html/rfc5819 * @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command */ - public function list(string $reference = '', string $folder = '*'): ResponseCollection; + public function list(string $reference = '', array|string $pattern = '*', array $selection = [], array $return = []): ResponseCollection; /** * Send a "STATUS" command. @@ -241,16 +222,17 @@ public function list(string $reference = '', string $folder = '*'): ResponseColl * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-status-command */ - public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse; + public function status(string $folder = 'INBOX', array $items = ['MESSAGES', 'UNSEEN', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse; /** * Send a "STORE" command. * - * Set message flags. + * Add, remove, or replace message flags using '+', '-', or null as the mode. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-store-command + * @see https://datatracker.ietf.org/doc/html/rfc7162#section-3.1.3 */ - public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection; + public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifierInterface ...$modifiers): StoreResult; /** * Send a "APPEND" command. @@ -259,25 +241,25 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command */ - public function append(string $folder, string $message, ?array $flags = null): TaggedResponse; + public function append(string $folder, string $message, ?array $flags = null, ?DateTimeInterface $date = null): AppendResult; /** - * Send a "UID COPY" command. + * Send a "COPY" or "UID COPY" command. * * Copy message set from current folder to other folder. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command */ - public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse; + public function copy(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; /** - * Send a "UID MOVE" command. + * Send a "MOVE" or "UID MOVE" command. * * Move a message set from current folder to another folder. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command */ - public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse; + public function move(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; /** * Send a "CREATE" command. @@ -331,7 +313,7 @@ public function unsubscribe(string $folder): TaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquota */ - public function quota(string $root): UntaggedResponse; + public function getQuota(string $root): UntaggedResponse; /** * Send a "GETQUOTAROOT" command. @@ -340,5 +322,5 @@ public function quota(string $root): UntaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquotaroot */ - public function quotaRoot(string $mailbox): ResponseCollection; + public function getQuotaRoot(string $mailbox): ResponseCollection; } diff --git a/src/Connection/IdleSession.php b/src/Connection/IdleSession.php new file mode 100644 index 0000000..6918943 --- /dev/null +++ b/src/Connection/IdleSession.php @@ -0,0 +1,205 @@ +active; + } + + /** + * Invalidate the session when its connection is closed. + */ + public function invalidate(): void + { + $this->active = false; + $this->pending = []; + } + + /** + * Yield unsolicited updates until completion or the renewal deadline. + * + * Finish the session or disconnect before sending another command. + * + * @param int $timeout The renewal interval in seconds. + * @return Generator + */ + public function responses(int $timeout = 300): Generator + { + if ($this->started) { + throw new LogicException('IDLE responses may only be consumed once.'); + } + + $this->started = true; + $this->deadline = Carbon::now()->addSeconds(max($timeout, 1)); + + while ($this->active) { + if ($this->ready) { + if (Carbon::now()->greaterThanOrEqualTo($this->deadline)) { + return; + } + + if ($this->pending) { + yield array_shift($this->pending); + + continue; + } + } + + $response = $this->receive(); + + if ($response instanceof UntaggedResponse) { + $this->pending[] = $response; + } + } + } + + /** + * Finish IDLE and return updates not yet delivered to the caller. + * + * @param int $timeout The time allowed to complete the exchange, in seconds. + */ + public function finish(int $timeout = 30): ResponseCollection + { + if (! $this->active) { + return new ResponseCollection; + } + + // Completion has its own deadline, separate from the renewal interval. + $this->deadline = Carbon::now()->addSeconds($timeout); + + try { + while ($this->active && ! $this->ready) { + if (($response = $this->receive()) instanceof UntaggedResponse) { + $this->pending[] = $response; + } + } + + if ($this->active) { + $this->connection->write('DONE'); + } + + $responses = new ResponseCollection($this->pending); + + $this->pending = []; + + while ($this->active) { + if (($response = $this->receive()) instanceof UntaggedResponse) { + $responses->push($response); + } + } + + return $responses; + } catch (ImapConnectionTimedOutException $e) { + // A missing acknowledgement leaves the connection unusable. + $this->connection->disconnect(); + + throw $e; + } + } + + /** + * Read a response and advance the IDLE protocol state. + */ + protected function receive(): Response + { + $remaining = (int) ceil(Carbon::now()->diffInSeconds($this->deadline, false)); + + $this->connection->stream()->setTimeout(max(1, $remaining)); + + try { + if ($remaining <= 0) { + throw new ImapConnectionTimedOutException('IDLE session timed out'); + } + + $response = $this->connection->read(); + } catch (ImapConnectionTimedOutException $e) { + if (! $this->ready) { + $this->connection->disconnect(); + + throw new ImapConnectionClosedException('Timed out waiting for the IDLE continuation', previous: $e); + } + + throw $e; + } catch (ImapConnectionClosedException $e) { + $this->connection->disconnect(); + + throw $e; + } + + if ($response instanceof ContinuationResponse) { + $this->ready = true; + } elseif ($response instanceof TaggedResponse && $response->tag()->value === $this->command->tag()) { + $this->active = false; + + if (! $response->successful()) { + throw ImapCommandException::make($this->command, $response); + } + } elseif ($response instanceof UntaggedResponse && $response->type()->is('BYE')) { + $this->connection->disconnect(); + + throw new ImapConnectionClosedException((string) $response); + } + + return $response; + } +} diff --git a/src/Connection/ImapCommand.php b/src/Connection/ImapCommand.php index 1ae85e7..0d1afeb 100644 --- a/src/Connection/ImapCommand.php +++ b/src/Connection/ImapCommand.php @@ -2,6 +2,7 @@ namespace DirectoryTree\ImapEngine\Connection; +use InvalidArgumentException; use Stringable; class ImapCommand implements Stringable @@ -59,15 +60,25 @@ public function compile(): array $lines = []; - $line = trim("{$this->tag} {$this->command}"); + $line = trim(new CommandPart($this->tag).' '.new CommandPart($this->command)); foreach ($this->tokens as $token) { if (is_array($token)) { // For tokens provided as arrays, the first element is a placeholder // (for example, "{20}") that signals a literal value will follow. // The second element holds the actual literal content. + if (count($token) !== 2 || ! is_string($token[0]) || ! is_string($token[1])) { + throw new InvalidArgumentException('Invalid IMAP literal token.'); + } + [$length, $literal] = $token; + $length = (string) new CommandPart($length); + + if (! preg_match('/\A\(?\{\d+\+?\}\z/', $length)) { + throw new InvalidArgumentException('Invalid IMAP literal marker.'); + } + $lines[] = new ImapCommandLine( value: "{$line} {$length}", synchronizing: ! str_contains($length, '+'), @@ -75,7 +86,9 @@ public function compile(): array $line = $literal; } else { - $line .= " {$token}"; + $part = (string) new CommandPart($token); + + $line .= (str_starts_with($part, ')') ? '' : ' ').$part; } } @@ -92,7 +105,7 @@ public function redacted(): ImapCommand return new static($this->tag, $this->command, array_map( function (mixed $token) { return is_array($token) - ? array_map(fn () => '[redacted]', $token) + ? [$token[0], '[redacted]'] : '[redacted]'; }, $this->tokens) ); diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index fed3fd3..fcd0d7d 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -2,28 +2,38 @@ namespace DirectoryTree\ImapEngine\Connection; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; use DirectoryTree\ImapEngine\Connection\Responses\Data\Data; -use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Responses\Response; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; 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\Enums\ImapIdentifier; 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\Support\Str; +use DirectoryTree\ImapEngine\Fetch\ModifierInterface as FetchModifierInterface; +use DirectoryTree\ImapEngine\FetchedResponse; +use DirectoryTree\ImapEngine\FetchResult; +use DirectoryTree\ImapEngine\ImapSort; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result as SelectionResult; +use DirectoryTree\ImapEngine\Store\ModifierInterface as StoreModifierInterface; +use DirectoryTree\ImapEngine\StoreResult; +use DirectoryTree\ImapEngine\Support\SequenceSet; +use DirectoryTree\ImapEngine\Vanished; use Exception; use Generator; +use InvalidArgumentException; use LogicException; use Throwable; @@ -39,6 +49,11 @@ class ImapConnection implements ConnectionInterface */ protected ?Result $result = null; + /** + * The current IDLE session open on the connection. + */ + protected ?IdleSession $idle = null; + /** * The parser instance. */ @@ -164,6 +179,9 @@ protected function getDefaultSocketOptions(string $transport, array $proxy = [], */ public function disconnect(): void { + $this->idle?->invalidate(); + $this->idle = null; + $this->stream->close(); } @@ -180,9 +198,9 @@ public function connected(): bool */ public function login(string $user, string $password): TaggedResponse { - $this->send('LOGIN', Str::literal([$user, $password]), $tag); + $this->send('LOGIN', CommandArgument::literal([$user, $password]), $tag); - return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => ( + return $this->assertTaggedResponse($tag, exception: fn (TaggedResponse $response) => ( ImapCommandException::make($this->result->command()->redacted(), $response) )); } @@ -192,19 +210,50 @@ public function login(string $user, string $password): TaggedResponse */ public function logout(): void { - $this->send('LOGOUT', tag: $tag); + if (! $this->connected()) { + return; + } + + try { + $this->send('LOGOUT', tag: $tag); + + $this->assertTaggedResponse($tag); + } finally { + $this->disconnect(); + } } /** * {@inheritDoc} */ - public function authenticate(string $user, string $token): TaggedResponse + public function authenticate(string $mechanism, ?string $initial = null): Generator { - $this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag); + $tokens = [CommandArgument::mechanism($mechanism)]; - return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => ( - ImapCommandException::make($this->result->command()->redacted(), $response) - )); + if ($initial !== null) { + $tokens[] = $initial === '' ? '=' : base64_encode($initial); + } + + $this->send('AUTHENTICATE', $tokens, $tag); + + while ($response = $this->nextResponse(fn (Response $response) => ( + $response instanceof ContinuationResponse + || ($response instanceof TaggedResponse && $response->tag()->is($tag)) + ))) { + if ($response instanceof TaggedResponse) { + if ($response->failed()) { + throw ImapCommandException::make($this->result->command()->redacted(), $response); + } + + return $response; + } + + $answer = yield base64_decode(trim(substr((string) $response, 1))); + + $this->write($answer === null ? '*' : base64_encode($answer), sensitive: true); + } + + throw new ImapResponseException('No authentication response found'); } /** @@ -222,39 +271,70 @@ public function startTls(): void /** * {@inheritDoc} */ - public function select(string $folder = 'INBOX'): ResponseCollection + public function enable(string ...$capabilities): ResponseCollection { - return $this->examineOrSelect('SELECT', $folder); + if (! $capabilities) { + throw new InvalidArgumentException('At least one capability is required.'); + } + + $this->send('ENABLE', array_map([CommandArgument::class, 'atom'], $capabilities), $tag); + + $this->assertTaggedResponse($tag); + + return $this->result->responses()->untagged()->filter( + fn (UntaggedResponse $response) => $response->type()->is('ENABLED') + ); } /** * {@inheritDoc} */ - public function examine(string $folder = 'INBOX'): ResponseCollection + public function select(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult { - return $this->examineOrSelect('EXAMINE', $folder); + return $this->examineOrSelect('SELECT', $folder, $options); + } + + /** + * {@inheritDoc} + */ + public function examine(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult + { + return $this->examineOrSelect('EXAMINE', $folder, $options); } /** * Examine and select have the same response. */ - protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): ResponseCollection + protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX', array $options = []): SelectionResult { - $this->send($command, [Str::literal($folder)], $tag); + $tokens = [CommandArgument::literal($folder)]; + + if ($options) { + $tokens[] = CommandArgument::list(array_map( + fn (OptionInterface $option) => $option->toImap(), + $options, + )); + } + + $this->send($command, $tokens, $tag); $this->assertTaggedResponse($tag); - return $this->result->responses()->untagged(); + return SelectionResult::fromResponses($this->result->responses()); } /** * {@inheritDoc} */ - public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse + public function status(string $folder = 'INBOX', array $items = ['MESSAGES', 'UNSEEN', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse { + if (! $items) { + throw new InvalidArgumentException('At least one status item is required.'); + } + $this->send('STATUS', [ - Str::literal($folder), - Str::list($arguments), + CommandArgument::literal($folder), + CommandArgument::list(CommandArgument::atoms($items)), ], $tag); $this->assertTaggedResponse($tag); @@ -269,7 +349,7 @@ public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', */ public function create(string $folder): ResponseCollection { - $this->send('CREATE', [Str::literal($folder)], $tag); + $this->send('CREATE', [CommandArgument::literal($folder)], $tag); $this->assertTaggedResponse($tag); @@ -283,7 +363,7 @@ public function create(string $folder): ResponseCollection */ public function delete(string $folder): TaggedResponse { - $this->send('DELETE', [Str::literal($folder)], tag: $tag); + $this->send('DELETE', [CommandArgument::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } @@ -293,7 +373,7 @@ public function delete(string $folder): TaggedResponse */ public function rename(string $oldPath, string $newPath): TaggedResponse { - $this->send('RENAME', Str::literal([$oldPath, $newPath]), tag: $tag); + $this->send('RENAME', CommandArgument::literal([$oldPath, $newPath]), tag: $tag); return $this->assertTaggedResponse($tag); } @@ -303,7 +383,7 @@ public function rename(string $oldPath, string $newPath): TaggedResponse */ public function subscribe(string $folder): TaggedResponse { - $this->send('SUBSCRIBE', [Str::literal($folder)], tag: $tag); + $this->send('SUBSCRIBE', [CommandArgument::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } @@ -313,7 +393,7 @@ public function subscribe(string $folder): TaggedResponse */ public function unsubscribe(string $folder): TaggedResponse { - $this->send('UNSUBSCRIBE', [Str::literal($folder)], tag: $tag); + $this->send('UNSUBSCRIBE', [CommandArgument::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } @@ -321,9 +401,9 @@ public function unsubscribe(string $folder): TaggedResponse /** * {@inheritDoc} */ - public function quota(string $root): UntaggedResponse + public function getQuota(string $root): UntaggedResponse { - $this->send('GETQUOTA', [Str::literal($root)], tag: $tag); + $this->send('GETQUOTA', [CommandArgument::literal($root)], tag: $tag); $this->assertTaggedResponse($tag); @@ -335,59 +415,78 @@ public function quota(string $root): UntaggedResponse /** * {@inheritDoc} */ - public function quotaRoot(string $mailbox): ResponseCollection + public function getQuotaRoot(string $mailbox): ResponseCollection { - $this->send('GETQUOTAROOT', [Str::literal($mailbox)], tag: $tag); + $this->send('GETQUOTAROOT', [CommandArgument::literal($mailbox)], tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('QUOTA') + fn (UntaggedResponse $response) => $response->type()->is('QUOTAROOT') || $response->type()->is('QUOTA') ); } /** * {@inheritDoc} */ - public function list(string $reference = '', string $folder = '*'): ResponseCollection + public function list(string $reference = '', array|string $pattern = '*', array $selection = [], array $return = []): ResponseCollection { - $this->send('LIST', Str::literal([$reference, $folder]), $tag); + if ($pattern === []) { + throw new InvalidArgumentException('At least one mailbox pattern is required.'); + } + + $tokens = $selection ? [CommandArgument::list(CommandArgument::atoms($selection))] : []; + + $tokens[] = CommandArgument::literal($reference); + + array_push($tokens, ...(is_array($pattern) ? CommandArgument::literalList($pattern) : [CommandArgument::literal($pattern)])); + + if ($return) { + $tokens[] = 'RETURN'; + $tokens[] = CommandArgument::list(CommandArgument::atoms($return)); + } + + $this->send('LIST', $tokens, $tag); $this->assertTaggedResponse($tag); - return $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('LIST') - ); + return $this->result->responses()->untagged(); } /** * {@inheritDoc} */ - public function append(string $folder, string $message, ?array $flags = null): TaggedResponse + public function append(string $folder, string $message, ?array $flags = null, ?DateTimeInterface $date = null): AppendResult { $tokens = []; - $tokens[] = Str::literal($folder); + $tokens[] = CommandArgument::literal($folder); if ($flags) { - $tokens[] = Str::list($flags); + $tokens[] = CommandArgument::list(array_map([CommandArgument::class, 'flag'], $flags)); } - $tokens[] = Str::literal($message); + if ($date) { + $tokens[] = CommandArgument::literal($date->format('d-M-Y H:i:s O')); + } + + $tokens[] = ['{'.strlen($message).'}', $message]; $this->send('APPEND', $tokens, tag: $tag); - return $this->assertTaggedResponse($tag); + return AppendResult::fromResponse( + $this->assertTaggedResponse($tag) + ); } /** * {@inheritDoc} */ - public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse + public function copy(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { - $this->send('UID COPY', [ - Str::set($from, $to), - Str::literal($folder), + $this->send($identifier === ImapIdentifier::Uid ? 'UID COPY' : 'COPY', [ + SequenceSet::format($set), + CommandArgument::literal($folder), ], $tag); return $this->assertTaggedResponse($tag); @@ -396,11 +495,11 @@ public function copy(string $folder, array|int $from, ?int $to = null): TaggedRe /** * {@inheritDoc} */ - public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse + public function move(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { - $this->send('UID MOVE', [ - Str::set($from, $to), - Str::literal($folder), + $this->send($identifier === ImapIdentifier::Uid ? 'UID MOVE' : 'MOVE', [ + SequenceSet::format($set), + CommandArgument::literal($folder), ], $tag); return $this->assertTaggedResponse($tag); @@ -409,87 +508,51 @@ public function move(string $folder, array|int $from, ?int $to = null): TaggedRe /** * {@inheritDoc} */ - public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection + public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifierInterface ...$modifiers): StoreResult { - $set = Str::set($from, $to); - - $flags = Str::list((array) $flags); - - $item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : ''); - - $this->send('UID STORE', [$set, $item, $flags], tag: $tag); + if (! in_array($mode, [null, '+', '-'], true)) { + throw new InvalidArgumentException('Invalid IMAP store mode.'); + } - $this->assertTaggedResponse($tag); + $tokens = [SequenceSet::format($set)]; - return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('FETCH') - ); - } + if ($modifiers) { + $tokens[] = CommandArgument::list(array_map( + fn (StoreModifierInterface $modifier) => $modifier->toImap(), + $modifiers, + )); + } - /** - * {@inheritDoc} - */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection - { - return $this->fetch(['UID'], (array) $ids, null, $identifier); - } + $tokens[] = $mode.'FLAGS'.($silent ? '.SILENT' : ''); + $tokens[] = CommandArgument::list(array_map([CommandArgument::class, 'flag'], (array) $flags)); - /** - * {@inheritDoc} - */ - public function bodyText(int|array $ids, bool $peek = true): ResponseCollection - { - return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids); - } + $this->send($identifier === ImapIdentifier::Uid ? 'UID STORE' : 'STORE', $tokens, $tag); - /** - * {@inheritDoc} - */ - public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection - { - return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids); - } + $response = $this->assertTaggedResponse($tag, assertion: true); + $responses = $this->result->responses(); + $fetches = FetchedResponse::collect($responses)->forMessageSet($tokens[0], $identifier); - /** - * Fetch the BODYSTRUCTURE for the given message(s). - */ - public function bodyStructure(int|array $ids): ResponseCollection - { - return $this->fetch(['BODYSTRUCTURE'], (array) $ids); - } + $result = StoreResult::fromResponses($responses, $response, $fetches); - /** - * Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc. - */ - public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection - { - $part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]"; + if ($response->status()->is('BAD') || ($response->failed() && $result->modified()->isEmpty())) { + throw ImapCommandException::make($this->result->command(), $response); + } - return $this->fetch([$part], (array) $ids); + return $result; } /** * {@inheritDoc} */ - public function flags(int|array $ids): ResponseCollection + public function search(array $criteria, ?string $charset = null, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { - return $this->fetch(['FLAGS'], (array) $ids); - } + if (! $criteria) { + throw new InvalidArgumentException('At least one search criterion is required.'); + } - /** - * {@inheritDoc} - */ - public function size(int|array $ids): ResponseCollection - { - return $this->fetch(['RFC822.SIZE'], (array) $ids); - } + $tokens = $charset === null ? $criteria : ['CHARSET', CommandArgument::charset($charset), ...$criteria]; - /** - * {@inheritDoc} - */ - public function search(array $params): UntaggedResponse - { - $this->send('UID SEARCH', $params, tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SEARCH' : 'SEARCH', $tokens, tag: $tag); $this->assertTaggedResponse($tag); @@ -501,11 +564,13 @@ public function search(array $params): UntaggedResponse /** * {@inheritDoc} */ - public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse + public function sort(ImapSort $sort, array $criteria, string $charset = 'UTF-8', ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { - $sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value; + if (! $criteria) { + throw new InvalidArgumentException('At least one search criterion is required.'); + } - $this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", CommandArgument::charset($charset), ...$criteria], tag: $tag); $this->assertTaggedResponse($tag); @@ -531,21 +596,16 @@ public function capability(): UntaggedResponse /** * {@inheritDoc} */ - public function id(?array $ids = null): UntaggedResponse + public function id(?array $parameters = null): UntaggedResponse { - $token = 'NIL'; - - if (is_array($ids) && ! empty($ids)) { - $token = '('; - - foreach ($ids as $id) { - $token .= '"'.Str::escape($id).'" '; - } + $values = []; - $token = rtrim($token).')'; + foreach ($parameters ?? [] as $field => $value) { + $values[] = $field; + $values[] = $value; } - $this->send('ID', [$token], tag: $tag); + $this->send('ID', $parameters === null ? ['NIL'] : CommandArgument::literalList($values), tag: $tag); $this->assertTaggedResponse($tag); @@ -557,9 +617,13 @@ public function id(?array $ids = null): UntaggedResponse /** * {@inheritDoc} */ - public function expunge(): ResponseCollection + public function expunge(array|int|string|null $uids = null): ResponseCollection { - $this->send('EXPUNGE', tag: $tag); + $this->send( + $uids === null ? 'EXPUNGE' : 'UID EXPUNGE', + $uids === null ? [] : [SequenceSet::format($uids)], + $tag, + ); $this->assertTaggedResponse($tag); @@ -579,37 +643,13 @@ public function noop(): TaggedResponse /** * {@inheritDoc} */ - public function idle(int $timeout): Generator + public function idle(): IdleSession { - $this->stream->setTimeout($timeout); - $this->send('IDLE', tag: $tag); - $this->assertNextResponse( - fn (Response $response) => $response instanceof ContinuationResponse, - fn (ContinuationResponse $response) => true, - fn (ContinuationResponse $response) => ImapCommandException::make(new ImapCommand('', 'IDLE'), $response), - ); - - while ($response = $this->nextReply()) { - yield $response; - } - } - - /** - * {@inheritDoc} - */ - public function done(): void - { - $this->write('DONE'); - - // After issuing a "DONE" command, the server must eventually respond with a - // tagged response to indicate that the IDLE command has been successfully - // terminated and the server is ready to accept further commands. - $this->assertNextResponse( - fn (Response $response) => $response instanceof TaggedResponse, - fn (TaggedResponse $response) => $response->successful(), - fn (TaggedResponse $response) => ImapCommandException::make(new ImapCommand('', 'DONE'), $response), + return $this->idle = new IdleSession( + new ImapCommand($tag, 'IDLE'), + $this, ); } @@ -620,6 +660,10 @@ public function done(): void */ public function send(string $name, array $tokens = [], ?string &$tag = null): void { + if ($this->idle?->active()) { + throw new LogicException('Finish the active IDLE session before sending another command.'); + } + if (! $tag) { $tag = 'TAG'.++$this->sequence; } @@ -632,7 +676,7 @@ public function send(string $name, array $tokens = [], ?string &$tag = null): vo $this->setResult(new Result($command)); foreach ($command->compile() as $line) { - $this->write($line->value); + $this->write($line->value, sensitive: in_array($name, ['LOGIN', 'AUTHENTICATE'])); if ($line->synchronizing) { $this->assertContinuationResponse($command); @@ -641,52 +685,66 @@ public function send(string $name, array $tokens = [], ?string &$tag = null): vo } /** - * Write data to the connected stream. + * {@inheritDoc} */ - protected function write(string $data): void + public function write(string $data, bool $sensitive = false): void { if ($this->stream->fwrite($data."\r\n") === false) { throw new ImapStreamException('Failed to write data to stream'); } - $this->logger?->sent($data); + $this->logger?->sent($sensitive ? '[redacted]' : $data); } /** - * Fetch one or more items for one or more messages. + * {@inheritDoc} */ - public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifierInterface ...$modifiers): FetchResult { - $prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : ''; + if ($items === []) { + throw new InvalidArgumentException('At least one fetch item is required.'); + } - $this->send(trim($prefix.' FETCH'), [ - Str::set($from, $to), - Str::list((array) $items), - ], $tag); + $prefix = ($identifier === ImapIdentifier::Uid) ? 'UID' : ''; + + $items = array_merge(...array_map(fn (string $item) => match (strtoupper($item)) { + 'ALL' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE'], + 'FAST' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE'], + 'FULL' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY'], + default => [$item], + }, array_values((array) $items))); + + $tokens = [ + SequenceSet::format($set), + CommandArgument::list($items), + ]; + + if ($modifiers) { + $tokens[] = CommandArgument::list(array_map( + fn (FetchModifierInterface $modifier) => $modifier->toImap($identifier), + $modifiers, + )); + } + + $this->send(trim($prefix.' FETCH'), $tokens, $tag); $this->assertTaggedResponse($tag); // Some IMAP servers can send unsolicited untagged responses along with fetch // requests. We'll need to filter these out so that we can return only the // responses that are relevant to the fetch command. For example: - // >> TAG123 FETCH (UID 456 BODY[TEXT]) + // >> TAG123 FETCH 123 (UID BODY[TEXT]) // << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!) // << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response - return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) { - // Skip over any untagged responses that are not FETCH responses. - // The third token should always be the list of data items. - if (! ($data = $response->tokenAt(3)) instanceof ListData) { - return false; - } + $responses = $this->result->responses(); - return match ($identifier) { - // If we're fetching UIDs, we can check if a UID token is contained in the list. - ImapFetchIdentifier::Uid => $data->contains('UID'), + $fetches = FetchedResponse::collect($responses) + ->forMessageSet($tokens[0], $identifier) + ->withItems($items); - // If we're fetching message numbers, we can check if the requested items are all contained in the list. - ImapFetchIdentifier::MessageNumber => $data->contains($items), - }; - }); + $vanished = Vanished::collect($responses)->forMessageSet($tokens[0]); + + return FetchResult::fromResponses($responses, $fetches, $vanished); } /** @@ -722,17 +780,19 @@ protected function newTokenizer(StreamInterface $stream): ImapTokenizer } /** - * Assert the next response is a successful tagged response. + * Assert the next response is tagged and passes the given assertion. */ - protected function assertTaggedResponse(string $tag, ?callable $exception = null): TaggedResponse + protected function assertTaggedResponse(string $tag, callable|true|null $assertion = null, ?callable $exception = null): TaggedResponse { + $assertion ??= fn (TaggedResponse $response) => $response->successful(); + /** @var TaggedResponse $response */ $response = $this->assertNextResponse( fn (Response $response) => ( $response instanceof TaggedResponse && $response->tag()->is($tag) ), fn (TaggedResponse $response) => ( - $response->successful() + $assertion === true || $assertion($response) ), $exception ?? fn (TaggedResponse $response) => ( ImapCommandException::make($this->result->command(), $response) @@ -798,7 +858,7 @@ protected function nextResponse(callable $filter): ?Response throw new LogicException('No parser instance set'); } - while ($response = $this->nextReply()) { + while ($response = $this->read()) { if (! $response instanceof Response) { continue; } @@ -814,9 +874,9 @@ protected function nextResponse(callable $filter): ?Response } /** - * Read the next reply from the stream. + * {@inheritDoc} */ - protected function nextReply(): Data|Token|Response|null + public function read(): Data|Token|Response|null { if (! $reply = $this->parser->next()) { $meta = $this->stream->meta(); @@ -832,4 +892,12 @@ protected function nextReply(): Data|Token|Response|null return $reply; } + + /** + * {@inheritDoc} + */ + public function stream(): StreamInterface + { + return $this->stream; + } } diff --git a/src/Connection/ImapQueryBuilder.php b/src/Connection/ImapQueryBuilder.php index fd2db7f..4b18b97 100644 --- a/src/Connection/ImapQueryBuilder.php +++ b/src/Connection/ImapQueryBuilder.php @@ -7,6 +7,8 @@ use Carbon\CarbonInterface; use DateTimeInterface; use DirectoryTree\ImapEngine\Enums\ImapSearchKey; +use DirectoryTree\ImapEngine\Support\Enum; +use DirectoryTree\ImapEngine\Support\SequenceSet; use DirectoryTree\ImapEngine\Support\Str; class ImapQueryBuilder @@ -287,7 +289,7 @@ public function uid(int|string|array $from, int|float|null $to = null): static $to = self::MAX_UID; } - return $this->where(ImapSearchKey::Uid, new RawQueryValue(Str::set($from, $to))); + return $this->where(ImapSearchKey::Uid, new RawQueryValue(SequenceSet::format($from, $to))); } /** @@ -356,6 +358,16 @@ public function isEmpty(): bool * Transform the instance into an IMAP-compatible query string. */ public function toImap(): string + { + return ltrim((string) new ImapCommand('', '', $this->toTokens())); + } + + /** + * Compile the query into command tokens, including synchronizing literals. + * + * @see https://www.rfc-editor.org/rfc/rfc3501#section-4.3 + */ + public function toTokens(): array { return $this->compileWheres($this->wheres); } @@ -375,7 +387,7 @@ protected function addBasicCondition(string $boolean, mixed $column, mixed $valu { $value = $this->prepareWhereValue($value); - $column = Str::enum($column); + $column = Enum::value($column); $this->wheres[] = [ 'type' => 'basic', @@ -387,7 +399,7 @@ protected function addBasicCondition(string $boolean, mixed $column, mixed $valu } /** - * Prepare the where value, escaping it as needed. + * Prepare the where value for compilation. */ protected function prepareWhereValue(mixed $value): RawQueryValue|string|null { @@ -411,7 +423,7 @@ protected function prepareWhereValue(mixed $value): RawQueryValue|string|null $value = $value->format($this->dateFormat); } - return Str::escape($value); + return str_replace(["\r", "\n"], '', (string) $value); } /** @@ -456,7 +468,7 @@ protected function makeExpressionNode(array $where): array ], 'nested' => [ - 'expr' => $where['query']->toImap(), + 'expr' => $where['query']->toTokens(), 'boolean' => $where['boolean'], ] }; @@ -467,24 +479,42 @@ protected function makeExpressionNode(array $where): array * * @param 'AND'|'OR' $boolean */ - protected function mergeExpressions(string $existing, string $next, string $boolean): string + protected function mergeExpressions(array $existing, array $next, string $boolean): array { return match ($boolean) { // AND is implicit – just append. - 'AND' => $existing.' '.$next, + 'AND' => [...$existing, ...$next], // IMAP's OR is binary; nest accordingly. - 'OR' => 'OR ('.$existing.') ('.$next.')', + 'OR' => ['OR', ...$this->groupExpression($existing), ...$this->groupExpression($next)], }; } /** - * Recursively compile the wheres array into an IMAP-compatible string. + * Wrap an expression in parentheses without changing its literal contents. */ - protected function compileWheres(array $wheres): string + protected function groupExpression(array $tokens): array + { + $tokens[0] = '('.$tokens[0]; + + $last = array_key_last($tokens); + + if (is_array($tokens[$last])) { + $tokens[] = ')'; + } else { + $tokens[$last] .= ')'; + } + + return $tokens; + } + + /** + * Recursively compile the where conditions into command tokens. + */ + protected function compileWheres(array $wheres): array { if (empty($wheres)) { - return ''; + return []; } // Convert each "where" into a node for later merging. @@ -502,26 +532,30 @@ protected function compileWheres(array $wheres): string ); } - return trim($combined); + return $combined; } /** - * Compile a basic where condition into an IMAP-compatible string. + * Compile a basic where condition into command tokens. */ - protected function compileBasic(array $where): string + protected function compileBasic(array $where): array { - $part = strtoupper($where['key']); + $parts = [strtoupper($where['key'])]; if ($where['value'] instanceof RawQueryValue) { - $part .= ' '.$where['value']->value; - } elseif ($where['value']) { - $part .= ' "'.Str::toImapUtf7($where['value']).'"'; + $parts[] = $where['value']->value; + } elseif ($where['value'] !== null) { + $value = (string) new CommandPart($where['value']); + + $parts[] = Str::isAscii($value) + ? CommandArgument::literal($value) + : ['{'.strlen($value).'}', $value]; } if ($where['not']) { - $part = 'NOT '.$part; + array_unshift($parts, 'NOT'); } - return $part; + return $parts; } } diff --git a/src/Connection/ImapTokenizer.php b/src/Connection/ImapTokenizer.php index f43a735..2fe297a 100644 --- a/src/Connection/ImapTokenizer.php +++ b/src/Connection/ImapTokenizer.php @@ -44,9 +44,9 @@ public function nextToken(): ?Token { $this->skipWhitespace(); - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null || $char === '') { return null; @@ -55,9 +55,9 @@ public function nextToken(): ?Token // Check for line feed. if ($char === "\n") { // With a valid IMAP response, we should never reach this point, - // but in case we receive a malformed response, we will flush + // but in case we receive a malformed response, we will reset // the buffer and return null to prevent an infinite loop. - $this->flushBuffer(); + $this->resetBuffer(); return null; } @@ -66,9 +66,9 @@ public function nextToken(): ?Token if ($char === "\r") { $this->advance(); // Consume CR - $this->ensureBuffer(1); + $this->fillBuffer(1); - if ($this->currentChar() !== "\n") { + if ($this->peek() !== "\n") { throw new ImapParserException('Expected LF after CR'); } @@ -122,6 +122,17 @@ public function nextToken(): ?Token return $this->readLiteral(); } + // BINARY fetch responses use the same literal framing with a "~" prefix. + if ($char === '~') { + $this->fillBuffer(2); + + if (substr($this->buffer, $this->position, 2) === '~{') { + $this->advance(); + + return $this->readLiteral(); + } + } + // Otherwise, parse a number or atom. return $this->readNumberOrAtom(); } @@ -132,9 +143,9 @@ public function nextToken(): ?Token protected function skipWhitespace(): void { while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); // Break on EOF. if ($char === null || $char === '') { @@ -168,9 +179,9 @@ protected function readQuotedString(): QuotedString $value = ''; while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null) { throw new ImapParserException(sprintf( @@ -183,9 +194,9 @@ protected function readQuotedString(): QuotedString if ($char === '\\') { $this->advance(); // Skip the backslash. - $this->ensureBuffer(1); + $this->fillBuffer(1); - $escapedChar = $this->currentChar(); + $escapedChar = $this->peek(); if ($escapedChar === null) { throw new ImapParserException('Unterminated escape sequence in quoted string'); @@ -227,9 +238,9 @@ protected function readLiteral(): Literal $numStr = ''; while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null) { throw new ImapParserException('Unterminated literal specifier'); @@ -247,7 +258,7 @@ protected function readLiteral(): Literal } // Expect carriage return after the literal specifier. - $this->ensureBuffer(2); + $this->fillBuffer(2); // Get the carriage return. $crlf = substr($this->buffer, $this->position, 2); @@ -318,7 +329,7 @@ protected function readNumberOrAtom(): Token while (ctype_digit($this->buffer[$position] ?? '')) { $position++; - $this->ensureBuffer($position - $this->position + 1); + $this->fillBuffer($position - $this->position + 1); } $next = $this->buffer[$position] ?? null; @@ -342,9 +353,9 @@ protected function readNumber(): Number $start = $this->position; while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null) { break; @@ -370,15 +381,15 @@ protected function readAtom(): Atom $value = ''; while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null) { break; } - if (! $this->isValidAtomCharacter($char)) { + if (! $this->isAtomCharacter($char)) { break; } @@ -420,9 +431,9 @@ protected function readEmailAddress(): ?EmailAddress $value = ''; while (true) { - $this->ensureBuffer(1); + $this->fillBuffer(1); - $char = $this->currentChar(); + $char = $this->peek(); if ($char === null) { throw new ImapParserException('Unterminated email address, expected ">"'); @@ -443,9 +454,9 @@ protected function readEmailAddress(): ?EmailAddress } /** - * Ensures that at least the given length in characters are available in the buffer. + * Fills the buffer with at least the given length in characters from the stream. */ - protected function ensureBuffer(int $length): void + protected function fillBuffer(int $length): void { // If we have enough data in the buffer, return early. while ((strlen($this->buffer) - $this->position) < $length) { @@ -462,15 +473,15 @@ protected function ensureBuffer(int $length): void } /** - * Returns the current character in the buffer. + * Peeks at the current character in the buffer without advancing the position. */ - protected function currentChar(): ?string + protected function peek(): ?string { return $this->buffer[$this->position] ?? null; } /** - * Advances the internal pointer by $n characters. + * Advances the internal pointer by the given number of characters. */ protected function advance(int $n = 1): void { @@ -478,14 +489,14 @@ protected function advance(int $n = 1): void // If we have consumed the entire buffer, reset it. if ($this->position >= strlen($this->buffer)) { - $this->flushBuffer(); + $this->resetBuffer(); } } /** - * Flush the buffer and reset the position. + * Reset the buffer and its position. */ - protected function flushBuffer(): void + protected function resetBuffer(): void { $this->buffer = ''; $this->position = 0; @@ -494,7 +505,7 @@ protected function flushBuffer(): void /** * Determine if the given character is a valid atom character. */ - protected function isValidAtomCharacter(string $char): bool + protected function isAtomCharacter(string $char): bool { $code = ord($char); diff --git a/src/Connection/Loggers/FakeLogger.php b/src/Connection/Loggers/FakeLogger.php new file mode 100644 index 0000000..64b4173 --- /dev/null +++ b/src/Connection/Loggers/FakeLogger.php @@ -0,0 +1,74 @@ +sent[] = $message; + } + + /** + * {@inheritDoc} + */ + public function received(string $message): void + { + $this->received[] = $message; + } + + /** + * Assert that the given message was sent. + */ + public function assertSent(string $message, int $times = 1): void + { + Assert::assertSame( + $times, + count(array_keys($this->sent, $message, strict: true)), + "Failed asserting that the message '{$message}' was sent {$times} times." + ); + } + + /** + * Assert that the given message was not sent. + */ + public function assertNotSent(string $message): void + { + $this->assertSent($message, times: 0); + } + + /** + * Assert that the given message was received. + */ + public function assertReceived(string $message, int $times = 1): void + { + Assert::assertSame( + $times, + count(array_keys($this->received, $message, strict: true)), + "Failed asserting that the message '{$message}' was received {$times} times." + ); + } + + /** + * Assert that the given message was not received. + */ + public function assertNotReceived(string $message): void + { + $this->assertReceived($message, times: 0); + } +} diff --git a/src/Connection/Result.php b/src/Connection/Result.php index c40ae90..951ae9f 100644 --- a/src/Connection/Result.php +++ b/src/Connection/Result.php @@ -12,8 +12,10 @@ class Result */ public function __construct( protected ImapCommand $command, - protected array $responses = [], - ) {} + protected ResponseCollection $responses = new ResponseCollection, + ) { + $this->responses = clone $responses; + } /** * Get the executed command. @@ -28,7 +30,7 @@ public function command(): ImapCommand */ public function addResponse(Response $response): void { - $this->responses[] = $response; + $this->responses->push($response); } /** @@ -36,6 +38,6 @@ public function addResponse(Response $response): void */ public function responses(): ResponseCollection { - return new ResponseCollection($this->responses); + return clone $this->responses; } } diff --git a/src/Connection/Streams/FakeStream.php b/src/Connection/Streams/FakeStream.php index cbabd66..91ef087 100644 --- a/src/Connection/Streams/FakeStream.php +++ b/src/Connection/Streams/FakeStream.php @@ -45,6 +45,46 @@ class FakeStream implements StreamInterface 'stream_type' => 'tcp_socket/unknown', ]; + /** + * The failure to report once all queued responses have been read. + */ + protected ?string $failureWhenEmpty = null; + + /** + * The most recently configured read timeout. + */ + protected ?int $timeout = null; + + /** + * Simulate a disconnection after the queued responses are consumed. + */ + public function disconnectWhenEmpty(): self + { + $this->failureWhenEmpty = 'eof'; + + return $this; + } + + /** + * Simulate a timeout after the queued responses are consumed. + */ + public function timeoutWhenEmpty(): self + { + $this->failureWhenEmpty = 'timed_out'; + + return $this; + } + + /** + * Apply the scripted failure when there are no responses left to read. + */ + protected function failWhenEmpty(): void + { + if (! $this->buffer && $this->failureWhenEmpty !== null) { + $this->meta[$this->failureWhenEmpty] = true; + } + } + /** * Feed a line to the stream buffer with a newline character. */ @@ -99,6 +139,8 @@ public function setMeta(string $attribute, mixed $value): self */ public function open(?string $transport = null, ?string $host = null, ?int $port = null, ?int $timeout = null, array $options = []): bool { + $this->timeout = $timeout; + $this->connection = compact('transport', 'host', 'port', 'timeout', 'options'); return true; @@ -122,8 +164,10 @@ public function read(int $length): string|false return false; } - if ($this->meta['eof'] && empty($this->buffer)) { - return false; // EOF and no data left. Indicate end of stream. + $this->failWhenEmpty(); + + if ($this->meta['timed_out'] || ($this->meta['eof'] && empty($this->buffer))) { + return false; } $data = implode('', $this->buffer); @@ -156,6 +200,8 @@ public function fgets(): string|false return false; } + $this->failWhenEmpty(); + // Simulate timeout/eof checks. if ($this->meta['timed_out'] || $this->meta['eof']) { return false; @@ -199,9 +245,19 @@ public function opened(): bool */ public function setTimeout(int $seconds): bool { + $this->timeout = $seconds; + return true; } + /** + * Get the most recently configured read timeout. + */ + public function timeout(): ?int + { + return $this->timeout; + } + /** * {@inheritDoc} */ diff --git a/src/Enums/ImapFetchIdentifier.php b/src/Enums/ImapIdentifier.php similarity index 78% rename from src/Enums/ImapFetchIdentifier.php rename to src/Enums/ImapIdentifier.php index 567df97..cde628d 100644 --- a/src/Enums/ImapFetchIdentifier.php +++ b/src/Enums/ImapIdentifier.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine\Enums; -enum ImapFetchIdentifier +enum ImapIdentifier { case Uid; case MessageNumber; diff --git a/src/Enums/ImapSpecialUse.php b/src/Enums/ImapSpecialUse.php new file mode 100644 index 0000000..95177b7 --- /dev/null +++ b/src/Enums/ImapSpecialUse.php @@ -0,0 +1,17 @@ +vanished && $identifier !== ImapIdentifier::Uid) { + throw new InvalidArgumentException('The VANISHED modifier requires UID FETCH.'); + } + + return 'CHANGEDSINCE '.$this->modSequence.($this->vanished ? ' VANISHED' : ''); + } +} diff --git a/src/Fetch/ModifierInterface.php b/src/Fetch/ModifierInterface.php new file mode 100644 index 0000000..9afb4d9 --- /dev/null +++ b/src/Fetch/ModifierInterface.php @@ -0,0 +1,13 @@ + $messages + */ + public function __construct( + protected Collection $messages = new Collection, + protected VanishedCollection $vanished = new VanishedCollection, + protected ResponseCollection $responses = new ResponseCollection, + ) {} + + /** + * Create a fetch result from IMAP responses and selected fetched responses. + */ + public static function fromResponses( + ResponseCollection $responses, + ?FetchedResponseCollection $fetches = null, + ?VanishedCollection $vanished = null, + ): static { + $fetches ??= FetchedResponse::collect($responses); + $vanished ??= Vanished::collect($responses); + + return new static( + $fetches->messages(), + $vanished, + $responses, + ); + } + + /** + * Get the fetched messages. + * + * @return Collection + */ + public function messages(): Collection + { + return $this->messages; + } + + /** + * Get the vanished message groups. + */ + public function vanished(): VanishedCollection + { + return $this->vanished; + } + + /** + * Get all unique vanished message UIDs. + * + * @return LazyCollection + */ + public function vanishedUids(): LazyCollection + { + return $this->vanished->lazy() + ->flatMap(fn (Vanished $vanished) => $vanished->uids()) + ->unique() + ->values(); + } + + /** + * Get the raw IMAP responses. + */ + public function responses(): ResponseCollection + { + return $this->responses; + } +} diff --git a/src/FetchedMessageData.php b/src/FetchedMessageData.php new file mode 100644 index 0000000..7525a40 --- /dev/null +++ b/src/FetchedMessageData.php @@ -0,0 +1,206 @@ +attributes = array_change_key_case($attributes, CASE_UPPER); + } + + /** + * Create message data from an IMAP FETCH response. + */ + public static function fromResponse(UntaggedResponse $response): static + { + $data = $response->tokenAt(3); + + if (! $data instanceof ListData) { + throw new RuntimeException(sprintf( + 'Expected instance of %s at index 3 in FETCH response, got %s', + ListData::class, + get_debug_type($data) + )); + } + + $tokens = $data->tokens(); + + $attributes = []; + + for ($index = 0; $index < count($tokens);) { + $key = strtoupper($tokens[$index++]->value); + + // Section specifiers and partial offsets belong to the attribute + // name, not its value. Keep them so multiple sections can coexist. + if ( + in_array($key, ['BODY', 'BINARY', 'BINARY.SIZE']) + && ($tokens[$index] ?? null) instanceof ResponseCodeData + ) { + $key .= strtoupper((string) $tokens[$index++]); + + if (($tokens[$index] ?? null) instanceof EmailAddress) { + $key .= (string) $tokens[$index++]; + } + } + + $attributes[$key] = $tokens[$index++]; + } + + return new static( + $attributes, + sequenceNumber: (int) $response->type()->value, + ); + } + + /** + * Determine if an attribute was returned, including an explicit NIL value. + */ + public function has(string $key): bool + { + return array_key_exists(strtoupper($key), $this->attributes); + } + + /** + * Get an attribute as a PHP value. IMAP numbers retain their string value. + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->has($key) + ? $this->value($this->attributes[strtoupper($key)]) + : $default; + } + + /** + * Create a copy containing the given attributes, leaving omitted values intact. + */ + public function merge(array|self $attributes): static + { + return new static( + array_replace( + $this->attributes, + $attributes instanceof self + ? $attributes->attributes + : array_change_key_case($attributes, CASE_UPPER), + ), + $attributes instanceof self + ? $attributes->sequenceNumber ?? $this->sequenceNumber + : $this->sequenceNumber, + ); + } + + /** + * Get the message sequence number. + */ + public function sequenceNumber(): ?int + { + return $this->sequenceNumber; + } + + /** + * Get the message UID. + */ + public function uid(): int + { + return (int) $this->get('UID'); + } + + /** + * Get the message flags. + */ + public function flags(): array + { + return $this->get('FLAGS') ?? []; + } + + /** + * Get the message headers. + */ + public function head(): string + { + return $this->get('BODY[HEADER]') ?? ''; + } + + /** + * Get the message text body. + */ + public function body(): string + { + return $this->get('BODY[TEXT]') ?? ''; + } + + /** + * Get the message size in bytes. + */ + public function size(): ?int + { + $size = $this->get('RFC822.SIZE'); + + return is_null($size) ? null : (int) $size; + } + + /** + * Get the message body structure tokens. + */ + public function bodyStructure(): ?ListData + { + $structure = $this->attributes['BODYSTRUCTURE'] ?? null; + + return $structure instanceof ListData ? $structure : null; + } + + /** + * Get the message modification sequence. + */ + public function modSequence(): ?int + { + $sequence = $this->get('MODSEQ')[0] ?? null; + + return is_null($sequence) ? null : (int) $sequence; + } + + /** + * Get all returned attributes as PHP values. + */ + public function toArray(): array + { + return array_map($this->value(...), $this->attributes); + } + + /** + * Convert protocol tokens to PHP values without losing nested lists or NIL. + */ + protected function value(mixed $value): mixed + { + return match (true) { + $value instanceof Nil => null, + $value instanceof Data => array_map($this->value(...), $value->tokens()), + $value instanceof Token => $value->value, + is_array($value) => array_map($this->value(...), $value), + default => $value, + }; + } + + /** + * Create a message for the given folder. + */ + public function toMessage(FolderInterface $folder): Message + { + return new Message($folder, $this); + } +} diff --git a/src/FetchedResponse.php b/src/FetchedResponse.php new file mode 100644 index 0000000..99c6398 --- /dev/null +++ b/src/FetchedResponse.php @@ -0,0 +1,62 @@ +fetches() + ->map(fn (UntaggedResponse $response) => static::fromResponse($response)) + ->values() + ); + } + + /** + * Get the message sequence number. + */ + public function sequenceNumber(): int + { + return (int) $this->data->sequenceNumber(); + } + + /** + * Get the fetched message data. + */ + public function data(): FetchedMessageData + { + return $this->data; + } + + /** + * Get the original untagged response. + */ + public function response(): UntaggedResponse + { + return $this->response; + } +} diff --git a/src/FileMessage.php b/src/FileMessage.php index b3b7cba..700736d 100644 --- a/src/FileMessage.php +++ b/src/FileMessage.php @@ -32,6 +32,14 @@ public function size(): ?int return strlen($this->contents); } + /** + * {@inheritDoc} + */ + public function modSequence(): ?int + { + return null; + } + /** * {@inheritDoc} */ @@ -84,7 +92,7 @@ public function hasBodyStructure(): bool /** * {@inheritDoc} */ - public function bodyPart(string $partNumber, bool $peek = true): ?string + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string { throw new BadMethodCallException('FileMessage does not support fetching body parts'); } diff --git a/src/Folder.php b/src/Folder.php index b167679..5c1eb6e 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -5,12 +5,11 @@ use Closure; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; -use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Contracts\Support\Arrayable; -use Illuminate\Support\ItemNotFoundException; use JsonSerializable; class Folder implements Arrayable, FolderInterface, JsonSerializable @@ -23,7 +22,7 @@ class Folder implements Arrayable, FolderInterface, JsonSerializable public function __construct( protected Mailbox $mailbox, protected string $path, - protected array $flags = [], + protected array $attributes = [], protected string $delimiter = '/', ) {} @@ -44,13 +43,13 @@ public function path(): string } /** - * Get the folder flags. + * Get the folder attributes. * * @return string[] */ - public function flags(): array + public function attributes(): array { - return $this->flags; + return $this->attributes; } /** @@ -85,7 +84,7 @@ public function is(FolderInterface $folder): bool public function messages(): MessageQuery { // Ensure the folder is selected. - $this->select(true); + $this->select(); return new MessageQuery($this, new ImapQueryBuilder); } @@ -93,9 +92,9 @@ public function messages(): MessageQuery /** * {@inheritDoc} */ - public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void + public function events(callable $callback, callable|int $timeout = 300, OptionInterface ...$options): void { - if (! in_array('IDLE', $this->mailbox->capabilities())) { + if (! $this->mailbox->capabilities()->supports('IDLE')) { throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.'); } @@ -104,37 +103,15 @@ public function idle(callable $callback, ?callable $query = null, callable|int $ $timeout = $timeout(...); } - // The message query to use when fetching messages. - $query ??= fn (MessageQuery $query) => $query; - - // Fetch the message by message number. - $fetch = fn (int $msgn) => ( - $query($this->messages())->findOrFail($msgn, ImapFetchIdentifier::MessageNumber) - ); - - (new Idle(clone $this->mailbox, $this->path, $timeout))->await( - function (int $msgn) use ($callback, $fetch) { - if (! $this->mailbox->connected()) { - $this->mailbox->connect(); - } - - try { - $message = $fetch($msgn); - } catch (ItemNotFoundException) { - // The message wasn't found. We will skip - // it and continue awaiting new messages. - return; - } catch (Exception) { - // Something else happened. We will attempt - // reconnecting and re-fetching the message. - $this->mailbox->reconnect(); - - $message = $fetch($msgn); - } + (new Watch(clone $this->mailbox, $this->path, $timeout, $options))->await($callback); + } - $callback($message); - } - ); + /** + * {@inheritDoc} + */ + public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300, OptionInterface ...$options): void + { + (new Idle($this))->await($callback, $query, $timeout, ...$options); } /** @@ -142,19 +119,17 @@ function (int $msgn) use ($callback, $fetch) { */ public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void { + if (is_callable($frequency) && ! $frequency instanceof Closure) { + $frequency = $frequency(...); + } + (new Poll(clone $this->mailbox, $this->path, $frequency))->start( function (MessageInterface $message) use ($callback) { if (! $this->mailbox->connected()) { $this->mailbox->connect(); } - try { - $callback($message); - } catch (Exception) { - // Something unexpected happened. We will attempt - // reconnecting and continue polling for messages. - $this->mailbox->reconnect(); - } + return $callback($message); }, $query ?? fn (MessageQuery $query) => $query ); @@ -173,9 +148,9 @@ public function move(string $newPath): void /** * {@inheritDoc} */ - public function select(bool $force = false): void + public function select(bool $force = false, OptionInterface ...$options): Result { - $this->mailbox->select($this, $force); + return $this->mailbox->select($this, $force, ...$options); } /** @@ -183,13 +158,15 @@ public function select(bool $force = false): void */ public function quota(): array { - if (! in_array('QUOTA', $this->mailbox->capabilities())) { + if (! $this->mailbox->capabilities()->supports('QUOTA')) { throw new ImapCapabilityException( 'Unable to fetch mailbox quotas. IMAP server does not support QUOTA capability.' ); } - $responses = $this->mailbox->connection()->quotaRoot($this->path); + $responses = $this->mailbox->connection()->getQuotaRoot($this->path)->filter( + fn (UntaggedResponse $response) => $response->type()->is('QUOTA') + ); $values = []; @@ -233,7 +210,7 @@ public function status(): array */ public function examine(): array { - return $this->mailbox->connection()->examine($this->path)->map( + return $this->mailbox->examine($this)->responses()->untagged()->map( fn (UntaggedResponse $response) => $response->toArray() )->all(); } @@ -241,11 +218,14 @@ public function examine(): array /** * {@inheritDoc} */ - public function expunge(): array + public function expunge(array|int|null $uids = null): array { - return $this->mailbox->connection()->expunge()->map( - fn (UntaggedResponse $response) => $response->tokenAt(1)->value - )->all(); + $this->select(); + + return $this->mailbox->connection()->expunge($uids) + ->filter(fn (UntaggedResponse $response) => $response->tokenAt(2)->is('EXPUNGE')) + ->map(fn (UntaggedResponse $response) => $response->tokenAt(1)->value) + ->all(); } /** @@ -263,7 +243,7 @@ public function toArray(): array { return [ 'path' => $this->path, - 'flags' => $this->flags, + 'attributes' => $this->attributes, 'delimiter' => $this->delimiter, ]; } diff --git a/src/FolderData.php b/src/FolderData.php new file mode 100644 index 0000000..e1dd897 --- /dev/null +++ b/src/FolderData.php @@ -0,0 +1,32 @@ +value; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + return $this->value; + } + + /** + * {@inheritDoc} + */ + public function capability(): string + { + return $this->value; + } +} diff --git a/src/FolderDataItemInterface.php b/src/FolderDataItemInterface.php new file mode 100644 index 0000000..5d574e5 --- /dev/null +++ b/src/FolderDataItemInterface.php @@ -0,0 +1,21 @@ + + */ + protected array $dataItems = []; + /** * Constructor. */ @@ -15,6 +23,18 @@ public function __construct( protected Mailbox $mailbox ) {} + /** + * {@inheritDoc} + */ + public function with(FolderDataItemInterface ...$items): static + { + foreach ($items as $item) { + $this->dataItems[$item->key()] = $item; + } + + return $this; + } + /** * {@inheritDoc} */ @@ -56,11 +76,23 @@ public function firstOrCreate(string $path): FolderInterface */ public function get(?string $match = '*', ?string $reference = ''): FolderCollection { - return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map( + $return = array_map(function (FolderDataItemInterface $item) { + if (! $this->mailbox->capabilities()->supports($item->capability())) { + throw new ImapCapabilityException( + "Unable to fetch {$item->key()} folder data. IMAP server does not support {$item->capability()} capability." + ); + } + + return $item->toImap(); + }, $this->dataItems); + + return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match), return: $return)->filter( + fn (UntaggedResponse $response) => $response->type()->is('LIST') + )->map( fn (UntaggedResponse $response) => new Folder( mailbox: $this->mailbox, path: $response->tokenAt(4)->value, - flags: $response->tokenAt(2)->values(), + attributes: $response->tokenAt(2)->values(), delimiter: $response->tokenAt(3)->value, ) )->pipeInto(FolderCollection::class); diff --git a/src/FolderRepositoryInterface.php b/src/FolderRepositoryInterface.php index a3a85c4..0da999e 100644 --- a/src/FolderRepositoryInterface.php +++ b/src/FolderRepositoryInterface.php @@ -6,6 +6,11 @@ interface FolderRepositoryInterface { + /** + * Add items to the folder LIST request. + */ + public function with(FolderDataItemInterface ...$items): static; + /** * Find a folder. */ diff --git a/src/HasFlags.php b/src/HasFlags.php index 791c460..06e9497 100644 --- a/src/HasFlags.php +++ b/src/HasFlags.php @@ -4,7 +4,7 @@ use BackedEnum; use DirectoryTree\ImapEngine\Enums\ImapFlag; -use DirectoryTree\ImapEngine\Support\Str; +use DirectoryTree\ImapEngine\Support\Enum; trait HasFlags { @@ -173,7 +173,7 @@ public function isRecent(): bool */ public function hasFlag(BackedEnum|string $flag): bool { - return in_array(Str::enum($flag), $this->flags()); + return in_array(Enum::value($flag), $this->flags()); } /** diff --git a/src/Idle.php b/src/Idle.php index 9be0f24..918e388 100644 --- a/src/Idle.php +++ b/src/Idle.php @@ -2,172 +2,125 @@ namespace DirectoryTree\ImapEngine; -use Carbon\Carbon; -use Carbon\CarbonInterface; -use Closure; -use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; -use DirectoryTree\ImapEngine\Connection\Tokens\Atom; -use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException; -use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; +use DirectoryTree\ImapEngine\Idle\Events\EventInterface; +use DirectoryTree\ImapEngine\Idle\Events\FolderSelected; +use DirectoryTree\ImapEngine\Idle\Events\MessagesExist; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; use Generator; class Idle { /** - * Constructor. + * The first UID that has not yet been delivered. */ - public function __construct( - protected Mailbox $mailbox, - protected string $folder, - protected Closure|int $timeout, - ) {} + protected ?int $nextUid = null; /** - * Destructor. + * The watching connection's latest folder selection. */ - public function __destruct() - { - $this->disconnect(); - } + protected ?Result $selection = null; /** - * Await new messages on the connection. + * Constructor. */ - public function await(callable $callback): void - { - $this->connect(); - - while ($ttl = $this->getNextTimeout()) { - try { - $this->listen($callback, $ttl); - } catch (ImapConnectionTimedOutException) { - $this->restart(); - } catch (ImapConnectionClosedException) { - $this->reconnect(); - } - } - } + public function __construct( + protected FolderInterface $folder + ) {} /** - * Start listening for new messages using the idle() generator. + * Await new messages, optionally customizing their query. + * + * @param callable(MessageInterface): mixed $callback + * @param (callable(MessageQueryInterface): MessageQueryInterface)|null $query */ - protected function listen(callable $callback, CarbonInterface $ttl): void + public function await(callable $callback, ?callable $query = null, callable|int $timeout = 300, OptionInterface ...$options): void { - // Iterate over responses yielded by the idle generator. - foreach ($this->idle($ttl) as $response) { - if (! $response instanceof UntaggedResponse) { - continue; - } - - if (! $token = $response->tokenAt(2)) { - continue; - } + $this->folder->events(function (EventInterface $event) use ($callback, $query) { + if ($event instanceof FolderSelected) { + $resuming = $this->selection !== null + && $this->selection->uidValidity() === $event->selection()->uidValidity(); - if ($token instanceof Atom && $token->is('EXISTS')) { - $msgn = (int) $response->tokenAt(1)->value; + if (! $resuming) { + $this->nextUid = $event->selection()->uidNext() ?? $this->getNextUid(); + } - $callback($msgn); + $this->selection = $event->selection(); - $ttl = $this->getNextTimeout(); + if ($resuming) { + return $this->deliver($callback, $query); + } } - if ($ttl === false) { - break; - } - - // If we've been idle too long, break out to restart the session. - if (Carbon::now()->greaterThanOrEqualTo($ttl)) { - $this->restart(); - - break; + if ($event instanceof MessagesExist) { + return $this->deliver($callback, $query); } - } + }, $timeout, ...$options); } /** - * Get the folder to idle. + * Retrieve arrivals and deliver them in UID order. */ - protected function folder(): FolderInterface + protected function deliver(callable $callback, ?callable $query): ?bool { - return $this->mailbox->folders()->findOrFail($this->folder); - } + foreach ($this->messages($query) as $message) { + if ($callback($message) === false) { + return false; + } - /** - * Issue a done command and restart the idle session. - */ - protected function restart(): void - { - try { - // Send DONE to terminate the current IDLE session gracefully. - $this->done(); - } catch (Exception) { - $this->reconnect(); + $this->nextUid = $message->uid() + 1; } - } - - /** - * Reconnect the client and restart the idle session. - */ - protected function reconnect(): void - { - $this->mailbox->disconnect(); - $this->connect(); + return null; } /** - * Connect the client and select the folder to idle. + * Retrieve arrivals, retrying once if the application connection is lost. + * + * @return Generator */ - protected function connect(): void + protected function messages(?callable $query): Generator { - $this->mailbox->connect(); + try { + yield from $this->fetch($query); + } catch (ImapConnectionClosedException) { + $this->folder->mailbox()->reconnect(); - $this->mailbox->select($this->folder(), true); + yield from $this->fetch($query); + } } /** - * Disconnect the client. + * Fetch arrivals that belong to the watching connection's UID validity. + * + * @return Generator */ - protected function disconnect(): void + protected function fetch(?callable $query): Generator { - try { - // Attempt to terminate IDLE gracefully. - $this->done(); - } catch (Exception) { - // Do nothing. + $current = $this->folder->select(true); + + if ($this->selection->uidValidity() !== null && $current->uidValidity() !== $this->selection->uidValidity()) { + return; } - $this->mailbox->disconnect(); - } + $messages = $this->folder->messages()->with(MessageData::flags()); - /** - * End the current IDLE session. - */ - protected function done(): void - { - $this->mailbox->connection()->done(); - } + $messages = $query ? $query($messages) : $messages; - /** - * Begin a new IDLE session as a generator. - */ - protected function idle(CarbonInterface $ttl): Generator - { - yield from $this->mailbox->connection()->idle( - (int) Carbon::now()->diffInSeconds($ttl, true) - ); + foreach ($messages->uid($this->nextUid.':*')->orderByUid()->cursor() as $message) { + // Reversed IMAP ranges can include an older UID when no arrivals exist. + if ($message->uid() >= $this->nextUid) { + yield $message; + } + } } /** - * Get the next timeout as a Carbon instance. + * Determine the next UID from the folder's existing messages. */ - protected function getNextTimeout(): CarbonInterface|false + protected function getNextUid(): int { - if (is_numeric($seconds = value($this->timeout))) { - return Carbon::now()->addSeconds(abs($seconds)); - } - - return false; + return ($this->folder->messages()->orderByUid('desc')->first()?->uid() ?? 0) + 1; } } diff --git a/src/Idle/Events/EventInterface.php b/src/Idle/Events/EventInterface.php new file mode 100644 index 0000000..5b78f27 --- /dev/null +++ b/src/Idle/Events/EventInterface.php @@ -0,0 +1,16 @@ +folder; + } + + /** + * Get the event type. + */ + public function type(): string + { + return 'SELECT'; + } + + /** + * Get the result of selecting the folder. + */ + public function selection(): Result + { + return $this->selection; + } +} diff --git a/src/Idle/Events/MessageExpunged.php b/src/Idle/Events/MessageExpunged.php new file mode 100644 index 0000000..66b7004 --- /dev/null +++ b/src/Idle/Events/MessageExpunged.php @@ -0,0 +1,14 @@ +response->type()->value; + } +} diff --git a/src/Idle/Events/MessageFetched.php b/src/Idle/Events/MessageFetched.php new file mode 100644 index 0000000..d505d0e --- /dev/null +++ b/src/Idle/Events/MessageFetched.php @@ -0,0 +1,25 @@ +response->type()->value; + } + + /** + * Get the parsed message data from this FETCH response. + */ + public function changes(): FetchResult + { + return FetchResult::fromResponses(new ResponseCollection([$this->response])); + } +} diff --git a/src/Idle/Events/MessagesExist.php b/src/Idle/Events/MessagesExist.php new file mode 100644 index 0000000..1f578d0 --- /dev/null +++ b/src/Idle/Events/MessagesExist.php @@ -0,0 +1,14 @@ +response->type()->value; + } +} diff --git a/src/Idle/Events/MessagesVanished.php b/src/Idle/Events/MessagesVanished.php new file mode 100644 index 0000000..1572597 --- /dev/null +++ b/src/Idle/Events/MessagesVanished.php @@ -0,0 +1,27 @@ + + */ + public function uids(): LazyCollection + { + return Vanished::fromResponse($this->response)->uids(); + } + + /** + * Determine whether the server marked the VANISHED response as EARLIER. + */ + public function earlier(): bool + { + return Vanished::fromResponse($this->response)->earlier(); + } +} diff --git a/src/Idle/Events/ResponseEvent.php b/src/Idle/Events/ResponseEvent.php new file mode 100644 index 0000000..6c7e909 --- /dev/null +++ b/src/Idle/Events/ResponseEvent.php @@ -0,0 +1,45 @@ +folder; + } + + /** + * Get the protocol response type. + */ + public function type(): string + { + return strtoupper( + $this->response->type() instanceof Number + ? $this->response->tokenAt(2)->value + : $this->response->type()->value + ); + } + + /** + * Get the original server response. + */ + public function response(): UntaggedResponse + { + return $this->response; + } +} diff --git a/src/Idle/Events/UnknownEvent.php b/src/Idle/Events/UnknownEvent.php new file mode 100644 index 0000000..7bd540e --- /dev/null +++ b/src/Idle/Events/UnknownEvent.php @@ -0,0 +1,5 @@ + + */ + 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..9ba7938 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -2,13 +2,19 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\Loggers\EchoLogger; use DirectoryTree\ImapEngine\Connection\Loggers\FileLogger; use DirectoryTree\ImapEngine\Connection\Streams\ImapStream; use DirectoryTree\ImapEngine\Connection\Tokens\Token; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\RequiresEnablementInterface; +use DirectoryTree\ImapEngine\Selection\Result; use Exception; +use InvalidArgumentException; class Mailbox implements MailboxInterface { @@ -24,7 +30,7 @@ class Mailbox implements MailboxInterface 'password' => '', 'encryption' => 'ssl', 'validate_cert' => true, - 'authentication' => 'plain', + 'authentication' => 'login', 'proxy' => [ 'socket' => null, 'username' => null, @@ -34,16 +40,21 @@ class Mailbox implements MailboxInterface ]; /** - * The cached mailbox capabilities. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1 + * The result from the currently selected folder. + */ + protected ?Result $selection = null; + + /** + * The currently selected or examined folder. */ - protected ?array $capabilities = null; + protected ?FolderInterface $folder = null; /** - * The currently selected folder. + * The cached mailbox capabilities. + * + * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1 */ - protected ?FolderInterface $selected = null; + protected ?Capabilities $capabilities = null; /** * The mailbox connection. @@ -63,7 +74,18 @@ public function __construct(array $config = []) */ public function __clone(): void { + $this->reset(); + } + + /** + * Reset the connection state. + */ + protected function reset(): void + { + $this->folder = null; + $this->selection = null; $this->connection = null; + $this->capabilities = null; } /** @@ -79,10 +101,6 @@ public static function make(array $config = []): static */ public function config(?string $key = null, mixed $default = null): mixed { - if (is_null($key)) { - return $this->config; - } - return data_get($this->config, $key, $default); } @@ -109,10 +127,14 @@ public function connected(): bool /** * {@inheritDoc} */ - public function reconnect(): void + public function reconnect(?string $password = null): void { $this->disconnect(); + if (! is_null($password)) { + $this->config['password'] = $password; + } + $this->connect(); } @@ -150,17 +172,19 @@ class_exists($debug) => new $debug, */ protected function authenticate(): void { - if ($this->config('authentication') === 'oauth') { - $this->connection->authenticate( - $this->config('username'), - $this->config('password') - ); - } else { - $this->connection->login( - $this->config('username'), - $this->config('password'), - ); - } + $username = $this->config('username'); + $password = $this->config('password'); + + match ($this->config('authentication')) { + 'login' => $this->connection->login($username, $password), + 'xoauth2' => (new Authentication( + $this->connection, + new Authentication\XOAuth2($username, $password), + ))->authenticate(), + default => throw new InvalidArgumentException( + 'Unsupported authentication mechanism.' + ), + }; } /** @@ -174,7 +198,7 @@ public function disconnect(): void } catch (Exception) { // Do nothing. } finally { - $this->connection = null; + $this->reset(); } } @@ -202,24 +226,124 @@ public function folders(): FolderRepositoryInterface /** * {@inheritDoc} */ - public function capabilities(): array + public function capabilities(): Capabilities { - return $this->capabilities ??= array_map( - fn (Token $token) => $token->value, - $this->connection()->capability()->tokensAfter(2) + return $this->capabilities ??= Capabilities::from( + ...array_map( + fn (Token $token) => Capability::make($token->value), + $this->connection()->capability()->tokensAfter(2) + ) ); } /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false): void + public function enable(string ...$capabilities): ResponseCollection { - if (! $this->selected($folder) || $force) { - $this->connection()->select($folder->path()); + $current = $this->capabilities(); + + $requested = Capabilities::from( + ...array_map(fn (string $capability) => Capability::make($capability), $capabilities) + ); + + foreach ($requested->all() as $capability) { + if (! $current->has($capability)) { + throw new ImapCapabilityException( + "Unable to enable capability [$capability]. IMAP server does not support it." + ); + } + } + + $requested = array_values(array_filter( + $requested->all(), + fn (string $capability) => ! $current->enabled($capability), + )); + + if (empty($requested)) { + return new ResponseCollection; + } + + if ($this->folder) { + throw new ImapCapabilityException( + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.' + ); } - $this->selected = $folder; + $responses = $this->connection()->enable(...$requested); + + $items = $current->items(); + + foreach ($responses as $response) { + if (! $response->type()->is('ENABLED')) { + continue; + } + + foreach ($response->tokensAfter(2) as $token) { + $capability = Capability::make($token->value, enabled: true); + + if ($current->has($capability->name())) { + $items[$capability->name()] = $capability; + } + } + } + + $this->capabilities = Capabilities::from(...array_values($items)); + + return $responses; + } + + /** + * {@inheritDoc} + */ + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result + { + foreach ($options as $option) { + if (! $this->capabilities()->supports($option->capability())) { + throw new ImapCapabilityException( + "Unable to select folder with [{$option->capability()}]. IMAP server does not support it." + ); + } + + if ($option instanceof RequiresEnablementInterface) { + $this->enable($option->capability()); + + if (! $this->capabilities()->enabled($option->capability())) { + throw new ImapCapabilityException( + "Unable to select folder with [{$option->capability()}]. IMAP server did not enable it." + ); + } + } + } + + if (! $this->selected($folder) || $force || $options) { + $this->folder = null; + $this->selection = null; + + $selection = $this->connection()->select($folder->path(), ...$options); + + $this->folder = $folder; + $this->selection = $selection; + } + + return $this->selection; + } + + /** + * {@inheritDoc} + */ + public function examine(FolderInterface $folder): Result + { + // EXAMINE replaces the server selection with a read-only one, even + // for the same folder. The next query must select it again. + $this->folder = null; + $this->selection = null; + + $selection = $this->connection()->examine($folder->path()); + + $this->folder = $folder; + + return $selection; } /** @@ -227,6 +351,6 @@ public function select(FolderInterface $folder, bool $force = false): void */ public function selected(FolderInterface $folder): bool { - return $this->selected?->is($folder) ?? false; + return $this->selection && $this->folder?->is($folder); } } diff --git a/src/MailboxInterface.php b/src/MailboxInterface.php index fba71c1..37e075c 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -2,7 +2,10 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; interface MailboxInterface { @@ -22,9 +25,12 @@ public function connection(): ConnectionInterface; public function connected(): bool; /** - * Force a reconnection to the server. + * Reconnect to the same account, optionally replacing the stored password or token. + * + * A null password retains the current credentials. A replacement is retained + * for subsequent connections, even if authentication fails. */ - public function reconnect(): void; + public function reconnect(?string $password = null): void; /** * Connect to the server. @@ -49,12 +55,24 @@ public function folders(): FolderRepositoryInterface; /** * Get the mailbox's capabilities. */ - public function capabilities(): array; + public function capabilities(): Capabilities; + + /** + * Enable the given mailbox capabilities for the current connection. + * + * Call this before selecting or examining any folder. + */ + public function enable(string ...$capabilities): ResponseCollection; /** * Select the given folder. */ - public function select(FolderInterface $folder, bool $force = false): void; + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result; + + /** + * Examine the given folder, invalidating the cached writable selection. + */ + public function examine(FolderInterface $folder): Result; /** * Determine if the given folder is selected. diff --git a/src/Message.php b/src/Message.php index 9938f9f..8598aba 100644 --- a/src/Message.php +++ b/src/Message.php @@ -8,8 +8,10 @@ use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; -use DirectoryTree\ImapEngine\Support\Str; +use DirectoryTree\ImapEngine\Support\BodyPartDecoder; +use DirectoryTree\ImapEngine\Support\Enum; use Illuminate\Contracts\Support\Arrayable; +use InvalidArgumentException; use JsonSerializable; use ZBateson\MailMimeParser\Header\DateHeader; use ZBateson\MailMimeParser\Header\HeaderConsts; @@ -33,12 +35,7 @@ class Message implements Arrayable, JsonSerializable, MessageInterface */ public function __construct( protected FolderInterface $folder, - protected int $uid, - protected array $flags, - protected string $head, - protected string $body, - protected ?int $size = null, - protected ?ListData $bodyStructureData = null, + protected FetchedMessageData $data, ) {} /** @@ -47,7 +44,7 @@ public function __construct( public function __sleep(): array { // We don't want to serialize the parsed message. - return ['folder', 'uid', 'flags', 'head', 'body', 'size']; + return ['folder', 'data']; } /** @@ -58,12 +55,20 @@ public function folder(): FolderInterface return $this->folder; } + /** + * Get all data fetched for the message. + */ + public function data(): FetchedMessageData + { + return $this->data; + } + /** * Get the message's identifier. */ public function uid(): int { - return $this->uid; + return $this->data->uid(); } /** @@ -71,7 +76,15 @@ public function uid(): int */ public function size(): ?int { - return $this->size; + return $this->data->size(); + } + + /** + * Get the message modification sequence. + */ + public function modSequence(): ?int + { + return $this->data->modSequence(); } /** @@ -79,7 +92,7 @@ public function size(): ?int */ public function flags(): array { - return $this->flags; + return $this->data->flags(); } /** @@ -87,11 +100,11 @@ public function flags(): array */ public function head(bool $fetch = false): string { - if (! $this->head && $fetch) { - $this->head = $this->fetchHead() ?? ''; + if (! $this->data->has('BODY[HEADER]') && $fetch) { + $this->fetchHead(); } - return $this->head; + return $this->data->head(); } /** @@ -99,7 +112,7 @@ public function head(bool $fetch = false): string */ public function hasHead(): bool { - return ! empty($this->head); + return $this->head() !== ''; } /** @@ -107,7 +120,7 @@ public function hasHead(): bool */ public function body(): string { - return $this->body; + return $this->data->body(); } /** @@ -115,7 +128,7 @@ public function body(): string */ public function hasBody(): bool { - return ! empty($this->body); + return $this->body() !== ''; } /** @@ -127,18 +140,20 @@ public function bodyStructure(bool $fetch = false): ?BodyStructureCollection return $this->bodyStructure; } - if (! $this->bodyStructureData && $fetch) { - $this->bodyStructureData = $this->fetchBodyStructureData(); + if (! $this->data->has('BODYSTRUCTURE') && $fetch) { + $this->fetchBodyStructureData(); } - if (! $tokens = $this->bodyStructureData?->tokens()) { + $structure = $this->data->bodyStructure(); + + if (! $tokens = $structure?->tokens()) { return null; } // If the first token is a list, it's a multipart message. return $this->bodyStructure = head($tokens) instanceof ListData - ? BodyStructureCollection::fromListData($this->bodyStructureData) - : new BodyStructureCollection(parts: [BodyStructurePart::fromListData($this->bodyStructureData)]); + ? BodyStructureCollection::fromListData($structure) + : new BodyStructureCollection(parts: [BodyStructurePart::fromListData($structure)]); } /** @@ -146,7 +161,7 @@ public function bodyStructure(bool $fetch = false): ?BodyStructureCollection */ public function hasBodyStructure(): bool { - return (bool) $this->bodyStructureData; + return ! is_null($this->data->bodyStructure()); } /** @@ -155,7 +170,7 @@ public function hasBodyStructure(): bool public function is(MessageInterface $message): bool { return $message instanceof self - && $this->uid === $message->uid + && $this->uid() === $message->uid() && $this->folder->is($message->folder); } @@ -164,20 +179,32 @@ public function is(MessageInterface $message): bool */ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void { - $flag = Str::enum($flag); + $flag = Enum::value($flag); - $this->folder->mailbox() + $result = $this->folder->mailbox() ->connection() - ->store($flag, $this->uid, mode: $operation); + ->store($this->uid(), $flag, mode: $operation); + + $receivedFlags = false; + + foreach ($result->messages() as $data) { + $receivedFlags = $receivedFlags || $data->has('FLAGS'); + + $this->data = $this->data->merge($data); + } if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($this->uid()); } - $this->flags = match ($operation) { - '+' => array_unique(array_merge($this->flags, [$flag])), - '-' => array_diff($this->flags, [$flag]), - }; + if (! $receivedFlags) { + $this->data = $this->data->merge([ + 'FLAGS' => match ($operation) { + '+' => array_unique(array_merge($this->flags(), [$flag])), + '-' => array_diff($this->flags(), [$flag]), + }, + ]); + } } /** @@ -187,15 +214,13 @@ public function copy(string $folder): ?int { $mailbox = $this->folder->mailbox(); - $capabilities = $mailbox->capabilities(); - - if (! in_array('UIDPLUS', $capabilities)) { + if (! $mailbox->capabilities()->supports('UIDPLUS')) { throw new ImapCapabilityException( 'Unable to copy message. IMAP server does not support UIDPLUS capability' ); } - $response = $mailbox->connection()->copy($folder, $this->uid); + $response = $mailbox->connection()->copy($this->uid(), $folder); return MessageResponseParser::getUidFromCopy($response); } @@ -209,19 +234,13 @@ public function move(string $folder, bool $expunge = false): ?int { $mailbox = $this->folder->mailbox(); - $capabilities = $mailbox->capabilities(); - switch (true) { - case in_array('MOVE', $capabilities): - $response = $mailbox->connection()->move($folder, $this->uid); - - if ($expunge) { - $this->folder->expunge(); - } + case $mailbox->capabilities()->supports('MOVE'): + $response = $mailbox->connection()->move($this->uid(), $folder); return MessageResponseParser::getUidFromCopy($response); - case in_array('UIDPLUS', $capabilities): + case $mailbox->capabilities()->supports('UIDPLUS'): $uid = $this->copy($folder); $this->delete($expunge); @@ -381,7 +400,7 @@ public function text(bool $fetch = false): ?string { if ($fetch && ! $this->hasBody()) { if ($part = $this->bodyStructure(fetch: true)?->text()) { - return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); + return BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); } } @@ -399,7 +418,7 @@ public function html(bool $fetch = false): ?string { if ($fetch && ! $this->hasBody()) { if ($part = $this->bodyStructure(fetch: true)?->html()) { - return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); + return BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); } } @@ -449,25 +468,41 @@ public function attachmentCount(): int } /** - * Fetch a specific body part by part number. + * Fetch a body section, optionally limited to a transfer-encoded byte range. */ - public function bodyPart(string $partNumber, bool $peek = true): ?string + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string { + $item = MessageData::section($partNumber); + + if ($length !== null) { + $item = $item->partial($offset, $length); + } elseif ($offset !== 0) { + throw new InvalidArgumentException('A partial fetch requires a length.'); + } + + $key = $item->key(); + + if ($length === null && $peek && $this->data->has($key)) { + return $this->data->get($key); + } + + if ($length !== null) { + $this->folder->select(); + } + $response = $this->folder->mailbox() ->connection() - ->bodyPart($partNumber, $this->uid, $peek); + ->fetch($this->uid(), ($peek ? $item->peek() : $item)->toImap()); - if ($response->isEmpty()) { + if (! $data = $response->messages()->first()) { return null; } - $data = $response->first()->tokenAt(3); - - if (! $data instanceof ListData) { - return null; + if ($length === null) { + $this->data = $this->data->merge($data); } - return $data->lookup("[$partNumber]")?->value; + return $data->get($key); } /** @@ -491,13 +526,7 @@ public function restore(): void */ public function toArray(): array { - return [ - 'uid' => $this->uid, - 'flags' => $this->flags, - 'head' => $this->head, - 'body' => $this->body, - 'size' => $this->size, - ]; + return $this->data->toArray(); } /** @@ -506,8 +535,8 @@ public function toArray(): array public function __toString(): string { return implode("\r\n\r\n", array_filter([ - rtrim($this->head), - ltrim($this->body), + rtrim($this->head()), + ltrim($this->body()), ])); } @@ -535,19 +564,15 @@ protected function fetchHead(): ?string $response = $this->folder ->mailbox() ->connection() - ->bodyHeader($this->uid); + ->fetch($this->uid(), 'BODY.PEEK[HEADER]'); - if ($response->isEmpty()) { + if (! $data = $response->messages()->first()) { return null; } - $data = $response->first()->tokenAt(3); - - if (! $data instanceof ListData) { - return null; - } + $this->data = $this->data->merge($data); - return $data->lookup('[HEADER]')?->value; + return $data->get('BODY[HEADER]'); } /** @@ -558,18 +583,14 @@ protected function fetchBodyStructureData(): ?ListData $response = $this->folder ->mailbox() ->connection() - ->bodyStructure($this->uid); + ->fetch($this->uid(), 'BODYSTRUCTURE'); - if ($response->isEmpty()) { + if (! $data = $response->messages()->first()) { return null; } - $data = $response->first()->tokenAt(3); - - if (! $data instanceof ListData) { - return null; - } + $this->data = $this->data->merge($data); - return $data->lookup('BODYSTRUCTURE'); + return $data->bodyStructure(); } } diff --git a/src/MessageData.php b/src/MessageData.php new file mode 100644 index 0000000..e9fc2dc --- /dev/null +++ b/src/MessageData.php @@ -0,0 +1,65 @@ +value; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + return $this->value; + } +} diff --git a/src/MessageData/Body.php b/src/MessageData/Body.php new file mode 100644 index 0000000..6291b1f --- /dev/null +++ b/src/MessageData/Body.php @@ -0,0 +1,95 @@ +offset = $offset; + $item->length = $length; + + return $item; + } + + /** + * Constructor. + */ + public function __construct( + protected string $section, + protected bool $peek = false, + ) {} + + /** + * Create a message header data item. + */ + public static function headers(): static + { + return new static('HEADER'); + } + + /** + * Create a message text data item. + */ + public static function text(): static + { + return new static('TEXT'); + } + + /** + * Create a message body section data item. + */ + public static function section(string $section): static + { + return new static($section); + } + + /** + * Fetch the message body section without setting the seen flag. + */ + public function peek(): static + { + $item = clone $this; + $item->peek = true; + + return $item; + } + + /** + * {@inheritDoc} + */ + public function key(): string + { + return "BODY[{$this->section}]".(is_null($this->offset) ? '' : "<{$this->offset}>"); + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + $item = $this->peek ? 'BODY.PEEK' : 'BODY'; + + return "{$item}[{$this->section}]".(is_null($this->offset) ? '' : "<{$this->offset}.{$this->length}>"); + } +} diff --git a/src/MessageData/FetchItemInterface.php b/src/MessageData/FetchItemInterface.php new file mode 100644 index 0000000..04062e3 --- /dev/null +++ b/src/MessageData/FetchItemInterface.php @@ -0,0 +1,16 @@ +ordering = new UidOrder(SortDirection::Descending); + } /** * Count all available messages matching the current search criteria. @@ -68,22 +72,73 @@ public function firstOrFail(): MessageInterface */ public function get(): MessageCollection { - return $this->process($this->sortKey ? $this->sort() : $this->search()); + return $this->process($this->uids()); } /** - * Append a new message to the folder. + * {@inheritDoc} + */ + public function cursor(int $chunkSize = 10): Generator + { + $query = clone $this; + $uids = $query->uids(); + $chunkSize = max(1, $chunkSize); + + for ($page = 1; $page <= (int) ceil($uids->count() / $chunkSize); $page++) { + foreach ($query->limit($chunkSize, $page)->populate($uids) as $message) { + yield $message; + } + } + } + + /** + * {@inheritDoc} */ - public function append(string $message, mixed $flags = null): int + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult { - $response = $this->connection()->append( - $this->folder->path(), $message, (array) Str::enums($flags), + if ($uids === []) { + return new FetchResult; + } + + $capability = $vanished ? 'QRESYNC' : 'CONDSTORE'; + + $mailbox = $this->folder->mailbox(); + + $supported = $mailbox->capabilities()->supports($capability) + || ($capability === 'CONDSTORE' && $mailbox->capabilities()->supports('QRESYNC')); + + if (! $supported) { + throw new ImapCapabilityException( + "Unable to fetch message changes. IMAP server does not support $capability capability." + ); + } + + if ($vanished && ! $mailbox->capabilities()->enabled('QRESYNC')) { + throw new ImapCapabilityException( + 'Enable QRESYNC before selecting a folder to request vanished messages.' + ); + } + + $items = array_map( + fn (FetchItemInterface $item) => $item->toImap(), + $this->fetchItems, ); - return (int) $response // TAG4 OK [APPENDUID ] APPEND completed. - ->tokenAt(2) // [APPENDUID ] - ->tokenAt(2) // - ->value; + if (empty($items)) { + $items[] = MessageData::flags()->toImap(); + } + + return $this->connection()->fetch($uids, $items, modifiers: new ChangedSince($modSequence, $vanished)); + } + + /** + * Append a new message to the folder. + */ + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult + { + return $this->connection()->append( + $this->folder->path(), $message, (array) Enum::values($flags), $date, + ); } /** @@ -108,8 +163,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->uids(); // Calculate how many chunks there are $totalChunks = (int) ceil($messages->count() / $chunkSize); @@ -167,34 +222,25 @@ public function paginate(int $perPage = 5, $page = null, string $pageName = 'pag /** * Find a message by the given identifier type or throw an exception. */ - public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface + public function findOrFail(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): MessageInterface { - /** @var UntaggedResponse $response */ - $response = $this->id($id, $identifier)->firstOrFail(); + $data = $this->id($id, $identifier) ?? throw new ItemNotFoundException; - $uid = $response->tokenAt(3) // ListData - ->tokenAt(1) // Atom - ->value; // UID - - return $this->process(new MessageCollection([$uid]))->firstOrFail(); + return $this->process(new MessageCollection([$data->uid()]))->firstOrFail(); } /** * Find a message by the given identifier type. */ - public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface + public function find(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?MessageInterface { - $response = $this->id($id, $identifier)->first(); + $data = $this->id($id, $identifier); - if (! $response instanceof UntaggedResponse) { + if (! $data) { return null; } - $uid = $response->tokenAt(3) // ListData - ->tokenAt(1) // Atom - ->value; // UID - - return $this->process(new MessageCollection([$uid]))->first(); + return $this->process(new MessageCollection([$data->uid()]))->first(); } /** @@ -206,10 +252,10 @@ public function destroy(array|int $uids, bool $expunge = false): void $this->folder->mailbox() ->connection() - ->store([ImapFlag::Deleted->value], $uids, mode: '+'); + ->store($uids, [ImapFlag::Deleted->value], mode: '+'); if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($uids); } } @@ -224,14 +270,10 @@ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = return 0; } - $this->connection()->store( - (array) Str::enums($flag), - $uids, - mode: $operation - ); + $this->connection()->store($uids, (array) Enum::values($flag), mode: $operation); if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($uids); } return count($uids); @@ -288,11 +330,7 @@ public function move(string $folder, bool $expunge = false): int return 0; } - $this->connection()->move($folder, $uids); - - if ($expunge) { - $this->folder->expunge(); - } + $this->connection()->move($uids, $folder); return count($uids); } @@ -308,7 +346,7 @@ public function copy(string $folder): int return 0; } - $this->connection()->copy($folder, $uids); + $this->connection()->copy($uids, $folder); return count($uids); } @@ -334,17 +372,8 @@ protected function populate(Collection $uids): MessageCollection $messages->total($uids->count()); - foreach ($this->fetch($uids) as $uid => $response) { - $messages->push( - $this->newMessage( - $uid, - $response['flags'] ?? [], - $response['head'] ?? '', - $response['body'] ?? '', - $response['size'] ?? null, - $response['bodystructure'] ?? null, - ) - ); + foreach ($this->fetch($uids) as $data) { + $messages->push($data->toMessage($this->folder)); } return $messages; @@ -352,84 +381,49 @@ protected function populate(Collection $uids): MessageCollection /** * Fetch a given id collection. + * + * @return Collection */ - protected function fetch(Collection $messages): array + protected function fetch(Collection $messages): Collection { - // 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), }; } $uids = $messages->forPage($this->page, $this->limit)->values(); - $fetch = []; - - if ($this->fetchFlags) { - $fetch[] = 'FLAGS'; - } - - if ($this->fetchSize) { - $fetch[] = 'RFC822.SIZE'; - } - - if ($this->fetchHeaders) { - $fetch[] = $this->fetchAsUnread - ? 'BODY.PEEK[HEADER]' - : 'BODY[HEADER]'; - } - - if ($this->fetchBody) { - $fetch[] = $this->fetchAsUnread - ? 'BODY.PEEK[TEXT]' - : 'BODY[TEXT]'; - } - - if ($this->fetchBodyStructure) { - $fetch[] = 'BODYSTRUCTURE'; - } + $fetch = array_map( + fn (FetchItemInterface $item) => $item->toImap(), + $this->fetchItems, + ); if (empty($fetch)) { return $uids->mapWithKeys(fn (string|int $uid) => [ - $uid => [ - 'size' => null, - 'flags' => [], - 'head' => '', - 'body' => '', - 'bodystructure' => null, - ], - ])->all(); + $uid => new FetchedMessageData(['UID' => (int) $uid]), + ]); } - return $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) { - $data = $response->tokenAt(3); - - if (! $data instanceof ListData) { - throw new RuntimeException(sprintf( - 'Expected instance of %s at index 3 in FETCH response, got %s', - ListData::class, - get_debug_type($data) - )); - } + $fetched = $this->connection()->fetch($uids->all(), $fetch)->messages() + ->keyBy(fn (FetchedMessageData $data) => $data->uid()); - $uid = $data->lookup('UID')->value; - - $size = $data->lookup('RFC822.SIZE')?->value; + return $uids + ->map(fn (string|int $uid) => $fetched->get($uid)) + ->filter() + ->mapWithKeys(fn (FetchedMessageData $data) => [$data->uid() => $data]); + } - return [ - $uid => [ - 'size' => $size ? (int) $size : null, - 'flags' => $data->lookup('FLAGS')?->values() ?? [], - 'head' => $data->lookup('[HEADER]')->value ?? '', - 'body' => $data->lookup('[TEXT]')->value ?? '', - 'bodystructure' => $data->lookup('BODYSTRUCTURE'), - ], - ]; - })->all(); + /** + * Get the ordered message UIDs. + */ + protected function uids(): Collection + { + return match (true) { + $this->ordering instanceof UidOrder => $this->search(), + $this->ordering instanceof ImapSort => $this->sort($this->ordering), + }; } /** @@ -442,9 +436,24 @@ protected function search(): Collection $this->query->all(); } - $response = $this->connection()->search([ - $this->query->toImap(), - ]); + $charset = null; + + if (! Str::isAscii($this->query->toImap())) { + $capabilities = $this->folder->mailbox()->capabilities(); + + // Dual-version servers remain in rev1 mode until rev2 is enabled. + // https://www.rfc-editor.org/rfc/rfc9051#appendix-A + $rev2 = $capabilities->enabled('IMAP4REV2') + || ($capabilities->has('IMAP4REV2') && ! $capabilities->has('IMAP4REV1')); + + // UTF-8 sessions prohibit an explicit SEARCH charset. + // https://www.rfc-editor.org/rfc/rfc6855#section-3 + if (! $capabilities->enabled('UTF8=ACCEPT') && ! $rev2) { + $charset = 'UTF-8'; + } + } + + $response = $this->connection()->search($this->query->toTokens(), charset: $charset); return new Collection(array_map( fn (Token $token) => $token->value, @@ -455,9 +464,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()->capabilities()->supports('SORT')) { throw new ImapCapabilityException( 'Unable to sort messages. IMAP server does not support SORT capability.' ); @@ -467,11 +476,7 @@ protected function sort(): Collection $this->query->all(); } - $response = $this->connection()->sort( - $this->sortKey, - $this->sortDirection, - [$this->query->toImap()] - ); + $response = $this->connection()->sort($sort, $this->query->toTokens()); return new Collection(array_map( fn (Token $token) => $token->value, @@ -482,20 +487,20 @@ protected function sort(): Collection /** * Get the UID for the given identifier. */ - protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection + protected function id(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?FetchedMessageData { try { - return $this->connection()->uid([$id], $identifier); + return $this->connection()->fetch($id, 'UID', identifier: $identifier)->messages()->first(); } catch (ImapCommandException $e) { // IMAP servers may return an error if the message number is not found. // If the identifier being used is a message number, and the message // number is in the command tokens, we can assume this has occurred - // and safely ignore the error and return an empty collection. + // and safely ignore the error and return null. if ( - $identifier === ImapFetchIdentifier::MessageNumber + $identifier === ImapIdentifier::MessageNumber && in_array($id, $e->command()->tokens()) ) { - return ResponseCollection::make(); + return null; } // Otherwise, re-throw the exception. @@ -503,14 +508,6 @@ protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdenti } } - /** - * Make a new message from given raw components. - */ - protected function newMessage(int $uid, array $flags, string $head, string $body, ?int $size = null, ?ListData $bodystructure = null): Message - { - return new Message($this->folder, $uid, $flags, $head, $body, $size, $bodystructure); - } - /** * Get the connection instance. */ diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 0fd04bf..7848a2e 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -3,27 +3,21 @@ namespace DirectoryTree\ImapEngine; use BackedEnum; +use DateTimeInterface; use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; +use Generator; /** * @mixin ImapQueryBuilder */ interface MessageQueryInterface { - /** - * Don't mark messages as read when fetching. - */ - public function leaveUnread(): MessageQueryInterface; - - /** - * Mark all messages as read when fetching. - */ - public function markAsRead(): MessageQueryInterface; - /** * Set the limit and page for the current query. */ @@ -50,139 +44,36 @@ public function getPage(): int; public function setPage(int $page): MessageQueryInterface; /** - * Determine if the body of messages is being fetched. - */ - public function isFetchingBody(): bool; - - /** - * Determine if the flags of messages is being fetched. - */ - public function isFetchingFlags(): bool; - - /** - * Determine if the headers of messages is being fetched. - */ - public function isFetchingHeaders(): bool; - - /** - * Determine if the size of messages is being fetched. - */ - public function isFetchingSize(): bool; - - /** - * Determine if the body structure of messages is being fetched. - */ - public function isFetchingBodyStructure(): bool; - - /** - * Fetch the flags of messages. - */ - public function withFlags(): MessageQueryInterface; - - /** - * Fetch the body of messages. - */ - public function withBody(): MessageQueryInterface; - - /** - * Fetch the headers of messages. - */ - public function withHeaders(): MessageQueryInterface; - - /** - * Fetch the size of messages. - */ - public function withSize(): MessageQueryInterface; - - /** - * Fetch the body structure of messages. - */ - public function withBodyStructure(): MessageQueryInterface; - - /** - * Don't fetch the body of messages. - */ - public function withoutBody(): MessageQueryInterface; - - /** - * Don't fetch the headers of messages. - */ - public function withoutHeaders(): MessageQueryInterface; - - /** - * Don't fetch the flags of messages. - */ - public function withoutFlags(): MessageQueryInterface; - - /** - * Don't fetch the size of messages. + * Add items to the message FETCH request. */ - public function withoutSize(): MessageQueryInterface; + public function with(FetchItemInterface ...$items): static; /** - * Don't fetch the body structure of messages. + * Remove items from the message FETCH request. */ - public function withoutBodyStructure(): MessageQueryInterface; + public function without(FetchItemInterface ...$items): static; /** - * Set the fetch order. + * Replace the items in the message FETCH request. */ - public function setFetchOrder(string $fetchOrder): MessageQueryInterface; + public function only(FetchItemInterface ...$items): static; /** - * Get the fetch order. + * Order messages locally by UID, replacing any server-side sort criteria. */ - public function getFetchOrder(): string; + public function orderByUid( + SortDirection|string $direction = SortDirection::Ascending, + ): static; /** - * 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. @@ -204,10 +95,26 @@ public function firstOrFail(): MessageInterface; */ public function get(): MessageCollection; + /** + * Yield matching messages with at most the given number per FETCH request. + * + * The matching UIDs are loaded once. Query pagination is not applied. + * + * @return Generator + */ + public function cursor(int $chunkSize = 10): Generator; + + /** + * Get messages changed after the given modification sequence. + * + * Requesting vanished messages requires enabling QRESYNC before selecting a folder. + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult; + /** * Append a new message to the folder. */ - public function append(string $message, mixed $flags = null): int; + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult; /** * Execute a callback over each message via a chunked query. @@ -227,12 +134,12 @@ public function paginate(int $perPage = 5, $page = null, string $pageName = 'pag /** * Find a message by the given identifier type or throw an exception. */ - public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface; + public function findOrFail(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): MessageInterface; /** * Find a message by the given identifier type. */ - public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface; + public function find(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?MessageInterface; /** * Destroy the given messages. diff --git a/src/Poll.php b/src/Poll.php index 76d5617..e47c8ec 100644 --- a/src/Poll.php +++ b/src/Poll.php @@ -5,13 +5,20 @@ use Closure; use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException; +use DirectoryTree\ImapEngine\Selection\Result; +use Generator; class Poll { /** - * The last seen message UID. + * The first UID that has not yet been delivered. */ - protected ?int $lastSeenUid = null; + protected ?int $nextUid = null; + + /** + * The polling connection's latest folder selection. + */ + protected ?Result $selection = null; /** * Constructor. @@ -23,23 +30,33 @@ public function __construct( ) {} /** - * Destructor. + * Poll for new messages at a given frequency. */ - public function __destruct() + public function start(callable $callback, callable $query): void { - $this->disconnect(); + try { + foreach ($this->messages($query) as $message) { + if ($callback($message) === false) { + break; + } + } + } finally { + $this->disconnect(); + } } /** - * Poll for new messages at a given frequency. + * Yield arrivals, reconnecting when the polling connection is lost. + * + * @return Generator */ - public function start(callable $callback, callable $query): void + protected function messages(callable $query): Generator { $this->connect(); while ($frequency = $this->getNextFrequency()) { try { - $this->check($callback, $query); + yield from $this->check($query); } catch (ImapConnectionClosedException) { $this->reconnect(); } @@ -49,36 +66,33 @@ public function start(callable $callback, callable $query): void } /** - * Check for new messages since the last seen UID. + * Check for new messages since the last delivered UID. + * + * @return Generator */ - protected function check(callable $callback, callable $query): void + protected function check(callable $query): Generator { $folder = $this->folder(); - // If we don't have a last seen UID, we will fetch - // the last one in the folder as a starting point. - if (! $this->lastSeenUid) { - $this->lastSeenUid = $folder->messages() - ->first() - ?->uid() ?? 0; + $this->select($folder); - return; - } + $messages = $query($folder->messages()->with(MessageData::flags())) + ->uid($this->nextUid.':*') + ->orderByUid() + ->cursor(); - $query($folder->messages()) - ->uid($this->lastSeenUid + 1, INF) - ->each(function (MessageInterface $message) use ($callback) { - // Avoid processing the same message twice on subsequent polls. - // Some IMAP servers will always return the last seen UID in - // the search results regardless of given UID search range. - if ($this->lastSeenUid === $message->uid()) { - return; - } + foreach ($messages as $message) { + // Avoid processing the same message twice on subsequent polls. + // Some IMAP servers will always return the last seen UID in + // the search results regardless of given UID search range. + if ($message->uid() < $this->nextUid) { + continue; + } - $callback($message); + yield $message; - $this->lastSeenUid = $message->uid(); - }); + $this->nextUid = $message->uid() + 1; + } } /** @@ -106,7 +120,29 @@ protected function connect(): void { $this->mailbox->connect(); - $this->mailbox->select($this->folder(), true); + $this->select($this->folder()); + } + + /** + * Select the folder and reset the arrival cursor when UID validity changes. + */ + protected function select(FolderInterface $folder): void + { + $selection = $folder->select(true); + + if ($this->selection === null || $this->selection->uidValidity() !== $selection->uidValidity()) { + $this->nextUid = $selection->uidNext() ?? $this->getNextUid($folder); + } + + $this->selection = $selection; + } + + /** + * Determine the next UID from the folder's existing messages. + */ + protected function getNextUid(FolderInterface $folder): int + { + return ($folder->messages()->orderByUid('desc')->first()?->uid() ?? 0) + 1; } /** diff --git a/src/QueriesMessages.php b/src/QueriesMessages.php index 1c45b72..5ebb31e 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -4,6 +4,9 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; +use DirectoryTree\ImapEngine\Support\Enum; use DirectoryTree\ImapEngine\Support\ForwardsCalls; use Illuminate\Support\Traits\Conditionable; @@ -27,58 +30,21 @@ trait QueriesMessages protected ?int $limit = null; /** - * Whether to fetch the message body. - */ - protected bool $fetchBody = false; - - /** - * Whether to fetch the message flags. - */ - protected bool $fetchFlags = false; - - /** - * Whether to fetch the message headers. - */ - protected bool $fetchHeaders = false; - - /** - * Whether to fetch the message size. - */ - protected bool $fetchSize = false; - - /** - * Whether to fetch the message body structure. - */ - protected bool $fetchBodyStructure = false; - - /** - * The fetch order. + * The items to include in message FETCH requests. * - * @var 'asc'|'desc' + * @var array */ - protected string $fetchOrder = 'desc'; + protected array $fetchItems = []; /** - * Whether to leave messages fetched as unread by default. + * The message ordering strategy. */ - protected bool $fetchAsUnread = true; + 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'; + protected array $passthru = ['toimap', 'totokens', 'isempty']; /** * Handle dynamic method calls into the query builder. @@ -94,26 +60,6 @@ public function __call(string $method, array $parameters): mixed return $this; } - /** - * {@inheritDoc} - */ - public function leaveUnread(): MessageQueryInterface - { - $this->fetchAsUnread = true; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function markAsRead(): MessageQueryInterface - { - $this->fetchAsUnread = false; - - return $this; - } - /** * {@inheritDoc} */ @@ -167,180 +113,10 @@ public function setPage(int $page): MessageQueryInterface /** * {@inheritDoc} */ - public function isFetchingBody(): bool - { - return $this->fetchBody; - } - - /** - * {@inheritDoc} - */ - public function isFetchingFlags(): bool - { - return $this->fetchFlags; - } - - /** - * {@inheritDoc} - */ - public function isFetchingHeaders(): bool - { - return $this->fetchHeaders; - } - - /** - * {@inheritDoc} - */ - public function isFetchingSize(): bool - { - return $this->fetchSize; - } - - /** - * {@inheritDoc} - */ - public function isFetchingBodyStructure(): bool - { - return $this->fetchBodyStructure; - } - - /** - * {@inheritDoc} - */ - public function withFlags(): MessageQueryInterface - { - return $this->setFetchFlags(true); - } - - /** - * {@inheritDoc} - */ - public function withBody(): MessageQueryInterface - { - return $this->setFetchBody(true); - } - - /** - * {@inheritDoc} - */ - public function withHeaders(): MessageQueryInterface - { - return $this->setFetchHeaders(true); - } - - /** - * {@inheritDoc} - */ - public function withSize(): MessageQueryInterface - { - return $this->setFetchSize(true); - } - - /** - * {@inheritDoc} - */ - public function withBodyStructure(): MessageQueryInterface - { - return $this->setFetchBodyStructure(true); - } - - /** - * {@inheritDoc} - */ - public function withoutBody(): MessageQueryInterface - { - return $this->setFetchBody(false); - } - - /** - * {@inheritDoc} - */ - public function withoutHeaders(): MessageQueryInterface + public function with(FetchItemInterface ...$items): static { - return $this->setFetchHeaders(false); - } - - /** - * {@inheritDoc} - */ - public function withoutFlags(): MessageQueryInterface - { - return $this->setFetchFlags(false); - } - - /** - * {@inheritDoc} - */ - public function withoutSize(): MessageQueryInterface - { - return $this->setFetchSize(false); - } - - /** - * {@inheritDoc} - */ - public function withoutBodyStructure(): MessageQueryInterface - { - return $this->setFetchBodyStructure(false); - } - - /** - * Set whether to fetch the flags. - */ - protected function setFetchFlags(bool $fetchFlags): MessageQueryInterface - { - $this->fetchFlags = $fetchFlags; - - return $this; - } - - /** - * Set the fetch body flag. - */ - protected function setFetchBody(bool $fetchBody): MessageQueryInterface - { - $this->fetchBody = $fetchBody; - - return $this; - } - - /** - * Set whether to fetch the headers. - */ - protected function setFetchHeaders(bool $fetchHeaders): MessageQueryInterface - { - $this->fetchHeaders = $fetchHeaders; - - return $this; - } - - /** - * Set whether to fetch the size. - */ - protected function setFetchSize(bool $fetchSize): MessageQueryInterface - { - $this->fetchSize = $fetchSize; - - return $this; - } - - /** - * Set whether to fetch the body structure. - */ - protected function setFetchBodyStructure(bool $fetchBodyStructure): MessageQueryInterface - { - $this->fetchBodyStructure = $fetchBodyStructure; - - return $this; - } - - /** {@inheritDoc} */ - public function setFetchOrder(string $fetchOrder): MessageQueryInterface - { - $fetchOrder = strtolower($fetchOrder); - - if (in_array($fetchOrder, ['asc', 'desc'])) { - $this->fetchOrder = $fetchOrder; + foreach ($items as $item) { + $this->fetchItems[$item->key()] = $item; } return $this; @@ -349,53 +125,34 @@ public function setFetchOrder(string $fetchOrder): MessageQueryInterface /** * {@inheritDoc} */ - public function getFetchOrder(): string + public function without(FetchItemInterface ...$items): static { - return $this->fetchOrder; - } - - /** - * {@inheritDoc} - */ - public function setFetchOrderAsc(): MessageQueryInterface - { - return $this->setFetchOrder('asc'); - } + foreach ($items as $item) { + unset($this->fetchItems[$item->key()]); + } - /** - * {@inheritDoc} - */ - public function setFetchOrderDesc(): MessageQueryInterface - { - return $this->setFetchOrder('desc'); + return $this; } /** * {@inheritDoc} */ - public function oldest(): MessageQueryInterface + public function only(FetchItemInterface ...$items): static { - return $this->setFetchOrder('asc'); - } + $this->fetchItems = []; - /** - * {@inheritDoc} - */ - public function newest(): MessageQueryInterface - { - return $this->setFetchOrder('desc'); + return $this->with(...$items); } /** * {@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( + SortDirection::from(strtolower(Enum::value($direction))), + ); return $this; } @@ -403,46 +160,22 @@ 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 = ImapSortKey::from(strtoupper(Enum::value($key))); - /** - * {@inheritDoc} - */ - public function setSortDirection(string $direction): MessageQueryInterface - { - $direction = strtolower($direction); + $direction = SortDirection::from(strtolower(Enum::value($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/Selection/CondStore.php b/src/Selection/CondStore.php new file mode 100644 index 0000000..4ff75e6 --- /dev/null +++ b/src/Selection/CondStore.php @@ -0,0 +1,22 @@ + 4294967295) { + throw new InvalidArgumentException('Invalid IMAP UID validity value.'); + } + + if ($highestModSequence < 1) { + throw new InvalidArgumentException('Invalid IMAP modification sequence.'); + } + } + + /** + * {@inheritDoc} + */ + public function capability(): string + { + return 'QRESYNC'; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + $parameters = [$this->uidValidity, $this->highestModSequence]; + + if ($this->knownUids !== []) { + $parameters[] = $this->sequenceSet($this->knownUids); + } + + if (! is_null($this->sequenceMatch)) { + $parameters[] = CommandArgument::list($this->sequenceMatch()); + } + + return 'QRESYNC '.CommandArgument::list($parameters); + } + + /** + * Make a UID set that can be used in a QRESYNC parameter. + */ + protected function sequenceSet(array|int|string $set): string + { + $set = SequenceSet::format($set); + + if ($set === '$' || str_contains($set, '*')) { + throw new InvalidArgumentException('Invalid QRESYNC UID set.'); + } + + return $set; + } + + /** + * Make and validate the message sequence match data. + */ + protected function sequenceMatch(): array + { + if (! array_is_list($this->sequenceMatch) || count($this->sequenceMatch) !== 2) { + throw new InvalidArgumentException('Invalid QRESYNC sequence match data.'); + } + + $sets = array_map($this->sequenceSet(...), $this->sequenceMatch); + + if ($this->ascendingSetLength($sets[0]) !== $this->ascendingSetLength($sets[1])) { + throw new InvalidArgumentException('Invalid QRESYNC sequence match data.'); + } + + return $sets; + } + + /** + * Get the number of values in an ascending sequence set. + */ + protected function ascendingSetLength(string $set): int + { + $length = 0; + $previous = 0; + + foreach (explode(',', $set) as $sequence) { + [$start, $end] = array_pad(array_map('intval', explode(':', $sequence, 2)), 2, null); + $end ??= $start; + + if ($start <= $previous || $end < $start) { + throw new InvalidArgumentException('Invalid QRESYNC sequence match data.'); + } + + $length += $end - $start + 1; + $previous = $end; + } + + return $length; + } +} diff --git a/src/Selection/RequiresEnablementInterface.php b/src/Selection/RequiresEnablementInterface.php new file mode 100644 index 0000000..b152cad --- /dev/null +++ b/src/Selection/RequiresEnablementInterface.php @@ -0,0 +1,8 @@ +untagged() as $response) { + $type = $response->tokenAt(2); + + if ($type instanceof Token && $type->is('EXISTS')) { + $exists = (int) $response->type()->value; + } elseif ($type instanceof Token && $type->is('RECENT')) { + $recent = (int) $response->type()->value; + } + + $code = $type; + + if (! $code instanceof ResponseCodeData) { + continue; + } + + $name = strtoupper($code->first()?->value ?? ''); + + $value = $code->tokenAt(1); + + match ($name) { + 'NOMODSEQ' => $highestModSequence = null, + 'UIDNEXT' => $uidNext = (int) $value->value, + 'UIDVALIDITY' => $uidValidity = (int) $value->value, + 'HIGHESTMODSEQ' => $highestModSequence = (int) $value->value, + 'PERMANENTFLAGS' => $permanentFlags = $value instanceof ListData ? $value->values() : [], + default => null, + }; + } + + return new static( + exists: $exists, + recent: $recent, + uidValidity: $uidValidity, + uidNext: $uidNext, + highestModSequence: $highestModSequence, + permanentFlags: $permanentFlags, + changes: FetchResult::fromResponses($responses), + responses: $responses, + ); + } + + /** + * Get the number of messages in the folder. + */ + public function exists(): ?int + { + return $this->exists; + } + + /** + * Get the number of messages with the recent flag. + */ + public function recent(): ?int + { + return $this->recent; + } + + /** + * Get the folder UID validity value. + */ + public function uidValidity(): ?int + { + return $this->uidValidity; + } + + /** + * Get the predicted next message UID. + */ + public function uidNext(): ?int + { + return $this->uidNext; + } + + /** + * Get the highest modification sequence in the folder. + */ + public function highestModSequence(): ?int + { + return $this->highestModSequence; + } + + /** + * Get the flags that can be permanently changed. + */ + public function permanentFlags(): array + { + return $this->permanentFlags; + } + + /** + * Determine if the folder supports persistent modification sequences. + */ + public function supportsModSequences(): bool + { + return ! is_null($this->highestModSequence); + } + + /** + * Get changes returned while selecting the folder. + */ + public function changes(): FetchResult + { + return $this->changes; + } + + /** + * Get the raw IMAP responses. + */ + public function responses(): ResponseCollection + { + return $this->responses; + } +} 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/Store/ModifierInterface.php b/src/Store/ModifierInterface.php new file mode 100644 index 0000000..6f563c3 --- /dev/null +++ b/src/Store/ModifierInterface.php @@ -0,0 +1,11 @@ +modSequence; + } +} diff --git a/src/StoreResult.php b/src/StoreResult.php new file mode 100644 index 0000000..56624bb --- /dev/null +++ b/src/StoreResult.php @@ -0,0 +1,77 @@ + $messages + * @param LazyCollection $modified + */ + public function __construct( + protected TaggedResponse $response, + protected Collection $messages = new Collection, + protected LazyCollection $modified = new LazyCollection, + protected ResponseCollection $responses = new ResponseCollection, + ) {} + + /** + * Create a store result from IMAP responses and selected fetched responses. + */ + public static function fromResponses(ResponseCollection $responses, TaggedResponse $response, FetchedResponseCollection $fetches): static + { + $code = $response->tokenAt(2); + + $modified = $code instanceof ResponseCodeData && strtoupper($code->first()?->value ?? '') === 'MODIFIED' + ? SequenceSet::parse($code->tokenAt(1)->value) + : new LazyCollection; + + return new static($response, $fetches->messages(), $modified, $responses); + } + + /** + * Get the tagged response that completed the STORE command. + */ + public function response(): TaggedResponse + { + return $this->response; + } + + /** + * Get the messages whose flags were changed. + * + * @return Collection + */ + public function messages(): Collection + { + return $this->messages; + } + + /** + * Get the UIDs or message numbers rejected because they changed after the checkpoint. + * + * @return LazyCollection + */ + public function modified(): LazyCollection + { + return $this->modified; + } + + /** + * Get the raw IMAP responses. + */ + public function responses(): ResponseCollection + { + return $this->responses; + } +} diff --git a/src/Support/Enum.php b/src/Support/Enum.php new file mode 100644 index 0000000..17d9c5d --- /dev/null +++ b/src/Support/Enum.php @@ -0,0 +1,36 @@ +value; + } + + return (string) $enum; + } +} diff --git a/src/Support/LazyBodyPartStream.php b/src/Support/LazyBodyPartStream.php index 4fb6f5d..6a535a7 100644 --- a/src/Support/LazyBodyPartStream.php +++ b/src/Support/LazyBodyPartStream.php @@ -4,20 +4,50 @@ use DirectoryTree\ImapEngine\BodyStructurePart; use DirectoryTree\ImapEngine\Message; +use InvalidArgumentException; use Psr\Http\Message\StreamInterface; use RuntimeException; class LazyBodyPartStream implements StreamInterface { /** - * The current position in the stream. + * The decoded content, spilling to disk beyond two megabytes. + * + * @var resource|null + */ + protected $buffer = null; + + /** + * The transfer decoder, retaining state across chunks. + * + * @var resource|null + */ + protected $decoder = null; + + /** + * The current read position in the decoded buffer, in bytes. */ protected int $position = 0; /** - * The cached content. + * The transfer-encoded byte offset of the next partial fetch. */ - protected ?string $content = null; + protected int $offset = 0; + + /** + * The number of decoded bytes currently available in the buffer. + */ + protected int $size = 0; + + /** + * Whether the entire body part has been fetched and decoded. + */ + protected bool $complete = false; + + /** + * Whether the stream has been closed. + */ + protected bool $closed = false; /** * Constructor. @@ -25,13 +55,28 @@ class LazyBodyPartStream implements StreamInterface public function __construct( protected Message $message, protected BodyStructurePart $part, - ) {} + protected int $chunkSize = 65536, + ) { + if ($chunkSize < 1) { + throw new InvalidArgumentException('The chunk size must be positive.'); + } + } + + /** + * Close the stream. + */ + public function __destruct() + { + $this->close(); + } /** * {@inheritDoc} */ public function __toString(): string { + $this->rewind(); + return $this->getContents(); } @@ -40,14 +85,19 @@ public function __toString(): string */ public function close(): void { - $this->content = null; - $this->position = 0; + if (is_resource($this->buffer)) { + fclose($this->buffer); + } + + $this->buffer = null; + $this->decoder = null; + $this->closed = true; } /** * {@inheritDoc} */ - public function detach() + public function detach(): null { $this->close(); @@ -59,7 +109,7 @@ public function detach() */ public function getSize(): ?int { - return strlen($this->getOrFetchContent()); + return $this->complete ? $this->size : null; } /** @@ -75,7 +125,7 @@ public function tell(): int */ public function eof(): bool { - return $this->position >= strlen($this->getOrFetchContent()); + return $this->complete && $this->position >= $this->size; } /** @@ -83,7 +133,7 @@ public function eof(): bool */ public function isSeekable(): bool { - return true; + return ! $this->closed; } /** @@ -91,19 +141,23 @@ public function isSeekable(): bool */ public function seek(int $offset, int $whence = SEEK_SET): void { - $content = $this->getOrFetchContent(); - $size = strlen($content); + if ($whence === SEEK_END) { + $this->fill(); + } - $this->position = match ($whence) { + $position = match ($whence) { SEEK_SET => $offset, SEEK_CUR => $this->position + $offset, - SEEK_END => $size + $offset, + SEEK_END => $this->size + $offset, default => throw new RuntimeException('Invalid whence'), }; - if ($this->position < 0) { - $this->position = 0; + if ($position < 0) { + throw new RuntimeException('Cannot seek before the beginning of the stream.'); } + + $this->fill($position); + $this->position = $position; } /** @@ -111,7 +165,7 @@ public function seek(int $offset, int $whence = SEEK_SET): void */ public function rewind(): void { - $this->position = 0; + $this->seek(0); } /** @@ -127,7 +181,7 @@ public function isWritable(): bool */ public function isReadable(): bool { - return true; + return ! $this->closed; } /** @@ -135,13 +189,21 @@ public function isReadable(): bool */ public function read(int $length): string { - $content = $this->getOrFetchContent(); + if ($length < 0) { + throw new RuntimeException('The read length must not be negative.'); + } - $result = substr($content, $this->position, $length); + $this->fill($this->position + $length); - $this->position += strlen($result); + if ($length === 0 || $this->position >= $this->size) { + return ''; + } + + fseek($this->buffer, $this->position); + $content = fread($this->buffer, min($length, $this->size - $this->position)); + $this->position += strlen($content); - return $result; + return $content; } /** @@ -149,21 +211,17 @@ public function read(int $length): string */ public function getContents(): string { - $content = $this->getOrFetchContent(); - - $result = substr($content, $this->position); + $this->fill(); - $this->position = strlen($content); - - return $result; + return $this->read(max(0, $this->size - $this->position)); } /** * {@inheritDoc} */ - public function getMetadata(?string $key = null): ?array + public function getMetadata(?string $key = null): mixed { - return $key === null ? [] : null; + return is_null($key) ? [] : null; } /** @@ -175,17 +233,57 @@ public function write(string $string): int } /** - * Fetch the content from the server if not already cached. + * Fetch enough encoded bytes to satisfy a decoded read or seek. */ - protected function getOrFetchContent(): string + protected function fill(?int $length = null): void { - if ($this->content === null) { - $this->content = BodyPartDecoder::binary( - $this->part, - $this->message->bodyPart($this->part->partNumber()) - ) ?? ''; + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + while (! $this->complete && ($length === null || $this->size < $length)) { + $this->fetch(); + } + } + + /** + * Append a partial response through a stateful transfer decoder. + */ + protected function fetch(): void + { + $content = $this->message->bodyPart( + $this->part->partNumber(), offset: $this->offset, length: $this->chunkSize, + ); + + if ($content === null) { + throw new RuntimeException('The body part is no longer available.'); + } + + if ($this->buffer === null) { + $this->buffer = fopen('php://temp/maxmemory:2097152', 'w+b'); + + $filter = match (strtolower($this->part->encoding() ?? '')) { + 'base64' => 'convert.base64-decode', + 'quoted-printable' => 'convert.quoted-printable-decode', + default => null, + }; + + if ($filter) { + $this->decoder = stream_filter_append($this->buffer, $filter, STREAM_FILTER_WRITE); + } + } + + fseek($this->buffer, 0, SEEK_END); + fwrite($this->buffer, $content); + + $this->offset += strlen($content); + $this->complete = strlen($content) < $this->chunkSize; + + if ($this->complete && $this->decoder !== null) { + stream_filter_remove($this->decoder); + $this->decoder = null; } - return $this->content; + $this->size = fstat($this->buffer)['size']; } } diff --git a/src/Support/MessageSetMatcher.php b/src/Support/MessageSetMatcher.php new file mode 100644 index 0000000..09bd438 --- /dev/null +++ b/src/Support/MessageSetMatcher.php @@ -0,0 +1,110 @@ +filterable = false; + + return; + } + + foreach (explode(',', $set) as $sequence) { + if (! str_contains($sequence, ':')) { + $this->numbers[(int) $sequence] = true; + + continue; + } + + [$start, $end] = array_map('intval', explode(':', $sequence, 2)); + + $this->ranges[] = [min($start, $end), max($start, $end)]; + } + + $this->ranges = static::mergeRanges($this->ranges); + } + + /** + * Determine whether the set may contain the given message number or UID. + */ + public function contains(int $number): bool + { + if (! $this->filterable || isset($this->numbers[$number])) { + return true; + } + + $start = 0; + $end = count($this->ranges) - 1; + + while ($start <= $end) { + $middle = intdiv($start + $end, 2); + [$rangeStart, $rangeEnd] = $this->ranges[$middle]; + + if ($number < $rangeStart) { + $end = $middle - 1; + + continue; + } + + if ($number > $rangeEnd) { + $start = $middle + 1; + + continue; + } + + return true; + } + + return false; + } + + /** + * Sort and merge overlapping or adjacent ranges. + * + * @param array $ranges + * @return array + */ + protected static function mergeRanges(array $ranges): array + { + usort($ranges, fn (array $left, array $right) => $left[0] <=> $right[0]); + + $merged = []; + + foreach ($ranges as [$start, $end]) { + $last = count($merged) - 1; + + if ($last < 0 || $start > $merged[$last][1] + 1) { + $merged[] = [$start, $end]; + + continue; + } + + $merged[$last][1] = max($merged[$last][1], $end); + } + + return $merged; + } +} diff --git a/src/Support/SequenceSet.php b/src/Support/SequenceSet.php new file mode 100644 index 0000000..cbc6c43 --- /dev/null +++ b/src/Support/SequenceSet.php @@ -0,0 +1,131 @@ + static::toSequenceSet($from), + is_null($to) => (string) $from, + $to == INF => $from.':*', + default => $from.':'.$to, + }; + + static::assertValidSequenceSet($set); + + return $set; + } + + /** + * Assert that a sequence set can be sent as an IMAP command argument. + */ + protected static function assertValidSequenceSet(string $set): void + { + if ($set === '$') { + return; + } + + foreach (explode(',', $set) as $sequence) { + if (! preg_match('/\A(?:[1-9][0-9]*|\*)(?::(?:[1-9][0-9]*|\*))?\z/', $sequence)) { + throw new InvalidArgumentException('Invalid IMAP sequence set.'); + } + + foreach (explode(':', $sequence) as $number) { + if ($number === '*') { + continue; + } + + $digits = strlen($number); + + if ($digits > 10 || ($digits === 10 && strcmp($number, '4294967295') > 0)) { + throw new InvalidArgumentException('Invalid IMAP sequence set.'); + } + } + } + } + + /** + * Convert the values into an IMAP sequence set. + * + * @param array $values + */ + protected static function toSequenceSet(array $values): string + { + return Collection::make(array_values($values)) + ->chunkWhile(function (int|string $value, int $key, Collection $range) { + $previous = $range->last(); + + if (! is_numeric($value) || ! is_numeric($previous)) { + return false; + } + + $difference = (int) $value - (int) $previous; + $direction = (int) $previous <=> (int) $range->first(); + + return in_array($difference, [-1, 1], true) + && ($range->count() === 1 || $difference === $direction); + }) + ->map(fn (Collection $range) => static::toSequenceRange( + $range->first(), + $range->last(), + )) + ->implode(','); + } + + /** + * Convert the values into an IMAP sequence range. + */ + protected static function toSequenceRange(int|string $start, int|string $end): string + { + return (string) $start === (string) $end + ? (string) $start + : $start.':'.$end; + } + + /** + * Parse a numeric IMAP sequence set without expanding its ranges in memory. + * + * @return LazyCollection + */ + public static function parse(string $set): LazyCollection + { + return new LazyCollection(function () use ($set) { + foreach (explode(',', $set) as $sequence) { + if (! str_contains($sequence, ':')) { + yield (int) $sequence; + + continue; + } + + [$start, $end] = array_map('intval', explode(':', $sequence, 2)); + $step = $start <= $end ? 1 : -1; + + for ($value = $start; ; $value += $step) { + yield $value; + + if ($value === $end) { + break; + } + } + } + }); + } +} diff --git a/src/Support/Str.php b/src/Support/Str.php index 7813f3d..4eadc68 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -2,146 +2,14 @@ namespace DirectoryTree\ImapEngine\Support; -use BackedEnum; -use Illuminate\Support\Collection; - class Str { /** - * Make a list with literals or nested lists. - */ - public static function list(array $list): string - { - $values = []; - - foreach ($list as $value) { - if (is_array($value)) { - $values[] = static::list($value); - } else { - $values[] = $value; - } - } - - return sprintf('(%s)', implode(' ', $values)); - } - - /** - * Make one or more literals. - */ - public static function literal(array|string $string): array|string - { - if (is_array($string)) { - $result = []; - - foreach ($string as $value) { - $result[] = static::literal($value); - } - - return $result; - } - - if (str_contains($string, "\n")) { - return ['{'.strlen($string).'}', $string]; - } - - return '"'.static::escape($string).'"'; - } - - /** - * Resolve the value of the given enums. - */ - public static function enums(BackedEnum|array|string|null $enums = null): array|string|null - { - if (is_null($enums)) { - return null; - } - - if (is_array($enums)) { - return array_map([static::class, 'enums'], $enums); - } - - return Str::enum($enums); - } - - /** - * Resolve the value of the given enum. - */ - public static function enum(BackedEnum|string $enum): string - { - if ($enum instanceof BackedEnum) { - return $enum->value; - } - - return (string) $enum; - } - - /** - * Make an IMAP sequence set. - */ - public static function set(int|string|array $from, int|float|string|null $to = null): string - { - if (is_array($from)) { - return static::toSequenceSet($from); - } - - // At this point, $from is an integer. No upper bound provided, return $from as a string. - if (is_null($to)) { - return (string) $from; - } - - // If the upper bound is infinite, use the '*' notation. - if ($to == INF) { - return $from.':*'; - } - - // Otherwise, return a typical range string. - return $from.':'.$to; - } - - /** - * Convert the values into an IMAP sequence set. - * - * @param array $values - */ - protected static function toSequenceSet(array $values): string - { - return Collection::make(array_values($values)) - ->chunkWhile(function (int|string $value, int $key, Collection $range) { - $previous = $range->last(); - - if (! is_numeric($value) || ! is_numeric($previous)) { - return false; - } - - $difference = (int) $value - (int) $previous; - $direction = (int) $previous <=> (int) $range->first(); - - return in_array($difference, [-1, 1], true) - && ($range->count() === 1 || $difference === $direction); - }) - ->map(fn (Collection $range) => static::toSequenceRange( - $range->first(), - $range->last(), - )) - ->implode(','); - } - - /** - * Convert the values into an IMAP sequence range. - */ - protected static function toSequenceRange(int|string $start, int|string $end): string - { - return (string) $start === (string) $end - ? (string) $start - : $start.':'.$end; - } - - /** - * Make a credentials string for use in the AUTHENTICATE command. + * Determine if the given string contains only ASCII characters. */ - public static function credentials(string $user, string $token): string + public static function isAscii(string $value): bool { - return base64_encode("user=$user\1auth=Bearer $token\1\1"); + return ! preg_match('/[^\x00-\x7F]/', $value); } /** diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index 6521d48..5623424 100644 --- a/src/Testing/FakeFolder.php +++ b/src/Testing/FakeFolder.php @@ -2,28 +2,62 @@ namespace DirectoryTree\ImapEngine\Testing; +use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\ComparesFolders; use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\FolderInterface; +use DirectoryTree\ImapEngine\Idle\Events\EventInterface; +use DirectoryTree\ImapEngine\Idle\Events\FolderSelected; use DirectoryTree\ImapEngine\MailboxInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; use DirectoryTree\ImapEngine\Support\Str; class FakeFolder implements FolderInterface { use ComparesFolders; + /** + * The modification sequence at which each message vanished. + * + * @var array + */ + protected array $vanished = []; + + /** + * The mailbox events to deliver while idling. + * + * @var EventInterface[] + */ + protected array $idleEvents = []; + + /** + * The next UID assigned to an appended message. + */ + protected int $uidNext; + /** * Constructor. */ public function __construct( protected string $path = '', - protected array $flags = [], - /** @var FakeMessage[] */ - protected array $messages = [], + protected array $attributes = [], + protected MessageCollection $messages = new MessageCollection, protected string $delimiter = '/', protected ?MailboxInterface $mailbox = null, - ) {} + ) { + $this->messages = clone $messages; + $this->uidNext = ($messages->max(fn (FakeMessage $message) => $message->uid()) ?? 0) + 1; + } + + /** + * Keep the cloned folder's message collection independent. + */ + public function __clone(): void + { + $this->messages = clone $this->messages; + } /** * {@inheritDoc} @@ -44,9 +78,9 @@ public function path(): string /** * {@inheritDoc} */ - public function flags(): array + public function attributes(): array { - return $this->flags; + return $this->attributes; } /** @@ -81,7 +115,7 @@ public function is(FolderInterface $folder): bool public function messages(): MessageQueryInterface { // Ensure the folder is selected. - $this->select(true); + $this->select(); return new FakeMessageQuery($this); } @@ -89,10 +123,37 @@ public function messages(): MessageQueryInterface /** * {@inheritDoc} */ - public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void + public function events(callable $callback, callable|int $timeout = 300, OptionInterface ...$options): void + { + if (! is_numeric($seconds = is_callable($timeout) ? $timeout() : $timeout) || $seconds <= 0) { + return; + } + + $selection = new FolderSelected($this->path, $this->select(true, ...$options)); + + foreach ([$selection, ...$this->idleEvents] as $event) { + if ($callback($event) === false) { + break; + } + } + } + + /** + * {@inheritDoc} + */ + public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300, OptionInterface ...$options): void { - foreach ($this->messages as $message) { - $callback($message); + if (! is_numeric($seconds = is_callable($timeout) ? $timeout() : $timeout) || $seconds <= 0) { + return; + } + + $messages = $this->messages(); + $messages = $query ? $query($messages) : $messages; + + foreach ($messages->orderByUid()->cursor() as $message) { + if ($callback($message) === false) { + break; + } } } @@ -101,8 +162,17 @@ public function idle(callable $callback, ?callable $query = null, callable|int $ */ public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void { - foreach ($this->messages as $message) { - $callback($message); + if (! is_numeric($seconds = is_callable($frequency) ? $frequency() : $frequency) || $seconds <= 0) { + return; + } + + $messages = $this->messages(); + $messages = $query ? $query($messages) : $messages; + + foreach ($messages->orderByUid()->cursor() as $message) { + if ($callback($message) === false) { + break; + } } } @@ -117,9 +187,9 @@ public function move(string $newPath): void /** * {@inheritDoc} */ - public function select(bool $force = false): void + public function select(bool $force = false, OptionInterface ...$options): Result { - $this->mailbox?->select($this, $force); + return $this->mailbox?->select($this, $force, ...$options) ?? new Result; } /** @@ -135,13 +205,15 @@ public function status(): array */ public function examine(): array { + $this->mailbox?->examine($this); + return []; } /** * {@inheritDoc} */ - public function expunge(): array + public function expunge(array|int|null $uids = null): array { return []; } @@ -184,11 +256,11 @@ public function setPath(string $path): FakeFolder } /** - * Set the folder's flags. + * Set the folder's attributes. */ - public function setFlags(array $flags): FakeFolder + public function setAttributes(array $attributes): FakeFolder { - $this->flags = $flags; + $this->attributes = $attributes; return $this; } @@ -204,33 +276,85 @@ public function setMailbox(MailboxInterface $mailbox): FakeFolder } /** - * Set the folder's messages. - * - * @param FakeMessage[] $messages + * Set the folder's fake messages. */ - public function setMessages(array $messages): FakeFolder + public function setMessages(MessageCollection $messages): FakeFolder { - $this->messages = $messages; + $this->messages = clone $messages; + + foreach ($messages as $message) { + $this->uidNext = max($this->uidNext, $message->uid() + 1); + } return $this; } /** - * Get the folder's messages. + * Set the events delivered after the initial folder selection. * - * @return FakeMessage[] + * @param EventInterface[] $events + */ + public function setIdleEvents(array $events): FakeFolder + { + $this->idleEvents = $events; + + return $this; + } + + /** + * Get the folder's fake messages. */ - public function getMessages(): array + public function getMessages(): MessageCollection { - return $this->messages; + return clone $this->messages; } /** - * Add a message to the folder. + * Add a fake message to the folder. */ public function addMessage(FakeMessage $message): void { - $this->messages[] = $message; + $this->messages->push($message); + $this->uidNext = max($this->uidNext, $message->uid() + 1); + } + + /** + * Get and increment the next message UID. + */ + public function nextUid(): int + { + return $this->uidNext++; + } + + /** + * Record a message as vanished at the given modification sequence. + */ + public function vanish(int $uid, int $modSequence): FakeFolder + { + $this->messages = $this->messages + ->reject(fn (FakeMessage $message) => $message->uid() === $uid) + ->values(); + + $this->vanished[$uid] = $modSequence; + $this->uidNext = max($this->uidNext, $uid + 1); + + return $this; + } + + /** + * Get the requested message UIDs that vanished after the checkpoint. + * + * @param int[] $uids + * @return int[] + */ + public function vanishedSince(int $modSequence, array $uids): array + { + return array_keys(array_filter( + $this->vanished, + fn (int $vanishedAt, int $uid) => $vanishedAt > $modSequence + && in_array($uid, $uids, true), + ARRAY_FILTER_USE_BOTH, + )); } /** diff --git a/src/Testing/FakeFolderRepository.php b/src/Testing/FakeFolderRepository.php index 9ca38f8..b773cb2 100644 --- a/src/Testing/FakeFolderRepository.php +++ b/src/Testing/FakeFolderRepository.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine\Testing; use DirectoryTree\ImapEngine\Collections\FolderCollection; +use DirectoryTree\ImapEngine\FolderDataItemInterface; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; use DirectoryTree\ImapEngine\MailboxInterface; @@ -11,15 +12,33 @@ class FakeFolderRepository implements FolderRepositoryInterface { + /** + * The requested folder data items. + * + * @var array + */ + protected array $dataItems = []; + /** * Constructor. */ public function __construct( protected MailboxInterface $mailbox, - /** @var FolderInterface[] */ - protected array $folders = [] + protected FolderCollection $folders = new FolderCollection ) {} + /** + * {@inheritDoc} + */ + public function with(FolderDataItemInterface ...$items): static + { + foreach ($items as $item) { + $this->dataItems[$item->key()] = $item; + } + + return $this; + } + /** * {@inheritDoc} */ @@ -63,7 +82,7 @@ public function firstOrCreate(string $path): FolderInterface */ public function get(?string $match = '*', ?string $reference = ''): FolderCollection { - $folders = FolderCollection::make($this->folders); + $folders = clone $this->folders; // If we're not matching all, filter the folders by the match pattern. if (! in_array($match, ['*', null])) { diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index e1ce41c..220a767 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -2,11 +2,19 @@ namespace DirectoryTree\ImapEngine\Testing; +use DirectoryTree\ImapEngine\Capabilities; +use DirectoryTree\ImapEngine\Capability; +use DirectoryTree\ImapEngine\Collections\FolderCollection; +use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; use DirectoryTree\ImapEngine\Exceptions\Exception; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; use DirectoryTree\ImapEngine\MailboxInterface; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\RequiresEnablementInterface; +use DirectoryTree\ImapEngine\Selection\Result; class FakeMailbox implements MailboxInterface { @@ -15,29 +23,75 @@ class FakeMailbox implements MailboxInterface */ protected ?FolderInterface $selected = null; + /** + * The currently examined folder. + */ + protected ?FolderInterface $examined = null; + /** * Constructor. */ - public function __construct( - protected array $config = [], - /** @var FakeFolder[] */ - protected array $folders = [], - protected array $capabilities = [], + protected function __construct( + protected array $config, + protected FolderCollection $folders, + protected Capabilities $capabilities, ) { + /** @var FakeFolder $folder */ foreach ($folders as $folder) { $folder->setMailbox($this); } } + /** + * Prepare the cloned instance. + */ + public function __clone(): void + { + $this->folders = $this->folders->map( + fn (FakeFolder $folder) => (clone $folder)->setMailbox($this) + ); + + $this->reset(); + } + + /** + * Reset the connection state. + */ + protected function reset(): void + { + $this->selected = null; + $this->examined = null; + + $this->capabilities = Capabilities::from( + ...array_map( + fn (string $capability) => Capability::make($capability), + $this->capabilities->all() + ) + ); + } + + /** + * Make a new fake mailbox. + * + * @param FakeFolder[] $folders + * @param string[] $capabilities + */ + public static function make(array $config = [], array $folders = [], array $capabilities = []): static + { + return new static( + $config, + new FolderCollection($folders), + Capabilities::from( + ...array_map(fn (string $capability) => Capability::make($capability), $capabilities) + ), + ); + } + /** * {@inheritDoc} */ public function config(?string $key = null, mixed $default = null): mixed { - if (is_null($key)) { - return $this->config; - } - return data_get($this->config, $key, $default); } @@ -60,9 +114,13 @@ public function connected(): bool /** * {@inheritDoc} */ - public function reconnect(): void + public function reconnect(?string $password = null): void { - // Do nothing. + if ($password !== null) { + $this->config['password'] = $password; + } + + $this->reset(); } /** @@ -78,7 +136,7 @@ public function connect(?ConnectionInterface $connection = null): void */ public function disconnect(): void { - // Do nothing. + $this->reset(); } /** @@ -100,7 +158,7 @@ public function folders(): FolderRepositoryInterface /** * {@inheritDoc} */ - public function capabilities(): array + public function capabilities(): Capabilities { return $this->capabilities; } @@ -108,9 +166,80 @@ public function capabilities(): array /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false): void + public function enable(string ...$capabilities): ResponseCollection + { + $current = $this->capabilities(); + + $requested = Capabilities::from( + ...array_map(fn (string $capability) => Capability::make($capability), $capabilities) + ); + + foreach ($requested->all() as $capability) { + if (! $current->has($capability)) { + throw new ImapCapabilityException( + "Unable to enable capability [$capability]. IMAP server does not support it." + ); + } + } + + $requested = array_values(array_filter( + $requested->all(), + fn (string $capability) => ! $current->enabled($capability), + )); + + if (empty($requested)) { + return new ResponseCollection; + } + + if ($this->selected || $this->examined) { + throw new ImapCapabilityException( + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.' + ); + } + + $items = $current->items(); + + foreach ($requested as $capability) { + $items[$capability] = Capability::make($capability, enabled: true); + } + + $this->capabilities = Capabilities::from(...array_values($items)); + + return new ResponseCollection; + } + + /** + * {@inheritDoc} + */ + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result { + foreach ($options as $option) { + if (! $this->capabilities()->supports($option->capability())) { + throw new ImapCapabilityException( + "Unable to select folder with [{$option->capability()}]. IMAP server does not support it." + ); + } + + if ($option instanceof RequiresEnablementInterface) { + $this->enable($option->capability()); + } + } + + $this->examined = null; $this->selected = $folder; + + return new Result; + } + + /** + * {@inheritDoc} + */ + public function examine(FolderInterface $folder): Result + { + $this->selected = null; + $this->examined = $folder; + + return new Result; } /** diff --git a/src/Testing/FakeMessage.php b/src/Testing/FakeMessage.php index b78e8b6..d902331 100644 --- a/src/Testing/FakeMessage.php +++ b/src/Testing/FakeMessage.php @@ -8,7 +8,7 @@ use DirectoryTree\ImapEngine\HasMessageAccessors; use DirectoryTree\ImapEngine\HasParsedMessage; use DirectoryTree\ImapEngine\MessageInterface; -use DirectoryTree\ImapEngine\Support\Str; +use DirectoryTree\ImapEngine\Support\Enum; class FakeMessage implements MessageInterface { @@ -23,6 +23,7 @@ public function __construct( protected string $contents = '', protected ?int $size = null, protected ?BodyStructureCollection $bodyStructure = null, + protected ?int $modSequence = null, ) {} /** @@ -41,6 +42,14 @@ public function size(): int return $this->size ?? strlen($this->contents); } + /** + * {@inheritDoc} + */ + public function modSequence(): ?int + { + return $this->modSequence; + } + /** * {@inheritDoc} */ @@ -57,7 +66,7 @@ public function is(MessageInterface $message): bool */ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void { - $flag = Str::enum($flag); + $flag = Enum::value($flag); if ($operation === '+') { $this->flags = array_unique([...$this->flags, $flag]); @@ -93,7 +102,7 @@ public function hasBodyStructure(): bool /** * {@inheritDoc} */ - public function bodyPart(string $partNumber, bool $peek = true): ?string + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string { return null; } diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index bcabbee..1337754 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -3,13 +3,29 @@ namespace DirectoryTree\ImapEngine\Testing; use BackedEnum; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\MessageCollection; +use DirectoryTree\ImapEngine\Collections\VanishedCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; +use DirectoryTree\ImapEngine\FetchedMessageData; +use DirectoryTree\ImapEngine\FetchResult; +use DirectoryTree\ImapEngine\MessageData; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; use DirectoryTree\ImapEngine\QueriesMessages; +use DirectoryTree\ImapEngine\UidOrder; +use DirectoryTree\ImapEngine\Vanished; +use Generator; +use Illuminate\Support\ItemNotFoundException; +use Illuminate\Support\LazyCollection; +use InvalidArgumentException; class FakeMessageQuery implements MessageQueryInterface { @@ -21,26 +37,117 @@ 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( - $this->folder->getMessages() + return $this->applyOrdering($this->folder->getMessages()); + } + + /** + * {@inheritDoc} + */ + public function cursor(int $chunkSize = 10): Generator + { + yield from $this->get(); + } + + /** + * {@inheritDoc} + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult + { + $uids = (array) $uids; + + if ($uids === []) { + return new FetchResult; + } + + if ($modSequence < 0) { + throw new InvalidArgumentException('Invalid IMAP modification sequence.'); + } + + $capability = $vanished ? 'QRESYNC' : 'CONDSTORE'; + + $mailbox = $this->folder->mailbox(); + + $supported = $mailbox->capabilities()->supports($capability) + || ($capability === 'CONDSTORE' && $mailbox->capabilities()->supports('QRESYNC')); + + if (! $supported) { + throw new ImapCapabilityException( + "Unable to fetch message changes. IMAP server does not support $capability capability." + ); + } + + if ($vanished && ! $mailbox->capabilities()->enabled('QRESYNC')) { + throw new ImapCapabilityException( + 'Enable QRESYNC before selecting a folder to request vanished messages.' + ); + } + + $messages = $this->folder->getMessages()->toBase() + ->filter(fn (FakeMessage $message) => in_array($message->uid(), $uids, true)) + ->filter(fn (FakeMessage $message) => ($message->modSequence() ?? 0) > $modSequence) + ->map(fn (FakeMessage $message) => $this->fetchedData($message)) + ->values(); + + $vanishedUids = $vanished + ? $this->folder->vanishedSince($modSequence, $uids) + : []; + + return new FetchResult( + $messages, + new VanishedCollection($vanishedUids + ? [new Vanished(new LazyCollection($vanishedUids), earlier: true)] + : []), ); } + /** + * Get the fetched data for the given message. + */ + protected function fetchedData(FakeMessage $message): FetchedMessageData + { + $items = $this->fetchItems ?: [MessageData::flags()]; + + $attributes = [ + 'UID' => $message->uid(), + 'MODSEQ' => [$message->modSequence()], + ]; + + foreach ($items as $item) { + $attributes[$item->key()] = $this->fetchValue($message, $item); + } + + return new FetchedMessageData($attributes); + } + + /** + * Get the value of the fetched item for the given message. + */ + protected function fetchValue(FakeMessage $message, FetchItemInterface $item): mixed + { + return match ($item->key()) { + 'FLAGS' => $message->flags(), + 'RFC822.SIZE' => $message->size(), + 'MODSEQ' => [$message->modSequence()], + 'BODYSTRUCTURE' => $message->bodyStructure(), + default => null, + }; + } + /** * {@inheritDoc} */ public function count(): int { - return count( - $this->folder->getMessages() - ); + return $this->folder->getMessages()->count(); } /** @@ -62,19 +169,53 @@ public function firstOrFail(): MessageInterface /** * {@inheritDoc} */ - public function append(string $message, mixed $flags = null): int + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult { - $uid = 1; - - if ($lastMessage = $this->get()->last()) { - $uid = $lastMessage->uid() + 1; - } + $uid = $this->folder->nextUid(); $this->folder->addMessage( new FakeMessage($uid, $flags === null ? [] : $flags, $message) ); - return $uid; + 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() ?? '', + }; } /** @@ -125,17 +266,24 @@ public function paginate(int $perPage = 5, $page = null, string $pageName = 'pag /** * {@inheritDoc} */ - public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface + public function findOrFail(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): MessageInterface { - return $this->get()->findOrFail($id); + return $this->find($id, $identifier) ?? throw new ItemNotFoundException; } /** * {@inheritDoc} */ - public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface + public function find(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?MessageInterface { - return $this->get()->find($id); + if ($identifier === ImapIdentifier::Uid) { + return $this->get()->find($id); + } + + return $this->folder->getMessages() + ->sortBy(fn (FakeMessage $message) => $message->uid()) + ->values() + ->get($id - 1); } /** @@ -152,7 +300,7 @@ public function destroy(array|int $uids, bool $expunge = false): void } $this->folder->setMessages( - $messages->values()->all() + $messages->values() ); } @@ -161,7 +309,7 @@ public function destroy(array|int $uids, bool $expunge = false): void */ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -169,7 +317,7 @@ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = */ public function markRead(): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -177,7 +325,7 @@ public function markRead(): int */ public function markUnread(): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -185,7 +333,7 @@ public function markUnread(): int */ public function markFlagged(): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -193,7 +341,7 @@ public function markFlagged(): int */ public function unmarkFlagged(): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -201,9 +349,9 @@ public function unmarkFlagged(): int */ public function delete(bool $expunge = false): int { - $count = count($this->folder->getMessages()); + $count = $this->folder->getMessages()->count(); - $this->folder->setMessages([]); + $this->folder->setMessages(new MessageCollection); return $count; } @@ -213,7 +361,7 @@ public function delete(bool $expunge = false): int */ public function move(string $folder, bool $expunge = false): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } /** @@ -221,6 +369,6 @@ public function move(string $folder, bool $expunge = false): int */ public function copy(string $folder): int { - return count($this->folder->getMessages()); + return $this->folder->getMessages()->count(); } } 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 @@ + $uids + */ + public function __construct( + protected LazyCollection $uids, + protected bool $earlier = false, + ) {} + + /** + * Create vanished message data from an IMAP VANISHED response. + */ + public static function fromResponse(UntaggedResponse $response): static + { + $data = $response->tokenAt(2); + $earlier = $data instanceof ListData + && strtoupper((string) ($data->values()[0] ?? '')) === 'EARLIER'; + $sequenceSet = $response->tokenAt($earlier ? 3 : 2); + + return new static( + SequenceSet::parse($sequenceSet->value), + $earlier, + ); + } + + /** + * Parse vanished responses from a collection of raw IMAP responses. + */ + public static function collect(ResponseCollection $responses): VanishedCollection + { + return new VanishedCollection( + $responses->vanished() + ->map(fn (UntaggedResponse $response) => static::fromResponse($response)) + ->values() + ); + } + + /** + * Get the vanished message UIDs. + * + * @return LazyCollection + */ + public function uids(): LazyCollection + { + return $this->uids; + } + + /** + * Determine if the messages vanished before the requested checkpoint. + */ + public function earlier(): bool + { + return $this->earlier; + } +} diff --git a/src/Watch.php b/src/Watch.php new file mode 100644 index 0000000..d0d2594 --- /dev/null +++ b/src/Watch.php @@ -0,0 +1,125 @@ +events() as $event) { + if ($callback($event) === false) { + break; + } + } + } finally { + $this->disconnect(); + } + } + + /** + * Yield folder selection and server response events without fetching messages. + * + * @return Generator + */ + protected function events(): Generator + { + $folder = null; + + while (is_numeric($seconds = value($this->timeout)) && $seconds > 0) { + $folder ??= $this->mailbox->folders()->findOrFail($this->folder); + + if (! $this->mailbox->selected($folder)) { + yield new FolderSelected($this->folder, $folder->select(true, ...$this->options)); + } + + $seconds = max((int) $seconds, 1); + + $session = $this->mailbox->connection()->idle(); + + try { + foreach ($session->responses($seconds) as $response) { + yield $this->toEvent($response); + } + } catch (ImapConnectionTimedOutException) { + // End the timed-out IDLE command before starting another. + } catch (ImapConnectionClosedException) { + $this->disconnect(); + + continue; + } + + try { + foreach ($session->finish($this->mailbox->config('timeout')) as $response) { + yield $this->toEvent($response); + } + } catch (ImapConnectionClosedException|ImapConnectionTimedOutException) { + $this->disconnect(); + } + } + } + + /** + * Convert an unsolicited response into a mailbox event. + */ + protected function toEvent(UntaggedResponse $response): ResponseEvent + { + $event = $response->type() instanceof Number + ? match (strtoupper($response->tokenAt(2)->value)) { + 'EXISTS' => MessagesExist::class, + 'FETCH' => MessageFetched::class, + 'EXPUNGE' => MessageExpunged::class, + default => UnknownEvent::class, + } + : match (strtoupper($response->type()->value)) { + 'VANISHED' => MessagesVanished::class, + default => UnknownEvent::class, + }; + + return new $event($this->folder, $response); + } + + /** + * Close the dedicated socket without sending commands during IDLE. + */ + protected function disconnect(): void + { + if ($this->mailbox->connected()) { + $this->mailbox->connection()->disconnect(); + } + + $this->mailbox->disconnect(); + } +} diff --git a/tests/Integration/ExpungeTest.php b/tests/Integration/ExpungeTest.php new file mode 100644 index 0000000..6438be6 --- /dev/null +++ b/tests/Integration/ExpungeTest.php @@ -0,0 +1,28 @@ +folders()->create(uniqid('expunge-')); + + try { + $target = $folder->messages()->append("Subject: Target\r\n\r\nBody")->uid(); + $retained = $folder->messages()->append("Subject: Retained\r\n\r\nBody")->uid(); + + $writer = mailbox(); + + $writer->folders()->findOrFail($folder->path())->messages()->findOrFail($retained)->delete(); + $folder->messages()->findOrFail($target)->delete(); + + $folder->expunge([$target]); + + expect($folder->messages()->find($target))->toBeNull(); + + $messages = $reader->connection()->fetch('1:*', ['UID', 'FLAGS'])->messages(); + + expect($messages)->toHaveCount(1); + expect($messages[0]->uid())->toBe($retained); + expect($messages[0]->flags())->toContain('\\Deleted'); + } finally { + $folder->delete(); + } +}); diff --git a/tests/Integration/FoldersTest.php b/tests/Integration/FoldersTest.php index 8d86527..f9b48e1 100644 --- a/tests/Integration/FoldersTest.php +++ b/tests/Integration/FoldersTest.php @@ -1,6 +1,7 @@ toBeInstanceOf(Folder::class); expect($folder->path())->toBe('foo'); expect($folder->name())->toBe('foo'); - expect($folder->delimiter())->toBe('.'); + expect($folder->delimiter())->toBe($folder->mailbox()->inbox()->delimiter()); }); test('first or create', function () { $folders = mailbox()->folders(); + $count = $folders->get()->count(); + $first = $folders->firstOrCreate('foo'); expect($first)->toBeInstanceOf(Folder::class); @@ -55,7 +58,7 @@ expect($second->is($first))->toBeTrue(); - expect($folders->get())->toHaveCount(2); + expect($folders->get())->toHaveCount($count + 1); }); test('move', function () { @@ -74,7 +77,6 @@ expect($folder->status())->toHaveKeys([ 'MESSAGES', - 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', @@ -86,7 +88,6 @@ expect($folder->status())->toHaveKeys([ 'MESSAGES', - 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', @@ -106,5 +107,25 @@ test('quota', function () { $folder = mailbox()->inbox(); + if (! $folder->mailbox()->capabilities()->supports('QUOTA')) { + expect(fn () => $folder->quota())->toThrow(ImapCapabilityException::class); + + return; + } + expect($folder->quota())->toBeArray(); }); + +test('queries reselect the correct folder after examination', function () { + $mailbox = mailbox(); + $selected = $mailbox->folders()->create('selected'); + $examined = $mailbox->folders()->create('examined'); + + $selected->messages()->append("Subject: selection test\r\n\r\nbody"); + expect($selected->messages()->count())->toBe(1); + + $examined->examine(); + + expect($selected->messages()->count())->toBe(1); + expect($examined->messages()->count())->toBe(0); +}); diff --git a/tests/Integration/IdleTest.php b/tests/Integration/IdleTest.php new file mode 100644 index 0000000..1d4d4fc --- /dev/null +++ b/tests/Integration/IdleTest.php @@ -0,0 +1,142 @@ +folders()->create(uniqid('events-')); + + try { + $uid = $folder->messages()->append("Subject: Existing\r\n\r\nBody")->uid(); + + $mutation = function () use ($operation, $folder, $uid) { + match ($operation) { + 'append' => $folder->messages()->append("Subject: Arrival\r\n\r\nBody"), + 'flags' => $folder->messages()->findOrFail($uid)->markSeen(), + 'expunge' => $folder->messages()->findOrFail($uid)->delete(), + }; + + if ($operation === 'expunge') { + $folder->expunge([$uid]); + } + }; + + // Mutate only after the real server acknowledges IDLE. No responses are mocked. + $logger = new IdleAcknowledgementLogger($mutation); + + $reader = mailbox(['timeout' => 5]); + + $reader->connect(new ImapConnection(new ImapStream, $logger)); + + $intervals = 0; + + $timeout = function () use (&$intervals) { + return ++$intervals === 1 ? 5 : 0; + }; + + $events = []; + + (new Watch($reader, $folder->path(), $timeout))->await( + function (EventInterface $event) use (&$events, $eventClass) { + $events[] = $event; + + return ! $event instanceof $eventClass; + } + ); + + expect($logger->acknowledged)->toBeTrue(); + expect($intervals)->toBe(1); + expect($events[0])->toBeInstanceOf(FolderSelected::class); + + $event = collect($events)->first(fn ($event) => $event instanceof $eventClass); + + expect($event)->toBeInstanceOf($eventClass); + expect($event->folder())->toBe($folder->path()); + + if ($event instanceof MessagesExist) { + expect($event->count())->toBe(2); + } elseif ($event instanceof MessageFetched) { + expect($event->changes()->messages()[0]->flags())->toContain('\\Seen'); + } else { + expect($event->sequenceNumber())->toBe(1); + } + + expect($reader->connected())->toBeFalse(); + + $reader->reconnect(); + + expect($reader->folders()->findOrFail($folder->path())->messages()->count())->toBe(match ($operation) { + 'append' => 2, + 'flags' => 1, + 'expunge' => 0, + }); + } finally { + $folder->delete(); + } +})->with([ + 'new message' => ['append', MessagesExist::class], + 'changed flags' => ['flags', MessageFetched::class], + 'expunged message' => ['expunge', MessageExpunged::class], +]); + +test('idle can finish after a quiet timeout and reuse the same connection', function () { + $mailbox = mailbox(['timeout' => 5]); + $folder = $mailbox->folders()->create(uniqid('idle-timeout-')); + + try { + $folder->select(); + + $connection = $mailbox->connection(); + $session = $connection->idle(); + + try { + foreach ($session->responses(1) as $response) { + // Drain unsolicited selection updates until the IDLE deadline. + } + } catch (ImapConnectionTimedOutException) { + // A quiet socket may reach its read timeout before the loop deadline. + } + + expect($session->active())->toBeTrue(); + + $session->finish(5); + + expect($session->active())->toBeFalse(); + expect($mailbox->connected())->toBeTrue(); + expect($mailbox->connection())->toBe($connection); + expect($folder->messages()->count())->toBe(0); + } finally { + $folder->delete(); + } +}); + +test('folder events stop renewing when the timeout callback returns zero', function () { + $mailbox = mailbox(['timeout' => 5]); + $folder = $mailbox->folders()->create(uniqid('events-timeout-')); + + try { + $intervals = 0; + $events = []; + + $folder->events(function (EventInterface $event) use (&$events) { + $events[] = $event; + }, function () use (&$intervals) { + return ++$intervals === 1 ? 1 : 0; + }); + + expect($intervals)->toBe(2); + expect(collect($events)->whereInstanceOf(FolderSelected::class))->toHaveCount(1); + expect($folder->messages()->count())->toBe(0); + } finally { + $folder->delete(); + } +}); diff --git a/tests/Integration/MailboxTest.php b/tests/Integration/MailboxTest.php index 84ee47d..1cf16e4 100644 --- a/tests/Integration/MailboxTest.php +++ b/tests/Integration/MailboxTest.php @@ -17,14 +17,13 @@ test('capabilities', function () { $mailbox = mailbox(); - expect(array_flip($mailbox->capabilities()))->toHaveKeys([ - 'IMAP4rev1', + expect(array_flip($mailbox->capabilities()->all()))->toHaveKeys([ + 'IMAP4REV1', 'LITERAL+', 'UIDPLUS', 'SORT', 'IDLE', 'MOVE', - 'QUOTA', ]); }); diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 54f251c..968b228 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -5,6 +5,7 @@ use DirectoryTree\ImapEngine\DraftMessage; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Message; +use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQuery; use Illuminate\Support\ItemNotFoundException; @@ -23,6 +24,19 @@ function folder(): Folder folder()->delete(); }); +test('it fetches partial body sections and hydrates partial query data', function (): void { + $folder = folder(); + $uid = $folder->messages()->append( + "From: foo@example.com\r\nSubject: Partial body\r\nContent-Type: text/plain\r\n\r\nabcdefghij", + )->uid(); + + $message = $folder->messages()->with(MessageData::text()->partial(2, 4)->peek())->findOrFail($uid); + + expect($message->data()->get('BODY[TEXT]<2>'))->toBe('cdef') + ->and($message->bodyPart('TEXT', offset: 7, length: 100))->toStartWith('hij') + ->and($message->bodyPart('TEXT', offset: 100, length: 5))->toBe(''); +}); + test('messages selects folder', function () { $folder = folder(); @@ -51,7 +65,7 @@ function folder(): Folder $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), - ); + )->uid(); expect($folder->messages()->first()->uid())->toBe($uid); }); @@ -61,7 +75,7 @@ function folder(): Folder $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), - ); + )->uid(); $message = $folder->messages()->firstOrFail(); @@ -82,7 +96,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $message = $folder->messages()->find($uid); @@ -97,7 +111,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $message = $folder->messages()->findOrFail($uid); @@ -118,7 +132,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $messages = $folder->messages()->get(); @@ -134,17 +148,17 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $messages = $callback($folder->messages())->get(); expect($messages->count())->toBe(1); expect($messages->first()->uid())->toBe($uid); })->with([ - fn (MessageQuery $query) => $query->withBody(), - fn (MessageQuery $query) => $query->withFlags(), - fn (MessageQuery $query) => $query->withHeaders(), - fn (MessageQuery $query) => $query->withSize(), + fn (MessageQuery $query) => $query->with(MessageData::text()->peek()), + fn (MessageQuery $query) => $query->with(MessageData::flags()), + fn (MessageQuery $query) => $query->with(MessageData::headers()->peek()), + fn (MessageQuery $query) => $query->with(MessageData::size()), ]); test('get with size', function () { @@ -157,14 +171,14 @@ function folder(): Folder subject: 'Test Subject', text: 'hello world', ), - ); + )->uid(); // Fetch without size - should be null $messagesWithoutSize = $folder->messages()->get(); expect($messagesWithoutSize->first()->size())->toBeNull(); // Fetch with size - should have a value - $messagesWithSize = $folder->messages()->withSize()->get(); + $messagesWithSize = $folder->messages()->with(MessageData::size())->get(); $message = $messagesWithSize->first(); expect($message->size())->toBeInt(); @@ -185,10 +199,10 @@ function folder(): Folder text: str_repeat('This is a longer message with more content. ', 100), ); - $uid1 = $folder->messages()->append($shortMessage); - $uid2 = $folder->messages()->append($longMessage); + $uid1 = $folder->messages()->append($shortMessage)->uid(); + $uid2 = $folder->messages()->append($longMessage)->uid(); - $messages = $folder->messages()->withSize()->get(); + $messages = $folder->messages()->with(MessageData::size())->get(); $short = $messages->find($uid1); $long = $messages->find($uid2); @@ -216,12 +230,14 @@ function folder(): Folder date: $datetime = Carbon::now()->subYear(), ), ['\\Seen'], - ); + )->uid(); $message = $messages - ->withHeaders() - ->withFlags() - ->withBody() + ->with( + MessageData::headers()->peek(), + MessageData::flags(), + MessageData::text()->peek(), + ) ->find($uid); expect($message->from()->email())->toBe('foo@email.com'); @@ -233,7 +249,7 @@ function folder(): Folder expect($message->hasAttachments())->toBeTrue(); expect($message->attachmentCount())->toBe(1); expect($message->attachments()[0]->filename())->toBe('attachment.txt'); - expect($message->flags())->toBe(['\\Seen']); + expect(array_values(array_diff($message->flags(), ['\\Recent'])))->toBe(['\\Seen']); }); test('flag', function () { @@ -246,20 +262,20 @@ function folder(): Folder from: 'foo@email.com', text: 'flag test' ) - ); + )->uid(); // Initially, message should not be marked as seen. - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); // Mark message as seen. $message->markSeen(); - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeTrue(); // Unmark message as seen. $message->unmarkSeen(); - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); }); @@ -273,9 +289,9 @@ function folder(): Folder from: 'foo@email.com', text: 'copy test' ) - ); + )->uid(); - $message = $messages->withHeaders()->withBody()->find($uid); + $message = $messages->with(MessageData::headers()->peek(), MessageData::text()->peek())->find($uid); $targetFolder = $folder->mailbox()->folders()->firstOrCreate( $targetFolderName = uniqid() @@ -287,8 +303,7 @@ function folder(): Folder expect($newUid)->toBeGreaterThan(0); $copiedMessage = $targetFolder->messages() - ->withBody() - ->withHeaders() + ->with(MessageData::text()->peek(), MessageData::headers()->peek()) ->findOrFail($newUid); expect($copiedMessage->from()->email())->toBe('foo@email.com'); @@ -305,19 +320,18 @@ function folder(): Folder from: 'foo@email.com', text: 'move test' ) - ); + )->uid(); - $message = $messages->withHeaders()->withBody()->find($uid); + $message = $messages->with(MessageData::headers()->peek(), MessageData::text()->peek())->find($uid); $targetFolder = $folder->mailbox()->folders()->firstOrCreate( $targetFolderName = uniqid() ); - expect($message->move($targetFolderName))->toBeNull(); + $newUid = $message->move($targetFolderName); $targetMessages = $targetFolder->messages() - ->withHeaders() - ->withBody() + ->with(MessageData::headers()->peek(), MessageData::text()->peek()) ->get(); expect($folder->messages()->count())->toBe(0); @@ -326,6 +340,10 @@ function folder(): Folder /** @var Message $movedMessage */ $movedMessage = $targetMessages->first(); + if (! is_null($newUid)) { + expect($newUid)->toBe($movedMessage->uid()); + } + expect($movedMessage->from()->email())->toBe('foo@email.com'); expect($movedMessage->text())->toBe('move test'); }); @@ -338,13 +356,13 @@ function folder(): Folder from: 'foo@email.com', text: 'delete test' ) - ); + )->uid(); $message = $messages->find($uid); $message->delete(); - expect($messages->withFlags()->find($uid)->isDeleted())->toBeTrue(); + expect($messages->with(MessageData::flags())->find($uid)->isDeleted())->toBeTrue(); }); test('retrieves messages using or statement', function () { @@ -355,14 +373,14 @@ function folder(): Folder from: 'foo@email.com', text: $firstUuid = uniqid(), ), - ); + )->uid(); $secondUid = $folder->messages()->append( new DraftMessage( from: 'foo@email.com', text: $secondUuid = uniqid(), ), - ); + )->uid(); $results = $folder->messages() ->where(fn (ImapQueryBuilder $q) => $q->body($firstUuid)) @@ -383,7 +401,7 @@ function folder(): Folder text: 'hello world', ), [$flag], - ); + )->uid(); expect( $folder->messages() @@ -415,14 +433,13 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $folder->messages() - ->markAsRead() - ->withHeaders() + ->with(MessageData::headers()) ->get(); - $message = $folder->messages()->withFlags()->find($uid); + $message = $folder->messages()->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeTrue(); }); @@ -435,14 +452,13 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $folder->messages() - ->leaveUnread() - ->withHeaders() + ->with(MessageData::headers()->peek()) ->get(); - $message = $folder->messages()->withFlags()->find($uid); + $message = $folder->messages()->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); }); @@ -455,11 +471,11 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); expect($folder->messages()->unseen()->count())->toBe(1); - $folder->messages()->withFlags()->find($uid)->markSeen(); + $folder->messages()->with(MessageData::flags())->find($uid)->markSeen(); expect($folder->messages()->unseen()->count())->toBe(0); }); @@ -473,7 +489,7 @@ function folder(): Folder subject: 'AAA First alphabetically', text: 'hello world', ), - ); + )->uid(); $uid2 = $folder->messages()->append( new DraftMessage( @@ -481,7 +497,7 @@ function folder(): Folder subject: 'ZZZ Last alphabetically', text: 'hello world', ), - ); + )->uid(); // Ascending order: AAA should come before ZZZ $messagesAsc = $folder->messages()->sortBy('subject', 'asc')->get(); diff --git a/tests/Integration/SynchronizationTest.php b/tests/Integration/SynchronizationTest.php new file mode 100644 index 0000000..f8a2fb1 --- /dev/null +++ b/tests/Integration/SynchronizationTest.php @@ -0,0 +1,129 @@ +capabilities(); + + foreach (['CONDSTORE', 'QRESYNC', 'UIDPLUS'] as $capability) { + if (getenv('MAILBOX_SERVER') === 'dovecot') { + expect($capabilities->supports($capability))->toBeTrue(); + } + + if (! $capabilities->supports($capability)) { + test()->markTestSkipped("Server does not support {$capability}."); + } + } +}); + +test('changed since returns updated and new messages but not unchanged messages', function () { + $reader = mailbox(); + $folder = $reader->folders()->create(uniqid('sync-')); + + try { + $unchanged = $folder->messages()->append("Subject: Unchanged\r\n\r\nBody")->uid(); + $updated = $folder->messages()->append("Subject: Updated\r\n\r\nBody")->uid(); + + $checkpoint = $folder->select(true, new CondStore); + + expect($checkpoint->highestModSequence())->toBeGreaterThan(0); + + $writer = mailbox(); + $other = $writer->folders()->findOrFail($folder->path()); + + $other->messages()->findOrFail($updated)->markSeen(); + + $added = $other->messages()->append("Subject: Added\r\n\r\nBody")->uid(); + + // Refresh the selected session's view before querying its saved checkpoint. + $reader->connection()->noop(); + + $changes = $reader->connection()->fetch('1:*', ['UID', 'FLAGS', 'MODSEQ'], modifiers: new ChangedSince($checkpoint->highestModSequence())); + + $messages = $changes->messages()->keyBy(fn ($message) => $message->uid()); + + expect($messages->keys()->all())->toEqualCanonicalizing([$updated, $added]); + expect($messages->has($unchanged))->toBeFalse(); + expect($messages[$updated]->flags())->toContain('\\Seen'); + expect($messages[$updated]->modSequence())->toBeGreaterThan($checkpoint->highestModSequence()); + } finally { + $folder->delete(); + } +}); + +test('quick resync reconciles changed and vanished messages after reconnecting', function () { + $reader = mailbox(); + $folder = $reader->folders()->create(uniqid('resync-')); + + try { + $updated = $folder->messages()->append("Subject: Updated\r\n\r\nBody")->uid(); + $removed = $folder->messages()->append("Subject: Removed\r\n\r\nBody")->uid(); + + $checkpoint = $folder->select(true, new CondStore); + + $reader->disconnect(); + + $writer = mailbox(); + $other = $writer->folders()->findOrFail($folder->path()); + + $other->messages()->findOrFail($updated)->markSeen(); + $other->messages()->findOrFail($removed)->delete(); + $other->expunge([$removed]); + + $added = $other->messages()->append("Subject: Added\r\n\r\nBody")->uid(); + + $reader->reconnect(); + + $selection = $folder->select(true, new QuickResync($checkpoint->uidValidity(), $checkpoint->highestModSequence(), [$updated, $removed])); + + expect($selection->uidValidity())->toBe($checkpoint->uidValidity()); + expect($selection->highestModSequence())->toBeGreaterThan($checkpoint->highestModSequence()); + expect($selection->changes()->vanishedUids()->all())->toBe([$removed]); + + $messages = $selection->changes()->messages()->keyBy(fn ($message) => $message->uid()); + + expect($messages->has($updated))->toBeTrue(); + expect($messages[$updated]->flags())->toContain('\\Seen'); + expect($folder->messages()->uid($checkpoint->uidNext().':*')->get()->map->uid()->all())->toBe([$added]); + expect($folder->messages()->get()->map->uid()->all())->toEqualCanonicalizing([$updated, $added]); + } finally { + $folder->delete(); + } +}); + +test('conditional flag updates reject stale checkpoints and accept current checkpoints', function () { + $reader = mailbox(); + $folder = $reader->folders()->create(uniqid('conflict-')); + + try { + $uid = $folder->messages()->append("Subject: Conflict\r\n\r\nBody")->uid(); + + $checkpoint = $folder->select(true, new CondStore); + + $writer = mailbox(); + + $writer->folders()->findOrFail($folder->path())->messages()->findOrFail($uid)->markSeen(); + + $result = $reader->connection()->store($uid, ['\\Flagged'], modifiers: new UnchangedSince($checkpoint->highestModSequence())); + + expect($result->modified()->all())->toBe([$uid]); + + $current = $reader->connection()->fetch($uid, ['UID', 'FLAGS', 'MODSEQ'])->messages()[0]; + + expect($current->flags())->toContain('\\Seen')->not->toContain('\\Flagged'); + + $result = $reader->connection()->store($uid, ['\\Flagged'], modifiers: new UnchangedSince($current->modSequence())); + + expect($result->response()->successful())->toBeTrue(); + expect($result->modified()->all())->toBe([]); + + $current = $reader->connection()->fetch($uid, ['UID', 'FLAGS'])->messages()[0]; + + expect($current->flags())->toContain('\\Seen', '\\Flagged'); + } finally { + $folder->delete(); + } +}); diff --git a/tests/IntegrationTestCase.php b/tests/IntegrationTestCase.php new file mode 100644 index 0000000..7b01f10 --- /dev/null +++ b/tests/IntegrationTestCase.php @@ -0,0 +1,27 @@ + + */ + public array $mailboxes = []; + + protected function tearDown(): void + { + foreach ($this->mailboxes as $mailbox) { + $mailbox->disconnect(); + } + + $this->mailboxes = []; + + parent::tearDown(); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index a9ab406..d43c2d2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -30,6 +30,9 @@ use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Mailbox; +use Tests\IntegrationTestCase; + +uses(IntegrationTestCase::class)->in('Integration'); expect()->extend('toBeOne', function () { return $this->toBe(1); @@ -53,7 +56,7 @@ function stub(string $filename): string function mailbox(array $config = []): Mailbox { - return new Mailbox([ + $mailbox = new Mailbox([ ...$config, 'host' => getenv('MAILBOX_HOST'), 'port' => getenv('MAILBOX_PORT'), @@ -61,6 +64,10 @@ function mailbox(array $config = []): Mailbox 'password' => getenv('MAILBOX_PASSWORD'), 'encryption' => getenv('MAILBOX_ENCRYPTION'), ]); + + test()->mailboxes = [...test()->mailboxes, $mailbox]; + + return $mailbox; } function parseBodyStructureResponse(string $response): ListData diff --git a/tests/Support/IdleAcknowledgementLogger.php b/tests/Support/IdleAcknowledgementLogger.php new file mode 100644 index 0000000..9d69062 --- /dev/null +++ b/tests/Support/IdleAcknowledgementLogger.php @@ -0,0 +1,53 @@ +tag = $matches[1]; + } + } + + /** + * {@inheritDoc} + */ + public function received(string $message): void + { + if ($this->tag === null || $this->acknowledged) { + return; + } + + if (str_starts_with($message, $this->tag.' ')) { + $this->tag = null; + } elseif (str_starts_with($message, '+')) { + $this->tag = null; + $this->acknowledged = true; + + ($this->callback)(); + } + } +} diff --git a/tests/Support/ScriptedFolder.php b/tests/Support/ScriptedFolder.php new file mode 100644 index 0000000..febf234 --- /dev/null +++ b/tests/Support/ScriptedFolder.php @@ -0,0 +1,33 @@ + $events + */ + public function __construct(Mailbox $mailbox, string $path, protected array $events) + { + parent::__construct($mailbox, $path); + } + + /** + * Deliver scripted events until the consumer stops watching. + */ + public function events(callable $callback, callable|int $timeout = 300, OptionInterface ...$options): void + { + foreach ($this->events as $event) { + if ($callback($event) === false) { + break; + } + } + } +} diff --git a/tests/Support/ScriptedMailbox.php b/tests/Support/ScriptedMailbox.php new file mode 100644 index 0000000..05a36fa --- /dev/null +++ b/tests/Support/ScriptedMailbox.php @@ -0,0 +1,41 @@ + + */ + protected Collection $connections; + + /** + * Create a mailbox with the sessions it is allowed to open. + */ + public function __construct(ConnectionInterface ...$connections) + { + parent::__construct(); + + $this->connections = new Collection($connections); + } + + /** + * Connect using the next scripted session. + */ + public function connect(?ConnectionInterface $connection = null): void + { + if ($this->connected()) { + return; + } + + parent::connect($connection ?? $this->connections->shift() + ?? throw new LogicException('No scripted connections remain.')); + } +} diff --git a/tests/Unit/AppendResultTest.php b/tests/Unit/AppendResultTest.php new file mode 100644 index 0000000..0c16511 --- /dev/null +++ b/tests/Unit/AppendResultTest.php @@ -0,0 +1,37 @@ +uidValidity())->toBe(1234567890) + ->and($result->uid())->toBe(42); +}); + +test('it creates a result without identifiers when APPENDUID is unavailable', function () { + $response = new TaggedResponse([ + new Atom('TAG1'), + new Atom('OK'), + new Atom('APPEND completed'), + ]); + + $result = AppendResult::fromResponse($response); + + expect($result->uidValidity())->toBeNull() + ->and($result->uid())->toBeNull(); +}); diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php new file mode 100644 index 0000000..4848ea1 --- /dev/null +++ b/tests/Unit/ArchitectureTest.php @@ -0,0 +1,10 @@ +expect('DirectoryTree\ImapEngine') + ->interfaces() + ->toHaveSuffix('Interface'); + +arch('all package methods have docblocks') + ->expect('DirectoryTree\ImapEngine') + ->toHaveMethodsDocumented(); diff --git a/tests/Unit/AuthenticationTest.php b/tests/Unit/AuthenticationTest.php new file mode 100644 index 0000000..46b8471 --- /dev/null +++ b/tests/Unit/AuthenticationTest.php @@ -0,0 +1,235 @@ +feed(array_filter([ + '* OK Ready', + $initialResponse ? null : '+', + 'TAG1 OK Authenticated', + ])); + + $logger = new FakeLogger; + + $connection = new ImapConnection($stream, $logger); + $connection->connect('imap.example.com'); + $response = (new Authentication($connection, new XOAuth2('foo', 'secret'))) + ->authenticate(initial: $initialResponse); + + $credentials = base64_encode("user=foo\1auth=Bearer secret\1\1"); + + if ($initialResponse) { + $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2 $credentials\r\n"); + } else { + $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2\r\n"); + $stream->assertWritten($credentials."\r\n"); + } + + expect($response->successful())->toBeTrue(); + $logger->assertSent('[redacted]', $initialResponse ? 1 : 2); +})->with([false, true]); + +test('oauth acknowledges an error challenge with an empty continuation and consumes completion', function (bool $initialResponse) { + $stream = new FakeStream; + $stream->feed(array_filter([ + '* OK Ready', + $initialResponse ? null : '+', + '+ '.base64_encode('{"status":"401","schemes":"bearer"}'), + 'TAG1 NO Invalid credentials', + 'TAG2 OK NOOP completed', + ])); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => (new Authentication($connection, new XOAuth2('foo', 'secret'))) + ->authenticate(initial: $initialResponse)) + ->toThrow(ImapCommandException::class); + + $credentials = base64_encode("user=foo\1auth=Bearer secret\1\1"); + + if ($initialResponse) { + $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2 $credentials\r\n"); + } else { + $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2\r\n"); + $stream->assertWritten($credentials."\r\n"); + } + + $stream->assertWritten("\r\n"); + $stream->assertNotWritten('='); + expect($connection->noop()->successful())->toBeTrue(); +})->with([false, true]); + +test('authentication supports multiple decoded challenges for custom mechanisms', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+ '.base64_encode('Username:'), + '+ '.base64_encode('Password:'), + 'TAG1 OK Authenticated', + ]); + + $authenticator = new class implements AuthenticatorInterface + { + public array $challenges = []; + + public function mechanism(): string + { + return 'LOGIN'; + } + + public function initial(): ?string + { + return null; + } + + public function respond(string $challenge): string + { + $this->challenges[] = $challenge; + + return match ($challenge) { + 'Username:' => 'foo', + 'Password:' => 'secret', + }; + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + (new Authentication($connection, $authenticator))->authenticate(initial: true); + + $stream->assertWritten("TAG1 AUTHENTICATE LOGIN\r\n"); + $stream->assertWritten(base64_encode('foo')."\r\n"); + $stream->assertWritten(base64_encode('secret')."\r\n"); + expect($authenticator->challenges)->toBe(['Username:', 'Password:']); +}); + +test('authentication encodes an empty initial response as equals', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Authenticated', + ]); + + $authenticator = new class implements AuthenticatorInterface + { + public function mechanism(): string + { + return 'EXTERNAL'; + } + + public function initial(): string + { + return ''; + } + + public function respond(string $challenge): string + { + return ''; + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + (new Authentication($connection, $authenticator))->authenticate(initial: true); + + $stream->assertWritten("TAG1 AUTHENTICATE EXTERNAL =\r\n"); +}); + +test('authentication can be cancelled by the authenticator', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+ '.base64_encode('challenge'), + 'TAG1 BAD Authentication cancelled', + 'TAG2 OK NOOP completed', + ]); + + $authenticator = new class implements AuthenticatorInterface + { + public function mechanism(): string + { + return 'X-CUSTOM'; + } + + public function initial(): ?string + { + return null; + } + + public function respond(string $challenge): ?string + { + return null; + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => (new Authentication($connection, $authenticator))->authenticate()) + ->toThrow(ImapCommandException::class); + + $stream->assertWritten("*\r\n"); + expect($connection->noop()->successful())->toBeTrue(); +}); + +test('authenticator exceptions disconnect an unfinished authentication exchange', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+ '.base64_encode('challenge'), + ]); + + $authenticator = new class implements AuthenticatorInterface + { + public function mechanism(): string + { + return 'X-CUSTOM'; + } + + public function initial(): ?string + { + return null; + } + + public function respond(string $challenge): ?string + { + throw new RuntimeException('Unable to respond'); + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => (new Authentication($connection, $authenticator))->authenticate()) + ->toThrow(RuntimeException::class, 'Unable to respond'); + expect($connection->connected())->toBeFalse(); +}); + +test('mailbox xoauth2 configuration uses challenge based authentication', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+', + 'TAG1 OK Authenticated', + ]); + + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'secret', + 'authentication' => 'xoauth2', + ]); + $mailbox->connect(new ImapConnection($stream)); + + $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2\r\n"); + $stream->assertWritten(base64_encode("user=foo\1auth=Bearer secret\1\1")."\r\n"); + expect($mailbox->connected())->toBeTrue(); +}); diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php new file mode 100644 index 0000000..f5ced9c --- /dev/null +++ b/tests/Unit/CapabilitiesTest.php @@ -0,0 +1,59 @@ +name())->toBe('QRESYNC'); + expect($capability->enabled())->toBeTrue(); + expect((new ReflectionClass($capability))->isReadOnly())->toBeTrue(); + expect((new ReflectionMethod(Capability::class, '__construct'))->isProtected())->toBeTrue(); +}); + +test('it determines supported capabilities', function () { + $capabilities = Capabilities::from( + Capability::make('IMAP4rev1'), + Capability::make('STARTTLS'), + Capability::make('starttls'), + Capability::make('AUTH=PLAIN'), + ); + + expect($capabilities->all())->toBe([ + 'IMAP4REV1', + 'STARTTLS', + 'AUTH=PLAIN', + ]); + expect($capabilities->supports('imap4rev1'))->toBeTrue(); + expect($capabilities->supports('AUTH'))->toBeTrue(); + expect($capabilities->supports('AUTH=PLAIN'))->toBeTrue(); + expect($capabilities->supports('AUTH=LOGIN'))->toBeFalse(); + expect($capabilities->supports('START'))->toBeFalse(); + expect($capabilities->has('auth'))->toBeFalse(); + expect($capabilities->has('auth=plain'))->toBeTrue(); + expect($capabilities->has('START'))->toBeFalse(); +}); + +test('it determines enabled capabilities', function () { + $capabilities = Capabilities::from( + Capability::make('QRESYNC'), + Capability::make('AUTH=PLAIN'), + Capability::make('auth=xoauth2', enabled: true), + ); + + expect($capabilities->enabled('QRESYNC'))->toBeFalse(); + expect($capabilities->enabled('AUTH'))->toBeFalse(); + expect($capabilities->enabled('AUTH=PLAIN'))->toBeFalse(); + expect($capabilities->enabled('AUTH=XOAUTH2'))->toBeTrue(); + expect((new ReflectionClass($capabilities))->isReadOnly())->toBeTrue(); + + $items = $capabilities->items(); + $items['QRESYNC'] = Capability::make('QRESYNC', enabled: true); + + expect($capabilities->enabled('QRESYNC'))->toBeFalse(); +}); + +test('it accepts only capability instances', function () { + expect(fn () => Capabilities::from('QRESYNC'))->toThrow(TypeError::class); +}); diff --git a/tests/Unit/Connection/CommandArgumentTest.php b/tests/Unit/Connection/CommandArgumentTest.php new file mode 100644 index 0000000..eb6c532 --- /dev/null +++ b/tests/Unit/Connection/CommandArgumentTest.php @@ -0,0 +1,63 @@ +toBe('"hello"'); + expect(CommandArgument::literal('He said: "Hi"'))->toBe('"He said: \\"Hi\\""'); +}); + +test('charset uses atoms when possible and quotes other names', function () { + expect(CommandArgument::charset('UTF-8'))->toBe('UTF-8'); + expect(CommandArgument::charset('US-ASCII'))->toBe('US-ASCII'); + expect(CommandArgument::charset('UTF "8"'))->toBe('"UTF \\"8\\""'); +}); + +test('charset rejects control characters', function (string $charset) { + expect(fn () => CommandArgument::charset($charset))->toThrow(InvalidArgumentException::class); +})->with(["UTF\0-8", "UTF\t-8", "UTF\r-8", "UTF\n-8", "UTF\x7f-8"]); + +test('atom accepts valid IMAP atoms', function (string $atom) { + expect(CommandArgument::atom($atom))->toBe($atom); +})->with(['QRESYNC', 'UTF8=ACCEPT', 'X-GOOD-IDEA']); + +test('atom rejects invalid IMAP atoms', function (string $atom) { + expect(fn () => CommandArgument::atom($atom))->toThrow(InvalidArgumentException::class); +})->with(['', 'BAD CAPABILITY', "BAD\r\nCAPABILITY", 'BAD]CAPABILITY', 'BAD\\CAPABILITY']); + +test('mechanism accepts valid SASL mechanism names', function (string $mechanism) { + expect(CommandArgument::mechanism($mechanism))->toBe($mechanism); +})->with(['PLAIN', 'XOAUTH2', 'X-CUSTOM_MECHANISM']); + +test('mechanism rejects invalid SASL mechanism names', function (string $mechanism) { + expect(fn () => CommandArgument::mechanism($mechanism))->toThrow(InvalidArgumentException::class); +})->with(['', 'plain', 'BAD MECHANISM', "BAD\r\nMECHANISM", str_repeat('A', 21)]); + +test('literal preserves carriage returns and newlines using literals', function (string $input) { + $expected = ['{'.strlen($input).'}', $input]; + expect(CommandArgument::literal($input))->toBe($expected); +})->with(["hello\nworld", "hello\rworld", "hello\r\nworld"]); + +test('literal handles an array of literals', function () { + expect(CommandArgument::literal(['first', 'second']))->toBe(['"first"', '"second"']); +}); + +test('list returns a properly formatted parenthesized list for a flat array', function () { + expect(CommandArgument::list(['"a"', '"b"', '"c"']))->toBe('("a" "b" "c")'); +}); + +test('list handles nested arrays recursively', function () { + expect(CommandArgument::list(['"a"', ['"b"', '"c"']]))->toBe('("a" ("b" "c"))'); +}); + +test('list returns empty parentheses for an empty array', function () { + expect(CommandArgument::list([]))->toBe('()'); +}); + +test('literal preserves control and non-ASCII bytes', function (string $input) { + expect(CommandArgument::literal($input))->toBe(['{'.strlen($input).'}', $input]); +})->with(["password\tvalue", "value\x01", "value\x7f", 'café', "value\xff"]); + +test('literal rejects NUL bytes', function () { + expect(fn () => CommandArgument::literal("pass\0word"))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/Connection/CommandPartTest.php b/tests/Unit/Connection/CommandPartTest.php new file mode 100644 index 0000000..9f25cc1 --- /dev/null +++ b/tests/Unit/Connection/CommandPartTest.php @@ -0,0 +1,20 @@ +toBe($expected); +})->with([ + 'string' => ['UID FETCH', 'UID FETCH'], + 'integer' => [42, '42'], +]); + +test('it rejects control characters', function (string $value) { + expect(fn () => new CommandPart($value)) + ->toThrow(InvalidArgumentException::class); +})->with([ + "TAG1\r\nTAG2 LOGOUT", + "TAG1\0TAG2", + "TAG1\tTAG2", + "TAG1\x7FTAG2", +]); diff --git a/tests/Unit/Connection/FakeLoggerTest.php b/tests/Unit/Connection/FakeLoggerTest.php new file mode 100644 index 0000000..3c4c630 --- /dev/null +++ b/tests/Unit/Connection/FakeLoggerTest.php @@ -0,0 +1,16 @@ +sent('sent message'); + $logger->sent('sent message'); + $logger->received('received message'); + + $logger->assertSent('sent message', times: 2); + $logger->assertReceived('received message'); + $logger->assertNotSent('other sent message'); + $logger->assertNotReceived('other received message'); +}); diff --git a/tests/Unit/Connection/FakeStreamTest.php b/tests/Unit/Connection/FakeStreamTest.php new file mode 100644 index 0000000..e6c1840 --- /dev/null +++ b/tests/Unit/Connection/FakeStreamTest.php @@ -0,0 +1,41 @@ +{$failure}(); + $stream->open(); + $stream->feed(['first', 'second']); + + if ($readBytes) { + expect($stream->read(3))->toBe('fir'); + expect($stream->meta()[$metadata])->toBeFalse(); + expect($stream->read(100))->toBe("st\r\nsecond\r\n"); + } else { + expect($stream->fgets())->toBe("first\r\n"); + expect($stream->meta()[$metadata])->toBeFalse(); + expect($stream->fgets())->toBe("second\r\n"); + } + + expect($stream->meta()[$metadata])->toBeFalse(); + expect($readBytes ? $stream->read(1) : $stream->fgets())->toBeFalse(); + expect($stream->meta()[$metadata])->toBeTrue(); +})->with([ + 'disconnect while reading lines' => ['disconnectWhenEmpty', 'eof', false], + 'disconnect while reading bytes' => ['disconnectWhenEmpty', 'eof', true], + 'timeout while reading lines' => ['timeoutWhenEmpty', 'timed_out', false], + 'timeout while reading bytes' => ['timeoutWhenEmpty', 'timed_out', true], +]); + +test('an unscripted empty stream does not report a disconnect or timeout', function () { + $stream = new FakeStream; + $stream->open(); + + expect($stream->fgets())->toBeFalse(); + expect($stream->read(1))->toBe(''); + expect($stream->meta())->toMatchArray(['eof' => false, 'timed_out' => false]); + + $stream->feed('arrived later'); + + expect($stream->fgets())->toBe("arrived later\r\n"); +}); diff --git a/tests/Unit/Connection/IdleTest.php b/tests/Unit/Connection/IdleTest.php new file mode 100644 index 0000000..595f316 --- /dev/null +++ b/tests/Unit/Connection/IdleTest.php @@ -0,0 +1,288 @@ +timeouts[] = $seconds; + + return true; + } + }; + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS', 'TAG1 OK IDLE completed']); + $connection = new ImapConnection($stream); + + try { + $connection->connect('localhost'); + $session = $connection->idle(); + + if ($consume) { + $responses = $session->responses(300); + $responses->current(); + Carbon::setTestNow(Carbon::now()->addSeconds(301)); + } + + $stream->timeouts = []; + $timeout === null ? $session->finish() : $session->finish($timeout); + + expect($stream->timeouts)->not->toBeEmpty(); + expect(array_unique($stream->timeouts))->toBe([$expected]); + expect($session->active())->toBeFalse(); + $stream->assertWritten('DONE'); + } finally { + $connection->disconnect(); + Carbon::setTestNow(); + } +})->with([ + 'default before continuation' => [null, 30, false], + 'short before continuation' => [5, 5, false], + 'long before continuation' => [90, 90, false], + 'short after renewal deadline' => [5, 5, true], + 'long after renewal deadline' => [90, 90, true], +]); + +test('an idle session owns the connection until it finishes', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS', 'TAG1 OK IDLE completed', 'TAG2 OK NOOP completed']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + + expect(fn () => $connection->noop())->toThrow(LogicException::class); + expect(fn () => $connection->idle())->toThrow(LogicException::class); + + $responses = $session->responses(); + $responses->current(); + + expect(fn () => $connection->noop())->toThrow(LogicException::class); + expect($session->active())->toBeTrue(); + expect($session->finish())->toBeEmpty(); + expect($session->active())->toBeFalse(); + expect($connection->noop()->successful())->toBeTrue(); +}); + +test('a session can finish before its responses are consumed', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '* 4 EXISTS', '+ idling', '* 3 EXPUNGE', 'TAG1 OK IDLE completed']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + + expect($session->finish()->map(fn ($response) => (string) $response)->all())->toBe([ + '* 4 EXISTS', '* 3 EXPUNGE', + ]); + expect(iterator_to_array($session->responses()))->toBeEmpty(); + $stream->assertWritten('DONE'); +}); + +test('a session rejects competing response consumers', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $responses = $session->responses(); + $responses->current(); + + expect(fn () => $session->responses()->current())->toThrow(LogicException::class); + $connection->disconnect(); +}); + +test('a disconnected session cannot consume responses from a new session', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $responses = $session->responses(); + $responses->current(); + $connection->disconnect(); + + $stream->feed(['* OK Welcome back', '+ idling', '* 5 EXISTS', 'TAG2 OK IDLE completed']); + $connection->connect('localhost'); + $nextSession = $connection->idle(); + + expect($session->active())->toBeFalse(); + expect($session->finish())->toBeEmpty(); + $responses->next(); + expect($responses->valid())->toBeFalse(); + expect($nextSession->finish()->map(fn ($response) => (string) $response)->all())->toBe(['* 5 EXISTS']); +}); + +test('a missing done acknowledgement invalidates the session and closes the connection', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $responses = $session->responses(); + $responses->current(); + $stream->setMeta('timed_out', true); + + expect(fn () => $session->finish())->toThrow(ImapConnectionTimedOutException::class); + expect($session->active())->toBeFalse(); + expect($connection->connected())->toBeFalse(); + expect($session->finish())->toBeEmpty(); + $stream->assertWritten('DONE'); +}); + +test('idle preserves updates before continuation and while ending', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', + '* 4 EXISTS', + '+ idling', + '* 2 FETCH (FLAGS (\\Seen))', + '* 3 EXPUNGE', + '* VANISHED 8:9', + 'TAG1 OK IDLE completed', + 'TAG2 OK NOOP completed', + ]); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $idle = $session->responses(30); + + expect((string) $idle->current())->toBe('* 4 EXISTS'); + $idle->next(); + expect((string) $idle->current())->toBe('* 2 FETCH (FLAGS (\\Seen))'); + + $pending = $session->finish(); + expect($pending->map(fn ($response) => (string) $response)->all())->toBe([ + '* 3 EXPUNGE', '* VANISHED 8:9', + ]); + expect($connection->noop()->successful())->toBeTrue(); + expect($session->finish())->toBeEmpty(); + $stream->assertWritten('DONE'); + $stream->assertWritten('TAG2 NOOP'); +}); + +test('idle rejection is reported immediately', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', 'TAG1 NO IDLE unavailable']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + + $session = $connection->idle(); + + expect(fn () => iterator_to_array($session->responses(30)))->toThrow(ImapCommandException::class); + $stream->assertNotWritten('DONE'); +}); + +test('idle reports server shutdown as a closed connection', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* BYE Server shutting down']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + + $session = $connection->idle(); + + expect(fn () => iterator_to_array($session->responses(30)))->toThrow(ImapConnectionClosedException::class); + expect($session->finish())->toBeEmpty(); +}); + +test('idle responses retain distinct generator keys across continuation', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', '* 4 EXISTS', '+ idling', '* 3 EXPUNGE', 'TAG1 OK IDLE completed', + ]); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + + $session = $connection->idle(); + $responses = iterator_to_array($session->responses(30)); + + expect(array_map(fn ($response) => (string) $response, $responses))->toBe([ + '* 4 EXISTS', '* 3 EXPUNGE', + ]); +}); + +test('idle can finish after a read timeout without losing queued changes', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $idle = $session->responses(30); + $idle->current(); + $stream->setMeta('timed_out', true); + + expect(fn () => $idle->next())->toThrow(ImapConnectionTimedOutException::class); + + $stream->setMeta('timed_out', false); + $stream->feed(['* 3 EXPUNGE', 'TAG1 OK IDLE completed']); + + expect((string) $session->finish()->first())->toBe('* 3 EXPUNGE'); + $stream->assertWritten('DONE'); +}); + +test('a timeout before continuation closes the ambiguous session', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed('* OK Welcome'); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $stream->setMeta('timed_out', true); + + $session = $connection->idle(); + + expect(fn () => iterator_to_array($session->responses(30)))->toThrow(ImapConnectionClosedException::class); + expect($connection->connected())->toBeFalse(); + expect($session->finish())->toBeEmpty(); + $stream->assertNotWritten('DONE'); +}); + +test('failed idle completion is surfaced by finish', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* OK Welcome', '+ idling', '* 4 EXISTS', 'TAG1 NO IDLE failed']); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $idle = $session->responses(30); + $idle->current(); + + expect(fn () => $session->finish())->toThrow(ImapCommandException::class); + expect($session->finish())->toBeEmpty(); +}); + +test('finish preserves pre-continuation updates not yet consumed by the generator', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', '* 4 EXISTS', '* 2 EXPUNGE', '+ idling', + '* 3 EXISTS', 'TAG1 OK IDLE completed', + ]); + $connection = new ImapConnection($stream); + $connection->connect('localhost'); + $session = $connection->idle(); + $idle = $session->responses(30); + + expect((string) $idle->current())->toBe('* 4 EXISTS'); + expect($session->finish()->map(fn ($response) => (string) $response)->all())->toBe([ + '* 2 EXPUNGE', '* 3 EXISTS', + ]); + $idle->next(); + expect($idle->valid())->toBeFalse(); +}); diff --git a/tests/Unit/Connection/ImapCommandTest.php b/tests/Unit/Connection/ImapCommandTest.php index a3a71b3..beb7342 100644 --- a/tests/Unit/Connection/ImapCommandTest.php +++ b/tests/Unit/Connection/ImapCommandTest.php @@ -32,6 +32,16 @@ expect((string) $lines[0])->toBe('A002 LOGIN [redacted] [redacted]'); }); +test('redacted preserves literal framing while hiding its contents', function () { + $cmd = new ImapCommand('A002', 'LOGIN', [['{4}', 'user']]); + + $lines = $cmd->redacted()->compile(); + + expect($lines)->toHaveCount(2); + expect((string) $lines[0])->toBe('A002 LOGIN {4}'); + expect((string) $lines[1])->toBe('[redacted]'); +}); + test('compile returns correct command lines with a literal token', function () { $cmd = new ImapCommand('A003', 'APPEND "INBOX"', [ ['{20}', 'literal-data'], @@ -109,3 +119,21 @@ expect($firstCall)->toBe($secondCall); }); + +test('compile rejects invalid literal markers', function (string $marker) { + $cmd = new ImapCommand('A001', 'APPEND', [[$marker, 'test']]); + + expect(fn () => $cmd->compile())->toThrow(InvalidArgumentException::class); +})->with([ + '{four}', + "{4}\r\nA002 LOGOUT", +]); + +test('compile preserves control characters inside literal contents', function () { + $cmd = new ImapCommand('A001', 'APPEND', [['{12}', "hello\r\nworld"]]); + + $lines = $cmd->compile(); + + expect((string) $lines[0])->toBe('A001 APPEND {12}'); + expect((string) $lines[1])->toBe("hello\r\nworld"); +}); diff --git a/tests/Unit/Connection/ImapConnectionFilteringTest.php b/tests/Unit/Connection/ImapConnectionFilteringTest.php new file mode 100644 index 0000000..8d0291a --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionFilteringTest.php @@ -0,0 +1,123 @@ +feed([ + '* OK Ready', + '* 99 FETCH (FLAGS (\\Answered))', + '* 8 FETCH (UID 8 FLAGS (\\Flagged))', + "* $number FETCH (UID $uid FLAGS (\\Seen))", + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $command === 'fetch' + ? $connection->fetch($set, 'FLAGS', identifier: $identifier) + : $connection->store($set, '\\Seen', silent: false, identifier: $identifier); + + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->uid())->toBe($uid); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->responses())->toHaveCount(4); + expect((string) $result->responses()->untagged()->all()[1])->toBe('* 8 FETCH (UID 8 FLAGS (\\Flagged))'); +})->with([ + 'integer' => [7], + 'array' => [[1, 7, 9]], + 'string' => ['7'], + 'ascending range' => ['5:7'], + 'descending range' => ['7:5'], + 'mixed ranges' => ['1,5:7,9'], +])->with(['fetch', 'store'])->with([ImapIdentifier::Uid, ImapIdentifier::MessageNumber]); + +test('sequence addressed results do not require a uid', function (string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 8 FETCH (FLAGS (\\Flagged))', + '* 7 FETCH (FLAGS (\\Seen))', + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $command === 'fetch' + ? $connection->fetch(7, 'FLAGS', identifier: ImapIdentifier::MessageNumber) + : $connection->store(7, '\\Seen', identifier: ImapIdentifier::MessageNumber); + + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->sequenceNumber())->toBe(7); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->responses())->toHaveCount(3); +})->with(['fetch', 'store']); + +test('fetch and store filter large sparse message sets', function (string $command) { + $set = range(1, 1999, 2); + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 1 FETCH (UID 1 FLAGS (\\Seen))', + '* 2 FETCH (UID 1000 FLAGS (\\Flagged))', + '* 3 FETCH (UID 1001 FLAGS (\\Seen))', + '* 4 FETCH (UID 1999 FLAGS (\\Seen))', + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $command === 'fetch' + ? $connection->fetch($set, 'FLAGS') + : $connection->store($set, '\\Seen', silent: false); + + expect($result->messages()->map(fn ($message) => $message->uid())->all())->toBe([1, 1001, 1999]); +})->with(['fetch', 'store']); + +test('fetch and store filter overlapping message ranges', function (string $command, ImapIdentifier $identifier) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 4 FETCH (UID 4 FLAGS (\\Flagged))', + '* 7 FETCH (UID 7 FLAGS (\\Seen))', + '* 10 FETCH (UID 10 FLAGS (\\Seen))', + '* 17 FETCH (UID 17 FLAGS (\\Flagged))', + '* 19 FETCH (UID 19 FLAGS (\\Seen))', + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $command === 'fetch' + ? $connection->fetch('10:5,1:7,20:18', 'FLAGS', identifier: $identifier) + : $connection->store('10:5,1:7,20:18', '\\Seen', silent: false, identifier: $identifier); + + expect($result->messages()->map(fn ($message) => $message->uid())->all())->toBe([4, 7, 10, 19]); +})->with(['fetch', 'store'])->with([ImapIdentifier::Uid, ImapIdentifier::MessageNumber]); + +test('server resolved sets do not discard potentially requested messages', function (string $set, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 3 FETCH (UID 7 FLAGS (\\Seen))', + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $command === 'fetch' + ? $connection->fetch($set, 'FLAGS') + : $connection->store($set, '\\Seen'); + + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->uid())->toBe(7); +})->with(['*', '999:*', '*:999', '$', '1:4294967295'])->with(['fetch', 'store']); diff --git a/tests/Unit/Connection/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php new file mode 100644 index 0000000..9d0460d --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -0,0 +1,272 @@ +feed([ + '* OK Welcome to IMAP', + 'TAG1 OK STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store([1, 2, 4], '\\Seen', mode: $mode, silent: $silent); + + $stream->assertWritten("TAG1 UID STORE 1:2,4 $item (\\Seen)"); + expect($result)->toBeInstanceOf(StoreResult::class); + expect($result->response()->successful())->toBeTrue(); +})->with([ + 'add' => ['+', false, '+FLAGS'], + 'remove' => ['-', false, '-FLAGS'], + 'replace' => [null, false, 'FLAGS'], + 'add silently' => ['+', true, '+FLAGS.SILENT'], + 'remove silently' => ['-', true, '-FLAGS.SILENT'], + 'replace silently' => [null, true, 'FLAGS.SILENT'], +]); + +test('non silent store retains fetched messages and raw responses', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 FETCH (UID 7 FLAGS (\\Seen))', + 'TAG1 OK STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store(7, '\\Seen', silent: false); + + $stream->assertWritten('TAG1 UID STORE 7 +FLAGS (\\Seen)'); + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->uid())->toBe(7); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->responses())->toHaveCount(2); + expect($result->modified()->all())->toBe([]); +}); + +test('silent store retains returned modification sequences', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 FETCH (UID 7 MODSEQ (44))', + 'TAG1 OK STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store(7, '\\Seen'); + + $stream->assertWritten('TAG1 UID STORE 7 +FLAGS.SILENT (\\Seen)'); + expect($result->messages()[0]->modSequence())->toBe(44); +}); + +test('conditional store returns conflicting message numbers', function (string $status) { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 FETCH (FLAGS (\\Seen) MODSEQ (44))', + "TAG1 $status [MODIFIED 2:3] Conditional STORE completed", + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store( + '1:3', '\\Seen', mode: null, silent: false, + identifier: ImapIdentifier::MessageNumber, + modifiers: new UnchangedSince(43), + ); + + $stream->assertWritten('TAG1 STORE 1:3 (UNCHANGEDSINCE 43) FLAGS (\\Seen)'); + expect($result->modified()->all())->toBe([2, 3]); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->messages()[0]->modSequence())->toBe(44); + expect($result->response()->successful())->toBe($status === 'OK'); +})->with(['OK', 'NO']); + +test('store rejects failures that are not conditional conflicts', function (string $response) { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + $response, + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->store(7, '\\Seen')) + ->toThrow(ImapCommandException::class); +})->with([ + 'TAG1 NO Permission denied', + 'TAG1 BAD Invalid arguments', + 'TAG1 BAD [MODIFIED 7] Invalid arguments', +]); + +test('store combines modifiers in one list and preserves a zero checkpoint', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK [MODIFIED 7] Conditional STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $custom = new class implements ModifierInterface + { + public function toImap(): string + { + return 'X-CUSTOM'; + } + }; + + $result = $connection->store(7, '\\Seen', '+', true, ImapIdentifier::Uid, new UnchangedSince(0), $custom); + + $stream->assertWritten('TAG1 UID STORE 7 (UNCHANGEDSINCE 0 X-CUSTOM) +FLAGS.SILENT (\\Seen)'); + expect($result->modified()->all())->toBe([7]); +}); + +test('search supports both identifier types without changing search criteria', function (ImapIdentifier $identifier, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* SEARCH 2 3', + 'TAG1 OK SEARCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $response = $connection->search(['UID 7:9'], identifier: $identifier); + + $stream->assertWritten("TAG1 $command UID 7:9"); + expect((string) $response)->toBe('* SEARCH 2 3'); +})->with([ + [ImapIdentifier::Uid, 'UID SEARCH'], + [ImapIdentifier::MessageNumber, 'SEARCH'], +]); + +test('sort supports both identifier types', function (ImapIdentifier $identifier, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* SORT 3 2', + 'TAG1 OK SORT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $sort = new ImapSort(new SortCriterion(ImapSortKey::Arrival)); + + $response = $connection->sort($sort, ['ALL'], identifier: $identifier); + + $stream->assertWritten("TAG1 $command (ARRIVAL) UTF-8 ALL"); + expect((string) $response)->toBe('* SORT 3 2'); +})->with([ + [ImapIdentifier::Uid, 'UID SORT'], + [ImapIdentifier::MessageNumber, 'SORT'], +]); + +test('copy and move support both identifier types', function (string $method, ImapIdentifier $identifier, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + "TAG1 OK $command completed", + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $response = $connection->{$method}('1:3', 'Archive', identifier: $identifier); + + $stream->assertWritten("TAG1 $command 1:3 \"Archive\""); + expect($response->successful())->toBeTrue(); +})->with([ + ['copy', ImapIdentifier::Uid, 'UID COPY'], + ['copy', ImapIdentifier::MessageNumber, 'COPY'], + ['move', ImapIdentifier::Uid, 'UID MOVE'], + ['move', ImapIdentifier::MessageNumber, 'MOVE'], +]); + +test('get quota sends the matching command', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* QUOTA "root" (STORAGE 10 100)', + 'TAG1 OK GETQUOTA completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $response = $connection->getQuota('root'); + + $stream->assertWritten('TAG1 GETQUOTA "root"'); + expect((string) $response)->toBe('* QUOTA "root" (STORAGE 10 100)'); +}); + +test('get quota root preserves the mailbox mapping and each quota response', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* QUOTAROOT "INBOX" "root" "other"', + '* QUOTA "root" (STORAGE 10 100)', + '* QUOTA "other" (MESSAGE 2 20)', + '* 5 EXISTS', + 'TAG1 OK GETQUOTAROOT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->getQuotaRoot('INBOX'); + + $stream->assertWritten('TAG1 GETQUOTAROOT "INBOX"'); + expect($responses)->toHaveCount(3); + expect((string) $responses->first())->toBe('* QUOTAROOT "INBOX" "root" "other"'); + expect((string) $responses->last())->toBe('* QUOTA "other" (MESSAGE 2 20)'); +}); + +test('get quota root preserves a mailbox with no quota roots', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + '* QUOTAROOT "INBOX"', + 'TAG1 OK GETQUOTAROOT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->getQuotaRoot('INBOX'); + + expect($responses)->toHaveCount(1); + expect((string) $responses->first())->toBe('* QUOTAROOT "INBOX"'); +}); + +test('logout closes the local connection when the server closes before completion', function () { + $stream = new FakeStream; + $stream->feed('* OK Welcome to IMAP'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $stream->setMeta('eof', true); + + expect(fn () => $connection->logout())->toThrow(ImapConnectionClosedException::class); + expect($connection->connected())->toBeFalse(); + $stream->assertWritten('TAG1 LOGOUT'); +}); diff --git a/tests/Unit/Connection/ImapConnectionParametersTest.php b/tests/Unit/Connection/ImapConnectionParametersTest.php new file mode 100644 index 0000000..767f340 --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -0,0 +1,560 @@ +feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->authenticate($mechanism)->current()) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with(['', 'plain', 'BAD MECHANISM', "XOAUTH2\r\nTAG2 LOGOUT", str_repeat('A', 21)]); + +test('enable rejects invalid capability names before writing', function (string $capability) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->enable($capability)) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with(['', 'BAD CAPABILITY', "QRESYNC\r\nTAG2 LOGOUT", 'BAD]CAPABILITY']); + +test('enable rejects an empty capability list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->enable()) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('id preserves field names and nil values', function (?array $parameters, string $expected) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* ID NIL', + 'TAG1 OK ID completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->id($parameters); + + $stream->assertWritten("TAG1 ID $expected"); +})->with([ + [null, 'NIL'], + [[], '()'], + [['name' => 'ImapEngine', 'version' => null], '("name" "ImapEngine" "version" NIL)'], +]); + +test('id frames literals at either end of a list without counting its parentheses', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+ Ready for field', + '+ Ready for value', + '* ID NIL', + 'TAG1 OK ID completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->id(["na\nme" => "v\n2"]); + + $stream->assertWritten('TAG1 ID ({5}'); + $stream->assertWritten("na\nme {3}"); + $stream->assertWritten("v\n2)"); +}); + +test('append always sends an exact message literal', function (string $message) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+ Ready for message', + 'TAG1 OK APPEND completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->append('INBOX', $message); + + $stream->assertWritten('TAG1 APPEND "INBOX" {'.strlen($message)."}\r\n"); + $stream->assertWritten($message."\r\n"); +})->with(['', 'A "quoted" message with a \\ slash', 'Bonjour été']); + +test('append rejects injected flags before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->append('INBOX', 'Message', ["\\Seen\r\nTAG2 LOGOUT"])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('fetch expands macros into valid data item lists', function (string $macro, string $items, string $extra) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 1 FETCH (FLAGS () INTERNALDATE "02-Sep-2026 10:00:00 +0000" RFC822.SIZE 20'.$extra.')', + '* 2 FETCH (FLAGS (\\Seen))', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $result = $connection->fetch('1:*', $macro, identifier: ImapIdentifier::MessageNumber); + + $stream->assertWritten("TAG1 FETCH 1:* ($items)"); + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->size())->toBe(20); +})->with([ + ['ALL', 'FLAGS INTERNALDATE RFC822.SIZE ENVELOPE', ' ENVELOPE NIL'], + ['fast', 'FLAGS INTERNALDATE RFC822.SIZE', ''], + ['FULL', 'FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY', ' ENVELOPE NIL BODY NIL'], +]); + +test('sequence fetch matches response attributes for peek and partial requests', function (string $request, string $attribute) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 1 FETCH ('.$attribute.' {5}', + 'hello)', + '* 2 FETCH (FLAGS (\\Seen))', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $result = $connection->fetch(1, $request, identifier: ImapIdentifier::MessageNumber); + + $stream->assertWritten("TAG1 FETCH 1 ($request)"); + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->get($attribute))->toBe('hello'); +})->with([ + ['BODY.PEEK[TEXT]', 'BODY[TEXT]'], + ['body.peek[header]', 'BODY[HEADER]'], + ['BODY.PEEK[TEXT]<0.5>', 'BODY[TEXT]<0>'], + ['BODY[1]<10.5>', 'BODY[1]<10>'], + ['BINARY.PEEK[1]<10.5>', 'BINARY[1]<10>'], + ['BODY.PEEK[HEADER.FIELDS (SUBJECT FROM)]', 'BODY[HEADER.FIELDS (SUBJECT FROM)]'], +]); + +test('message operations accept raw sequence sets', function (string $method, array $arguments, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->{$method}('1:3,7:*', ...$arguments); + + $stream->assertWritten("TAG1 $command"); +})->with([ + ['fetch', ['FLAGS'], 'UID FETCH 1:3,7:* (FLAGS)'], + ['store', ['\\Seen'], 'UID STORE 1:3,7:* +FLAGS.SILENT (\\Seen)'], + ['copy', ['Archive'], 'UID COPY 1:3,7:* "Archive"'], + ['move', ['Archive'], 'UID MOVE 1:3,7:* "Archive"'], + ['expunge', [], 'UID EXPUNGE 1:3,7:*'], +]); + +test('message operations reject injected sequence sets before writing', function (string $method, array $arguments) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->{$method}("1\r\nTAG2 LOGOUT", ...$arguments)) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with([ + ['fetch', ['FLAGS']], + ['store', ['\\Seen']], + ['copy', ['Archive']], + ['move', ['Archive']], + ['expunge', []], +]); + +test('quick resync rejects injected message sets before selecting', function (array|int|string $knownUids, ?array $sequenceMatch) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->select('INBOX', new QuickResync(777, 42, $knownUids, $sequenceMatch))) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with([ + ["1\r\nTAG2 LOGOUT", null], + [[1, "2\r\nTAG2 LOGOUT"], null], + [[], [["1\r\nTAG2 LOGOUT"], [2]]], + [0, null], + ['', null], + ['*', null], + ['$', null], +]); + +test('quick resync rejects invalid sequence match data before selecting', function (array $sequenceMatch) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->select('INBOX', new QuickResync(777, 42, [], $sequenceMatch))) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with([ + 'missing uid set' => [[[1, 2]]], + 'additional set' => [[[1, 2], [5, 6], [9, 10]]], + 'associative pair' => [['messages' => [1, 2], 'uids' => [5, 6]]], + 'unequal cardinality' => [[[1, 2, 3], [5, 6]]], + 'descending message numbers' => [[[3, 2, 1], [5, 6, 9]]], + 'descending uids' => [[[1, 2, 3], [9, 6, 5]]], + 'duplicate message numbers' => [['1:3,3:4', '5:9']], + 'wildcard message numbers' => [['1:*', '5:9']], + 'wildcard uids' => [['1:3', '5:*']], +]); + +test('quick resync accepts sequence match data without known uids', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->select('INBOX', new QuickResync(777, 42, [], [[1, 2], [5, 6]])); + + $stream->assertWritten('TAG1 SELECT "INBOX" (QRESYNC (777 42 (1:2 5:6)))'); +}); + +test('search accepts an explicit charset separately from criteria', function (ImapIdentifier $identifier, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* SEARCH 7', + 'TAG1 OK SEARCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->search(['SUBJECT', '"été"'], 'UTF-8', $identifier); + + $stream->assertWritten('TAG1 '.$command.' CHARSET UTF-8 SUBJECT "été"'); +})->with([ + [ImapIdentifier::Uid, 'UID SEARCH'], + [ImapIdentifier::MessageNumber, 'SEARCH'], +]); + +test('search rejects control characters in the charset before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->search(['ALL'], "UTF-8\r\nTAG2 LOGOUT")) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('sort accepts an explicit charset separately from criteria', function (ImapIdentifier $identifier, string $command) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* SORT 7', + 'TAG1 OK SORT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->sort(new ImapSort(new SortCriterion(ImapSortKey::Arrival)), ['ALL'], 'US-ASCII', $identifier); + + $stream->assertWritten('TAG1 '.$command.' (ARRIVAL) US-ASCII ALL'); +})->with([ + [ImapIdentifier::Uid, 'UID SORT'], + [ImapIdentifier::MessageNumber, 'SORT'], +]); + +test('sort quotes charsets that are not atoms and rejects control characters', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* SORT 7', + 'TAG1 OK SORT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $sort = new ImapSort(new SortCriterion(ImapSortKey::Arrival)); + + $connection->sort($sort, ['ALL'], 'UTF 8'); + + $stream->assertWritten('TAG1 UID SORT (ARRIVAL) "UTF 8" ALL'); + + expect(fn () => $connection->sort($sort, ['ALL'], "UTF-8\r\nTAG2 LOGOUT")) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG2 UID SORT'); +}); + +test('list supports selection options multiple patterns and status return data', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* LIST (\\Subscribed) "/" "INBOX"', + '* STATUS "INBOX" (MESSAGES 5 UNSEEN 2)', + 'TAG1 OK LIST completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $responses = $connection->list( + pattern: ['INBOX', 'Shared/*'], + selection: ['SUBSCRIBED', 'RECURSIVEMATCH'], + return: ['CHILDREN', 'STATUS', ['MESSAGES', 'UNSEEN']], + ); + + $stream->assertWritten('TAG1 LIST (SUBSCRIBED RECURSIVEMATCH) "" ("INBOX" "Shared/*") RETURN (CHILDREN STATUS (MESSAGES UNSEEN))'); + expect($responses)->toHaveCount(2); + expect($responses->last()->type()->is('STATUS'))->toBeTrue(); +}); + +test('list rejects injected selection and return options before writing', function (array $selection, array $return) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->list(selection: $selection, return: $return)) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with([ + 'selection' => [["SUBSCRIBED\r\nTAG2 LOGOUT"], []], + 'return' => [[], ["CHILDREN\r\nTAG2 LOGOUT"]], + 'nested return' => [[], ['STATUS', ["MESSAGES\r\nTAG2 LOGOUT"]]], +]); + +test('folder listing ignores additional untagged responses', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* LIST () "/" "INBOX"', + '* STATUS "INBOX" (MESSAGES 5)', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders)->toHaveCount(1); + expect($folders->first()->path())->toBe('INBOX'); +}); + +test('status accepts explicitly requested items including rev1 recent', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* STATUS "INBOX" (RECENT 2)', + 'TAG1 OK STATUS completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->status(items: ['RECENT']); + + $stream->assertWritten('TAG1 STATUS "INBOX" (RECENT)'); +}); + +test('status rejects injected items before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->status(items: ["MESSAGES\r\nTAG2 LOGOUT"])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('status rejects an empty item list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->status(items: [])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('list rejects an empty mailbox pattern list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->list(pattern: [])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('search rejects an empty criteria list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->search([])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('sort rejects an empty criteria list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $sort = new ImapSort(new SortCriterion(ImapSortKey::Date)); + + expect(fn () => $connection->sort($sort, [])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('fetch rejects an empty item list before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->fetch(1, [])) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('fetch rejects vanished with message number identifiers before writing', function () { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->fetch( + 1, + 'FLAGS', + ImapIdentifier::MessageNumber, + new ChangedSince(42, vanished: true), + ))->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +}); + +test('store rejects invalid modes and flags before writing', function (?string $mode, string $flag) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->store(1, $flag, mode: $mode)) + ->toThrow(InvalidArgumentException::class); + + $stream->assertNotWritten('TAG1'); +})->with([ + 'mode' => ["+\r\nTAG2 LOGOUT", '\\Seen'], + 'flag' => ['+', "\\Seen\r\nTAG2 LOGOUT"], +]); + +test('quick resync includes paired sequence matches', function (array|int|string $knownUids, string $expected) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->select('INBOX', new QuickResync(777, 42, $knownUids, [[1, 2, 3], [5, 6, 9]])); + + $stream->assertWritten('TAG1 SELECT "INBOX" (QRESYNC (777 42'.$expected.' (1:3 5:6,9)))'); +})->with([ + [[5, 6, 9], ' 5:6,9'], + ['5:6,9', ' 5:6,9'], +]); + +test('uid fetch requires both uid and requested attributes while preserving raw updates', function (string $request, string $attribute) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* 1 FETCH (UID 7 MODSEQ (43))', + '* 1 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (44))', + '* 1 FETCH ('.$attribute.' "missing uid")', + '* 1 FETCH (UID 7 '.$attribute.' "content" MODSEQ (45))', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $result = $connection->fetch(7, $request); + + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->get($attribute))->toBe('content'); + expect($result->responses()->untagged())->toHaveCount(4); +})->with([ + ['BODY.PEEK[1.2]', 'BODY[1.2]'], + ['BODY.PEEK[TEXT]<0.7>', 'BODY[TEXT]<0>'], + ['BINARY.PEEK[1]<10.7>', 'BINARY[1]<10>'], +]); diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index d99f718..9b13699 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,13 +1,78 @@ feed(['* OK Welcome', '* 4 EXISTS']); + $logger = new class implements LoggerInterface + { + public array $sent = []; + + public array $received = []; + + public function sent(string $message): void + { + $this->sent[] = $message; + } + + public function received(string $message): void + { + $this->received[] = $message; + } + }; + $connection = new ImapConnection($stream, $logger); + $connection->connect('localhost'); + + $connection->write('DONE'); + $connection->write('secret', sensitive: true); + $response = $connection->read(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + expect((string) $response)->toBe('* 4 EXISTS'); + expect($logger->sent)->toBe(['DONE', '[redacted]']); + expect($logger->received)->toBe(['* OK Welcome', '* 4 EXISTS']); + $stream->assertWritten("DONE\r\n"); + $stream->assertWritten("secret\r\n"); + $connection->disconnect(); +}); + +test('connection exposes its underlying stream for transport settings', function (bool $result) { + $stream = new class($result) extends FakeStream + { + public ?int $timeout = null; + + public function __construct(protected bool $result) {} + + public function setTimeout(int $seconds): bool + { + $this->timeout = $seconds; + + return $this->result; + } + }; + $connection = new ImapConnection($stream); + + expect($connection->stream())->toBe($stream); + expect($connection->stream()->setTimeout(120))->toBe($result); + expect($stream->timeout)->toBe(120); +})->with([ + 'accepted' => true, + 'rejected' => false, +]); test('connect success', function () { $stream = new FakeStream; @@ -72,6 +137,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '* BYE Logging out', 'TAG1 OK Logged out', ]); @@ -81,6 +147,7 @@ $connection->logout(); $stream->assertWritten('TAG1 LOGOUT'); + expect($connection->connected())->toBeFalse(); }); test('logout failure', function () { @@ -95,9 +162,10 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->logout(); + expect(fn () => $connection->logout())->toThrow(ImapCommandException::class); $stream->assertWritten('TAG1 LOGOUT'); + expect($connection->connected())->toBeFalse(); }); test('authenticate success', function () { @@ -106,17 +174,17 @@ $stream->feed([ '* OK Welcome to IMAP', + '+', 'TAG1 OK Authenticated', ]); $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate('foo', 'bar'); + (new Authentication($connection, new XOAuth2('foo', 'bar')))->authenticate(); - $credentials = Str::credentials('foo', 'bar'); - - $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2 $credentials"); + $stream->assertWritten('TAG1 AUTHENTICATE XOAUTH2'); + $stream->assertWritten('dXNlcj1mb28BYXV0aD1CZWFyZXIgYmFyAQE='); }); test('authenticate failure', function () { @@ -131,8 +199,8 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate('foo', 'bar'); -})->throws(ImapCommandException::class, 'IMAP command "TAG1 AUTHENTICATE [redacted] [redacted]" failed. Response: "TAG1 BAD Authentication failed"'); + (new Authentication($connection, new XOAuth2('foo', 'bar')))->authenticate(); +})->throws(ImapCommandException::class, 'IMAP command "TAG1 AUTHENTICATE [redacted]" failed. Response: "TAG1 BAD Authentication failed"'); test('start tls success', function () { $stream = new FakeStream; @@ -172,13 +240,19 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ idling', + '* 1 EXISTS', 'TAG1 OK Completed', ]); $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->done(); + $session = $connection->idle(); + $idle = $session->responses(30); + $idle->current(); + + expect($session->finish())->toBeEmpty(); $stream->assertWritten('DONE'); }); @@ -212,11 +286,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->select('INBOX'); + $result = $connection->select('INBOX'); $stream->assertWritten('TAG1 SELECT "INBOX"'); - expect($responses->count())->toBe(1); + expect($result->responses()->untagged())->toHaveCount(1); }); test('examine folder', function () { @@ -232,11 +306,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->examine('INBOX'); + $result = $connection->examine('INBOX'); $stream->assertWritten('TAG1 EXAMINE "INBOX"'); - expect($responses->count())->toBe(1); + expect($result->responses()->untagged())->toHaveCount(1); }); test('status folder', function () { @@ -254,7 +328,7 @@ $response = $connection->status('INBOX'); - $stream->assertWritten('TAG1 STATUS "INBOX" (MESSAGES UNSEEN RECENT UIDNEXT UIDVALIDITY)'); + $stream->assertWritten('TAG1 STATUS "INBOX" (MESSAGES UNSEEN UIDNEXT UIDVALIDITY)'); expect($response->type()->is('STATUS'))->toBeTrue(); }); @@ -374,21 +448,74 @@ expect($responses->count())->toBeGreaterThan(0); }); +test('list folders with return options', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + '* LIST (\\Sent) "/" "Sent"', + 'TAG1 OK LIST completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->list('', '*', return: ['SPECIAL-USE']); + + $stream->assertWritten('TAG1 LIST "" "*" RETURN (SPECIAL-USE)'); + + expect($responses)->toHaveCount(1); +}); + test('append message', function () { $stream = new FakeStream; $stream->open(); $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', + 'TAG1 OK [APPENDUID 1234567890 42] APPEND completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->append('INBOX', 'Test message', ['\\Seen']); + + $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) {12}'); + $stream->assertWritten('Test message'); + + expect($result)->toBeInstanceOf(AppendResult::class); + expect($result->uidValidity())->toBe(1234567890); + expect($result->uid())->toBe(42); +}); + +test('append message with internal date', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + '+ Ready', 'TAG1 OK APPEND completed', ]); $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->append('INBOX', 'Test message', ['\\Seen']); + $result = $connection->append( + 'INBOX', + 'Test message', + ['\\Seen'], + new DateTimeImmutable('2026-09-01 12:34:56 -04:00'), + ); + + $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "01-Sep-2026 12:34:56 -0400" {12}'); + $stream->assertWritten('Test message'); - $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "Test message"'); + expect($result->uidValidity())->toBeNull(); + expect($result->uid())->toBeNull(); }); test('append sends literal data after receiving a continuation response', function () { @@ -463,7 +590,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->copy('Archive', 1, 3); + $connection->copy('1:3', 'Archive'); $stream->assertWritten('TAG1 UID COPY 1:3 "Archive"'); }); @@ -479,7 +606,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->move('Archive', [1, 2, 3]); + $connection->move([1, 2, 3], 'Archive'); $stream->assertWritten('TAG1 UID MOVE 1:3 "Archive"'); }); @@ -496,11 +623,14 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $response = $connection->store(['\\Seen'], 1, 3, '+FLAGS'); + $response = $connection->store('1:3', ['\\Seen']); $stream->assertWritten('TAG1 UID STORE 1:3 +FLAGS.SILENT (\\Seen)'); - expect($response)->toBeInstanceOf(ResponseCollection::class); + expect($response)->toBeInstanceOf(StoreResult::class); + expect($response->response()->successful())->toBeTrue(); + expect($response->messages())->toBeEmpty(); + expect($response->modified()->all())->toBe([]); }); test('uid fetch with uid', function () { @@ -516,11 +646,12 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->uid(1, ImapFetchIdentifier::Uid); + $responses = $connection->fetch(123, 'UID'); - $stream->assertWritten('TAG1 UID FETCH 1 (UID)'); + $stream->assertWritten('TAG1 UID FETCH 123 (UID)'); - expect((string) $responses->first())->toBe('* 1 FETCH (UID 123)'); + expect($responses)->toBeInstanceOf(FetchResult::class); + expect($responses->messages()[0]->uid())->toBe(123); }); test('uid fetch with message number', function () { @@ -536,11 +667,12 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->uid(1, ImapFetchIdentifier::MessageNumber); + $responses = $connection->fetch(1, 'UID', identifier: ImapIdentifier::MessageNumber); $stream->assertWritten('TAG1 FETCH 1 (UID)'); - expect((string) $responses->first())->toBe('* 1 FETCH (UID 123)'); + expect($responses)->toBeInstanceOf(FetchResult::class); + expect($responses->messages()[0]->uid())->toBe(123); }); test('text fetch with peek', function () { @@ -558,11 +690,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->bodyText(1); + $responses = $connection->fetch(1, 'BODY.PEEK[TEXT]'); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[TEXT])'); - expect((string) $responses->first())->toBe("* 1 FETCH (UID 1 BODY [TEXT] {14}\r\nHello World!\r\n)"); + expect($responses->messages()[0]->body())->toBe("Hello World!\r\n"); }); test('header fetch with peek', function () { @@ -579,11 +711,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->bodyHeader(1); + $responses = $connection->fetch(1, 'BODY.PEEK[HEADER]'); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[HEADER])'); - expect((string) $responses->first())->toBe("* 1 FETCH (UID 1 BODY [HEADER] {14}\r\nHello World!\r\n)"); + expect($responses->messages()[0]->head())->toBe("Hello World!\r\n"); }); test('flags fetch', function () { @@ -599,11 +731,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->flags(1); + $responses = $connection->fetch(1, 'FLAGS'); $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); - expect((string) $responses->first())->toBe('* 1 FETCH (UID 1 FLAGS (\\Seen))'); + expect($responses->messages()[0]->flags())->toBe(['\\Seen']); }); test('sizes fetch', function () { @@ -619,11 +751,11 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->size(1); + $responses = $connection->fetch(1, 'RFC822.SIZE'); $stream->assertWritten('TAG1 UID FETCH 1 (RFC822.SIZE)'); - expect((string) $responses->first())->toBe('* 1 FETCH (UID 1 RFC822.SIZE 1024)'); + expect($responses->messages()[0]->size())->toBe(1024); }); test('search', function () { @@ -704,7 +836,7 @@ 'support_id' => 'true', ]); - $stream->assertWritten('TAG1 ID ("Acme IMAP Server" "2.0" "true")'); + $stream->assertWritten('TAG1 ID ("name" "Acme IMAP Server" "version" "2.0" "support_id" "true")'); expect($response->type()->is('ID'))->toBeTrue(); }); @@ -715,6 +847,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', '* ID NIL', 'TAG1 OK ID completed', ]); @@ -728,7 +861,8 @@ 'vendor' => 'Test\\Vendor', ]); - $stream->assertWritten('TAG1 ID ("Evil\\"Client" "1.0LOGOUT" "Test\\\\Vendor")'); + $stream->assertWritten('TAG1 ID ("name" "Evil\\"Client" "version" {11}'); + $stream->assertWritten("1.0\r\nLOGOUT".' "vendor" "Test\\\\Vendor")'); }); test('expunge', function () { @@ -751,6 +885,26 @@ expect($responses->count())->toBeGreaterThan(0); }); +test('expunge messages by uid', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 EXPUNGE', + 'TAG1 OK UID EXPUNGE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->expunge([1, 2, 3]); + + $stream->assertWritten('TAG1 UID EXPUNGE 1:3'); + + expect($responses->count())->toBeGreaterThan(0); +}); + test('noop', function () { $stream = new FakeStream; $stream->open(); @@ -783,9 +937,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(function () use ($connection) { - iterator_to_array($connection->idle(30)); - })->toThrow(ImapConnectionException::class); + expect(iterator_to_array($connection->idle()->responses(30)))->toBeEmpty(); $stream->assertWritten('TAG1 IDLE'); }); @@ -803,9 +955,142 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('FLAGS', 1); + $responses = $connection->fetch(123, 'FLAGS'); - $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); + $stream->assertWritten('TAG1 UID FETCH 123 (FLAGS)'); + + expect($responses)->toBeInstanceOf(FetchResult::class); + expect($responses->messages()[0]->uid())->toBe(123); + expect($responses->messages()[0]->flags())->toBe(['\\Seen']); + expect($responses->vanished())->toBeEmpty(); + expect($responses->vanishedUids()->all())->toBe([]); + expect($responses->responses())->toHaveCount(2); +}); + +test('fetch supports changed since with uid ranges', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 2 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (43))', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch('1:*', 'FLAGS', modifiers: new ChangedSince(42)); + + $stream->assertWritten('TAG1 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 42)'); + expect($result)->toBeInstanceOf(FetchResult::class); + expect($result->messages()[0]->modSequence())->toBe(43); + expect($result->vanishedUids()->all())->toBe([]); +}); + +test('fetch supports changed since with message numbers and a zero checkpoint', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 2 FETCH (FLAGS (\\Seen) MODSEQ (43))', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch([1, 2], 'FLAGS', identifier: ImapIdentifier::MessageNumber, modifiers: new ChangedSince(0)); + + $stream->assertWritten('TAG1 FETCH 1:2 (FLAGS) (CHANGEDSINCE 0)'); + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->messages()[0]->modSequence())->toBe(43); +}); + +test('fetch preserves raw responses while filtering unsolicited message data', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 4 EXISTS', + '* 2 FETCH (FLAGS (\\Seen))', + '* 3 FETCH (UID 7 FLAGS () MODSEQ (43))', + '* VANISHED (EARLIER) 1:2', + '* VANISHED 2,4,99', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch([1, 2, 4, 7], 'FLAGS', modifiers: new ChangedSince(42, vanished: true)); + + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->uid())->toBe(7); + expect($result->vanished())->toHaveCount(2); + expect($result->vanished()[0]->earlier())->toBeTrue(); + expect($result->vanished()[1]->earlier())->toBeFalse(); + expect($result->vanishedUids()->all())->toBe([1, 2, 4]); + expect($result->responses()->contains(fn ($response) => (string) $response === '* VANISHED 2,4,99'))->toBeTrue(); + expect($result->responses())->toHaveCount(6); + expect((string) $result->responses()->untagged()->first())->toBe('* 4 EXISTS'); +}); + +test('fetch can return vanished uids without fetched messages', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* VANISHED (EARLIER) 1:2', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch([1, 2], 'FLAGS', modifiers: new ChangedSince(42, vanished: true)); + + expect($result->messages())->toBeEmpty(); + expect($result->vanishedUids()->all())->toBe([1, 2]); +}); + +test('fetch combines custom modifiers into one modifier list', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK FETCH completed', + ]); - expect((string) $responses->first())->toBe("* 1 FETCH (UID 123 FLAGS (\Seen))"); + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $custom = new class implements ModifierInterface + { + public function toImap(ImapIdentifier $identifier): string + { + return 'X-CUSTOM'; + } + }; + + $result = $connection->fetch([1, 2], 'FLAGS', ImapIdentifier::Uid, new ChangedSince(42), $custom); + + $stream->assertWritten('TAG1 UID FETCH 1:2 (FLAGS) (CHANGEDSINCE 42 X-CUSTOM)'); + expect($result->messages())->toBeEmpty(); + expect($result->vanishedUids()->all())->toBe([]); +}); + +test('fetch throws when the server rejects a modifier', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 BAD Unsupported FETCH modifier', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->fetch(1, 'FLAGS', modifiers: new ChangedSince(42))) + ->toThrow(ImapCommandException::class); }); diff --git a/tests/Unit/Connection/ImapQueryBuilderTest.php b/tests/Unit/Connection/ImapQueryBuilderTest.php index 9c10c80..61b39ec 100644 --- a/tests/Unit/Connection/ImapQueryBuilderTest.php +++ b/tests/Unit/Connection/ImapQueryBuilderTest.php @@ -1,6 +1,7 @@ where('subject', ''); // empty string - expect($builder->toImap())->toBe('SUBJECT'); + expect($builder->toImap())->toBe('SUBJECT ""'); }); test('compiles a null value', function () { @@ -170,6 +171,39 @@ function (ImapQueryBuilder $q) { ); }); +test('preserves search strings without mailbox encoding', function (string $value) { + $builder = new ImapQueryBuilder; + + $builder->subject($value); + + expect($builder->toImap())->toBe('SUBJECT "'.$value.'"'); +})->with(['R&D', '0', '']); + +test('compiles international search values as byte-counted literals', function (string $value) { + $builder = new ImapQueryBuilder; + + $builder->subject($value); + + expect($builder->toTokens())->toBe(['SUBJECT', ['{'.strlen($value).'}', $value]]); +})->with(['café', '日本語', 'Привет', 'مرحبا', 'Hello 🌍', 'café "quoted" \\ path']); + +test('preserves literals inside nested boolean expressions', function () { + $builder = new ImapQueryBuilder; + + $builder->subject('café')->orWhere(function (ImapQueryBuilder $query) { + $query->body('日本語')->whereNot('TEXT', 'مرحبا'); + }); + + $lines = (new ImapCommand('TAG1', 'UID SEARCH', $builder->toTokens()))->compile(); + + expect(array_map(fn ($line) => $line->value, $lines))->toBe([ + 'TAG1 UID SEARCH OR (SUBJECT {5}', + 'café) (BODY {9}', + '日本語 NOT TEXT {10}', + 'مرحبا)', + ]); +}); + test('escapes double quotes in search value', function () { $builder = new ImapQueryBuilder; @@ -232,14 +266,20 @@ function (ImapQueryBuilder $q) { 'wrapped message id' => [''], ]); -test('converts values from utf-8 to utf-7', function () { +test('preserves UTF-8 values in the rendered query', function () { $builder = new ImapQueryBuilder; $builder->where('foo', 'Joué'); - expect($builder->toImap())->toBe('FOO "Jou&AOk-"'); + expect($builder->toImap())->toBe("FOO {5}\r\nJoué"); }); +test('rejects control characters in international search values', function () { + $builder = new ImapQueryBuilder; + + $builder->subject("café\0")->toTokens(); +})->throws(InvalidArgumentException::class); + test('compiles UID condition without quotes', function () { $builder = new ImapQueryBuilder; diff --git a/tests/Unit/Connection/ResultTest.php b/tests/Unit/Connection/ResultTest.php new file mode 100644 index 0000000..cb69803 --- /dev/null +++ b/tests/Unit/Connection/ResultTest.php @@ -0,0 +1,24 @@ +responses(); + + $responses->pop(); + $result->addResponse($second); + + expect($snapshot->all())->toBe([$first]); + + $snapshot->pop(); + + expect($result->responses()->all())->toBe([$first, $second]); +}); diff --git a/tests/Unit/Fetch/ChangedSinceTest.php b/tests/Unit/Fetch/ChangedSinceTest.php new file mode 100644 index 0000000..e520708 --- /dev/null +++ b/tests/Unit/Fetch/ChangedSinceTest.php @@ -0,0 +1,34 @@ +toImap($identifier))->toBe('CHANGEDSINCE 42'); +})->with(ImapIdentifier::cases()); + +test('it includes vanished when using uid fetch', function () { + $modifier = new ChangedSince(42, vanished: true); + + expect($modifier->toImap(ImapIdentifier::Uid))->toBe('CHANGEDSINCE 42 VANISHED'); +}); + +test('it rejects vanished when using message number fetch', function () { + $modifier = new ChangedSince(42, vanished: true); + + expect(fn () => $modifier->toImap(ImapIdentifier::MessageNumber)) + ->toThrow(InvalidArgumentException::class); +}); + +test('it accepts a zero modification sequence', function () { + $modifier = new ChangedSince(0); + + expect($modifier->toImap(ImapIdentifier::Uid))->toBe('CHANGEDSINCE 0'); +}); + +test('it rejects negative modification sequences', function () { + expect(fn () => new ChangedSince(-1)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/FetchResultTest.php b/tests/Unit/FetchResultTest.php new file mode 100644 index 0000000..4a780f0 --- /dev/null +++ b/tests/Unit/FetchResultTest.php @@ -0,0 +1,20 @@ +vanishedUids(); + + expect($uids)->toBeInstanceOf(LazyCollection::class); + expect($uids->take(5)->all())->toBe([3, 1, 2, 4, 5]); + expect($uids->take(5)->all())->toBe([3, 1, 2, 4, 5]); +}); diff --git a/tests/Unit/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php new file mode 100644 index 0000000..d98106a --- /dev/null +++ b/tests/Unit/FetchedMessageDataTest.php @@ -0,0 +1,282 @@ +open(); + $stream->feed([ + '* 5 FETCH (UID 42 FLAGS (\\Seen) RFC822.SIZE 1024 BODY[HEADER] "Subject: Test" BODY[TEXT] "Hello world")', + ]); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + + $data = FetchedMessageData::fromResponse($response); + $message = $data->toMessage(new Folder(new Mailbox, 'INBOX')); + + expect($data->sequenceNumber())->toBe(5); + expect($data->uid())->toBe(42); + expect($message->uid())->toBe(42); + expect($message->flags())->toBe(['\\Seen']); + expect($message->size())->toBe(1024); + expect($message->head())->toBe('Subject: Test'); + expect($message->body())->toBe('Hello world'); +}); + +test('it preserves standard and extension attributes without dedicated properties', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed('* 1 FETCH (UID 7 INTERNALDATE "02-Sep-2026 12:00:00 +0000" ENVELOPE (NIL "Subject" ((NIL NIL "steve" "example.com"))) EMAILID (M123) THREADID NIL X-CUSTOM ("Mixed Case" (NIL "NIL" 5)) RFC822.SIZE 0 MODSEQ (9223372036854775807))'); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + $data = FetchedMessageData::fromResponse($response); + + expect($data->get('internaldate'))->toBe('02-Sep-2026 12:00:00 +0000'); + expect($data->get('ENVELOPE'))->toBe([null, 'Subject', [[null, null, 'steve', 'example.com']]]); + expect($data->get('EMAILID'))->toBe(['M123']); + expect($data->get('X-CUSTOM'))->toBe(['Mixed Case', [null, 'NIL', '5']]); + expect($data->has('THREADID'))->toBeTrue(); + expect($data->get('THREADID', 'missing'))->toBeNull(); + expect($data->get('UNKNOWN', 'missing'))->toBe('missing'); + expect($data->size())->toBe(0); + expect($data->modSequence())->toBe(9223372036854775807); +}); + +test('it preserves multiple body sections and partial offsets', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed('* 1 FETCH (UID 7 BODY[1.2] "complete" BODY[1.2]<0> "first" BODY[1.2]<5> "second" BODY[HEADER.FIELDS (SUBJECT FROM)] "headers" BODY[] "raw message" BODY ("TEXT" "PLAIN") BINARY.SIZE[1.2] 100)'); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + $data = FetchedMessageData::fromResponse($response); + + expect($data->get('body[1.2]'))->toBe('complete'); + expect($data->get('BODY[1.2]<0>'))->toBe('first'); + expect($data->get('BODY[1.2]<5>'))->toBe('second'); + expect($data->get('BODY[HEADER.FIELDS (SUBJECT FROM)]'))->toBe('headers'); + expect($data->get('BODY[]'))->toBe('raw message'); + expect($data->get('BODY'))->toBe(['TEXT', 'PLAIN']); + expect($data->get('BINARY.SIZE[1.2]'))->toBe('100'); +}); + +test('it preserves binary literal content', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* 1 FETCH (UID 7 BINARY[1] ~{3}', + "a\0b)", + ]); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + $data = FetchedMessageData::fromResponse($response); + + expect($data->get('BINARY[1]'))->toBe("a\0b"); +}); + +test('it distinguishes omitted attributes from empty or nil attributes', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* 1 FETCH (UID 7 MODSEQ (42))', + '* 1 FETCH (UID 7 FLAGS () BODY[TEXT] "" THREADID NIL)', + ]); + + $parser = new ImapParser(new ImapTokenizer($stream)); + $omitted = FetchedMessageData::fromResponse($parser->next()); + $empty = FetchedMessageData::fromResponse($parser->next()); + + expect($omitted->has('FLAGS'))->toBeFalse(); + expect($omitted->flags())->toBe([]); + expect($omitted->has('BODY[TEXT]'))->toBeFalse(); + expect($empty->has('FLAGS'))->toBeTrue(); + expect($empty->flags())->toBe([]); + expect($empty->has('BODY[TEXT]'))->toBeTrue(); + expect($empty->body())->toBe(''); + expect($empty->has('THREADID'))->toBeTrue(); + expect($empty->get('THREADID'))->toBeNull(); +}); + +test('merging partial updates preserves omitted attributes without mutating the original', function () { + $original = new FetchedMessageData( + [ + 'UID' => 7, + 'FLAGS' => ['\\Seen'], + 'BODY[TEXT]' => 'Existing content', + 'THREADID' => ['T123'], + ], + sequenceNumber: 5, + ); + $changes = new FetchedMessageData(['FLAGS' => [], 'MODSEQ' => [43], 'THREADID' => null]); + + $merged = $original->merge($changes)->merge(['emailid' => ['M123']]); + + expect($merged->uid())->toBe(7); + expect($merged->sequenceNumber())->toBe(5); + expect($merged->flags())->toBe([]); + expect($merged->body())->toBe('Existing content'); + expect($merged->modSequence())->toBe(43); + expect($merged->has('THREADID'))->toBeTrue(); + expect($merged->get('THREADID'))->toBeNull(); + expect($merged->get('EMAILID'))->toBe(['M123']); + expect($original->flags())->toBe(['\\Seen']); + expect($original->has('MODSEQ'))->toBeFalse(); +}); + +test('message conversion and serialization retain all fetched attributes', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed('* 1 FETCH (UID 7 FLAGS () BODY[1.2] "content" EMAILID (M123) THREADID NIL BODYSTRUCTURE ("text" "plain" NIL NIL NIL "7bit" 7 1))'); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + $data = FetchedMessageData::fromResponse($response); + $message = $data->toMessage(new Folder(new Mailbox, 'INBOX')); + $restored = unserialize(serialize($message)); + + expect($message->data())->toBe($data); + expect($restored->data()->get('BODY[1.2]'))->toBe('content'); + expect($restored->data()->get('EMAILID'))->toBe(['M123']); + expect($restored->data()->has('THREADID'))->toBeTrue(); + expect($restored->hasBodyStructure())->toBeTrue(); + expect($restored->bodyStructure())->not->toBeNull(); + expect($restored->toArray())->toBe($data->toArray()); + expect(json_decode(json_encode($restored), true))->toBe($data->toArray()); +}); + +test('queried body sections reach messages and are reused only for peeking', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + '* SEARCH 7', + 'TAG3 OK SEARCH completed', + '* 1 FETCH (UID 7 BODY[1.2] "content")', + 'TAG4 OK FETCH completed', + '* 1 FETCH (UID 7 BODY[1.2] "content" MODSEQ (43))', + 'TAG5 OK FETCH completed', + ]); + + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + $message = $folder->messages()->only(MessageData::section('1.2')->peek())->get()->first(); + + expect($message->data()->get('BODY[1.2]'))->toBe('content'); + expect($message->bodyPart('1.2'))->toBe('content'); + $stream->assertWritten('TAG4 UID FETCH 7 (BODY.PEEK[1.2])'); + $stream->assertNotWritten('TAG5'); + + expect($message->bodyPart('1.2', peek: false))->toBe('content'); + expect($message->modSequence())->toBe(43); + $stream->assertWritten('TAG5 UID FETCH 7 (BODY[1.2])'); +}); + +test('partial body sections are not treated as complete cached parts', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 7 BODY[1.2] "complete content" EMAILID (M123))', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $message = new Message(new Folder($mailbox, 'INBOX'), new FetchedMessageData([ + 'UID' => 7, + 'BODY[1.2]<0>' => 'partial', + 'FLAGS' => ['\\Seen'], + ])); + + expect($message->bodyPart('1.2'))->toBe('complete content'); + expect($message->data()->get('BODY[1.2]<0>'))->toBe('partial'); + expect($message->data()->get('BODY[1.2]'))->toBe('complete content'); + expect($message->data()->get('EMAILID'))->toBe(['M123']); + expect($message->flags())->toBe(['\\Seen']); + $stream->assertWritten('TAG2 UID FETCH 7 (BODY.PEEK[1.2])'); +}); + +test('body structure is fetched lazily and cached on the message', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 7 BODYSTRUCTURE ("text" "plain" NIL NIL NIL "7bit" 7 1))', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $message = new Message(new Folder($mailbox, 'INBOX'), new FetchedMessageData([ + 'UID' => 7, + 'FLAGS' => ['\\Seen'], + ])); + + expect($message->bodyStructure())->toBeNull(); + $stream->assertNotWritten('TAG2'); + + $structure = $message->bodyStructure(fetch: true); + + expect($structure)->not->toBeNull(); + expect($message->hasBodyStructure())->toBeTrue(); + expect($message->flags())->toBe(['\\Seen']); + expect($message->bodyStructure(fetch: true))->toBe($structure); + $stream->assertWritten('TAG2 UID FETCH 7 (BODYSTRUCTURE)'); + $stream->assertNotWritten('TAG3'); +}); + +test('empty fetched headers do not trigger repeated fetches', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 7 BODY[HEADER] "")', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $message = new Message(new Folder($mailbox, 'INBOX'), new FetchedMessageData(['UID' => 7])); + + expect($message->data()->has('BODY[HEADER]'))->toBeFalse(); + expect($message->head(fetch: true))->toBe(''); + expect($message->data()->has('BODY[HEADER]'))->toBeTrue(); + expect($message->head(fetch: true))->toBe(''); + $stream->assertWritten('TAG2 UID FETCH 7 (BODY.PEEK[HEADER])'); + $stream->assertNotWritten('TAG3'); +}); + +test('lazy body loading ignores unsolicited uid updates before the requested response', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (43))', + '* 1 FETCH (UID 7 BODY[1.2] "content")', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $message = new Message(new Folder($mailbox, 'INBOX'), new FetchedMessageData(['UID' => 7])); + + expect($message->bodyPart('1.2'))->toBe('content'); + expect($message->bodyPart('1.2'))->toBe('content'); + $stream->assertWritten('TAG2 UID FETCH 7 (BODY.PEEK[1.2])'); + $stream->assertNotWritten('TAG3 UID FETCH'); +}); diff --git a/tests/Unit/FetchedResponseTest.php b/tests/Unit/FetchedResponseTest.php new file mode 100644 index 0000000..fae370e --- /dev/null +++ b/tests/Unit/FetchedResponseTest.php @@ -0,0 +1,49 @@ +open(); + $stream->feed([ + '* 4 EXISTS', + '* 2 FETCH (UID 7 FLAGS (\Seen))', + '* 3 FETCH (UID 9 FLAGS (\Flagged))', + 'TAG1 OK Completed', + ]); + + $parser = new ImapParser(new ImapTokenizer($stream)); + $responses = new ResponseCollection([ + $parser->next(), + $parser->next(), + $parser->next(), + $parser->next(), + ]); + + $fetches = $responses->fetches(); + + expect($fetches)->toBeInstanceOf(ResponseCollection::class); + expect($fetches)->toHaveCount(2); + expect($fetches[1])->toBeInstanceOf(UntaggedResponse::class); + expect($fetches[1])->toBe($responses[1]); + expect($fetches[2])->toBe($responses[2]); + + $parsed = FetchedResponse::collect($responses); + $first = $parsed[0]; + $second = $parsed[1]; + + expect($parsed)->toBeInstanceOf(FetchedResponseCollection::class); + expect($parsed)->toHaveCount(2); + expect($first->sequenceNumber())->toBe(2); + expect($first->data()->uid())->toBe(7); + expect($first->response())->toBe($responses[1]); + expect($second->sequenceNumber())->toBe(3); + expect($second->data()->flags())->toBe(['\Flagged']); + expect($responses)->toHaveCount(4); +}); diff --git a/tests/Unit/FolderRepositoryTest.php b/tests/Unit/FolderRepositoryTest.php new file mode 100644 index 0000000..d51206c --- /dev/null +++ b/tests/Unit/FolderRepositoryTest.php @@ -0,0 +1,157 @@ +feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SPECIAL-USE', + 'TAG2 OK CAPABILITY completed', + '* LIST (\\sent \\HasNoChildren) "/" "Outgoing"', + 'TAG3 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); + + $stream->assertWritten('TAG3 LIST "" "*" RETURN (SPECIAL-USE)'); +}); + +test('it does not infer special uses from folder names', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\HasNoChildren) "/" "Sent Items"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent))->toBeNull(); + + $stream->assertWritten('TAG2 LIST "" "*"'); + $stream->assertNotWritten('TAG2 LIST "" "*" RETURN (SPECIAL-USE)'); +}); + +test('it resolves special-use attributes from an ordinary list response', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\Sent \\HasNoChildren) "/" "Outgoing"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); + + $stream->assertWritten('TAG2 LIST "" "*"'); +}); + +test('it resolves special-use attributes instead of matching folder names', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SPECIAL-USE', + 'TAG2 OK CAPABILITY completed', + '* LIST (\\HasNoChildren) "/" "Sent"', + '* LIST (\\Sent \\HasNoChildren) "/" "Outgoing"', + 'TAG3 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); +}); + +test('it returns null when a special-use folder cannot be resolved', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\HasNoChildren) "/" "INBOX"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Archive))->toBeNull(); +}); + +test('it throws when requested folder data is not supported', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1', + 'TAG2 OK CAPABILITY completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + expect(fn () => $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get() + )->toThrow(ImapCapabilityException::class); +}); diff --git a/tests/Unit/FolderTest.php b/tests/Unit/FolderTest.php index ebdf8f1..cbe624e 100644 --- a/tests/Unit/FolderTest.php +++ b/tests/Unit/FolderTest.php @@ -1,9 +1,178 @@ feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* CAPABILITY IMAP4rev1 IDLE', 'TAG2 OK CAPABILITY completed', + '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 10]', 'TAG3 OK SELECT completed', + '* SEARCH 7 9', 'TAG4 OK SEARCH completed', + '* 2 FETCH (UID 7 FLAGS (\\Seen))', '* 3 FETCH (UID 9 FLAGS ())', 'TAG5 OK FETCH completed', + ]); + $watching = new FakeStream; + $watching->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* 1 EXISTS', '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 7]', 'TAG3 OK SELECT completed', + '+ idling', '* 3 EXISTS', + ]); + $mailbox = new ScriptedMailbox(new ImapConnection($application), new ImapConnection($watching)); + $folder = new Folder($mailbox, 'INBOX'); + $received = []; + $uids = []; + + try { + if ($retrieve) { + $folder->idle(function (MessageInterface $message) use (&$uids) { + $uids[] = $message->uid(); + + return $message->uid() !== 9; + }, function (MessageQuery $query) { + return $query->seen(); + }); + } else { + $folder->events(function (EventInterface $event) use (&$received, $application) { + $received[] = $event::class; + $application->assertNotWritten('SELECT'); + $application->assertNotWritten('SEARCH'); + $application->assertNotWritten('FETCH'); + + return ! $event instanceof MessagesExist; + }); + } + + expect($received)->toBe($retrieve ? [] : [FolderSelected::class, MessagesExist::class]); + expect($uids)->toBe($retrieve ? [7, 9] : []); + expect($application->opened())->toBeTrue(); + expect($watching->opened())->toBeFalse(); + if ($retrieve) { + $application->assertWritten('UID FETCH'); + $application->assertWritten('SEEN'); + } else { + $application->assertNotWritten('UID FETCH'); + } + $watching->assertNotWritten('FETCH'); + $watching->assertNotWritten('LOGOUT'); + } finally { + $mailbox->connection()->disconnect(); + $mailbox->disconnect(); + } +})->with(['retrieve arrivals' => true, 'observe count only' => false]); + +test('folder idle preserves or replaces the arrival cursor after reconnecting', function (int $validity, int $uidNext, int $expectedUid) { + $application = new FakeStream; + $application->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* CAPABILITY IMAP4rev1 IDLE', 'TAG2 OK CAPABILITY completed', + "* OK [UIDVALIDITY {$validity}]", 'TAG3 OK SELECT completed', + "* SEARCH {$expectedUid}", 'TAG4 OK SEARCH completed', + "* 2 FETCH (UID {$expectedUid} FLAGS ())", 'TAG5 OK FETCH completed', + ]); + $watching = new FakeStream; + $watching->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* 1 EXISTS', '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 7]', 'TAG3 OK SELECT completed', + '+ idling', '* BYE Restarting', + ]); + $reconnected = new FakeStream; + $reconnected->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* 2 EXISTS', "* OK [UIDVALIDITY {$validity}]", "* OK [UIDNEXT {$uidNext}]", 'TAG2 OK SELECT completed', + '+ idling', '* 3 EXISTS', + ]); + $mailbox = new ScriptedMailbox(new ImapConnection($application), new ImapConnection($watching), new ImapConnection($reconnected)); + $folder = new Folder($mailbox, 'INBOX'); + $uids = []; + $attempts = 0; + + try { + $folder->idle(function (MessageInterface $message) use (&$uids) { + $uids[] = $message->uid(); + + return false; + }, timeout: function () use (&$attempts) { + return ++$attempts <= 2 ? 300 : false; + }); + + expect($uids)->toBe([$expectedUid]); + $start = $validity === 1 ? 7 : $uidNext; + $application->assertWritten("UID SEARCH UID {$start}:*"); + $watching->assertNotWritten('FETCH'); + $reconnected->assertNotWritten('FETCH'); + } finally { + $mailbox->connection()->disconnect(); + $mailbox->disconnect(); + } +})->with([ + 'same validity preserves the cursor' => [1, 10, 9], + 'changed validity replaces the cursor' => [2, 3, 3], +]); + +test('folder polling propagates callback exceptions', function (string $exceptionClass) { + $application = new FakeStream; + $application->feed(['* OK Welcome', 'TAG1 OK LOGIN completed']); + $polling = new FakeStream; + $polling->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDNEXT 2]', 'TAG3 OK SELECT completed', + '* LIST () "/" "INBOX"', 'TAG4 OK LIST completed', + '* OK [UIDNEXT 3]', 'TAG5 OK SELECT completed', + '* SEARCH 2', 'TAG6 OK SEARCH completed', + '* 2 FETCH (UID 2 FLAGS ())', 'TAG7 OK FETCH completed', + ]); + $mailbox = new ScriptedMailbox(new ImapConnection($application), new ImapConnection($polling)); + $mailbox->connect(); + $folder = new Folder($mailbox, 'INBOX'); + $exception = new $exceptionClass('Application callback failed'); + + try { + expect(fn () => $folder->poll(function () use ($exception) { + throw $exception; + }, frequency: 1))->toThrow($exception); + } finally { + $mailbox->connection()->disconnect(); + $mailbox->disconnect(); + } +})->with([ + RuntimeException::class, + Exception::class, + ImapConnectionClosedException::class, +]); + +test('it examines a folder using the typed selection result', function () { + $mailbox = Mailbox::make(); + $mailbox->connect(ImapConnection::fake([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 3 EXISTS', + '* OK [UIDVALIDITY 777] UIDs valid', + 'TAG2 OK EXAMINE completed', + ])); + + $folder = new Folder($mailbox, 'INBOX'); + + expect($folder->examine())->toBe([ + ['*', '3', 'EXISTS'], + ['*', 'OK', ['UIDVALIDITY', '777'], 'UIDs', 'valid'], + ]); +}); test('it properly decodes name from UTF-7', function () { $mailbox = Mailbox::make(); @@ -12,7 +181,7 @@ $folder = new Folder( mailbox: $mailbox, path: '[Gmail]/&BBoEPgRABDcEOAQ9BDA-', - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); @@ -32,7 +201,7 @@ $folder = new Folder( mailbox: $mailbox, path: '[Gmail]/'.$utf8FolderName, - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); @@ -45,7 +214,7 @@ $mixedFolder = new Folder( mailbox: $mailbox, path: '[Gmail]/'.$mixedUtf8FolderName, - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); @@ -213,3 +382,76 @@ $mailbox->inbox()->quota(); })->throws(ImapCapabilityException::class); + +test('examining a folder invalidates the previous writable selection', function (string $path) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* OK [UIDVALIDITY 100] Valid', + 'TAG2 OK [READ-WRITE] SELECT completed', + '* OK [UIDVALIDITY 200] Valid', + 'TAG3 OK [READ-ONLY] EXAMINE completed', + '* OK [UIDVALIDITY 300] Valid', + 'TAG4 OK [READ-WRITE] SELECT completed', + '* SEARCH 7', + 'TAG5 OK SEARCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $inbox = new Folder($mailbox, 'INBOX'); + $examined = new Folder($mailbox, $path); + + expect($inbox->select()->uidValidity())->toBe(100); + $examined->examine(); + + expect($mailbox->selected($inbox))->toBeFalse(); + expect($mailbox->selected($examined))->toBeFalse(); + expect($inbox->messages()->count())->toBe(1); + expect($inbox->select()->uidValidity())->toBe(300); + + $stream->assertWritten('TAG3 EXAMINE "'.$path.'"'); + $stream->assertWritten('TAG4 SELECT "INBOX"'); + $stream->assertWritten('TAG5 UID SEARCH ALL'); +})->with(['Archive', 'INBOX']); + +test('failed examination still invalidates the previous selection', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + 'TAG3 NO Mailbox unavailable', + '* OK [UIDVALIDITY 300] Valid', + 'TAG4 OK SELECT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $inbox = new Folder($mailbox, 'INBOX'); + $missing = new Folder($mailbox, 'Missing'); + + $inbox->select(); + expect(fn () => $missing->examine())->toThrow(ImapCommandException::class); + expect($mailbox->selected($inbox))->toBeFalse(); + expect($inbox->select()->uidValidity())->toBe(300); + + $stream->assertWritten('TAG4 SELECT "INBOX"'); +}); + +test('expunge selects its own folder before removing messages', function (?int $uid) { + $connection = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + 'TAG2 OK SELECT completed', 'TAG3 OK SELECT completed', + '* 2 EXPUNGE', 'TAG4 OK EXPUNGE completed', + ]); + $mailbox = new Mailbox; + $mailbox->connect($connection); + (new Folder($mailbox, 'Other'))->select(); + + expect((new Folder($mailbox, 'INBOX'))->expunge($uid))->toBe(['2']); + $connection->stream()->assertWritten('TAG3 SELECT "INBOX"'); + $connection->stream()->assertWritten($uid === null ? "TAG4 EXPUNGE\r\n" : "TAG4 UID EXPUNGE 7\r\n"); + $connection->disconnect(); +})->with([null, 7]); diff --git a/tests/Unit/IdleEventTest.php b/tests/Unit/IdleEventTest.php new file mode 100644 index 0000000..e5a945b --- /dev/null +++ b/tests/Unit/IdleEventTest.php @@ -0,0 +1,204 @@ +open(); + $stream->feed([ + '* 10 EXISTS', + '* 3 EXPUNGE', + '* 2 FETCH (UID 42 FLAGS (\\Seen) MODSEQ (99))', + '* VANISHED (EARLIER) 7:9', + '* 4 FETCH (FLAGS (\\Flagged))', + ]); + $parser = new ImapParser(new ImapTokenizer($stream)); + + $exists = new MessagesExist('INBOX', $parser->next()); + expect($exists)->toBeInstanceOf(MessagesExist::class); + expect($exists->type())->toBe('EXISTS'); + expect($exists->count())->toBe(10); + expect($exists->folder())->toBe('INBOX'); + + $expunge = new MessageExpunged('INBOX', $parser->next()); + expect($expunge)->toBeInstanceOf(MessageExpunged::class); + expect($expunge->type())->toBe('EXPUNGE'); + expect($expunge->sequenceNumber())->toBe(3); + + $fetch = new MessageFetched('INBOX', $parser->next()); + expect($fetch)->toBeInstanceOf(MessageFetched::class); + expect($fetch->changes()->messages()[0]->uid())->toBe(42); + expect($fetch->sequenceNumber())->toBe(2); + + $vanished = new MessagesVanished('INBOX', $parser->next()); + expect($vanished)->toBeInstanceOf(MessagesVanished::class); + expect($vanished->type())->toBe('VANISHED'); + expect($vanished->uids()->all())->toBe([7, 8, 9]); + expect($vanished->earlier())->toBeTrue(); + + $flags = new MessageFetched('INBOX', $parser->next()); + expect($flags)->toBeInstanceOf(MessageFetched::class); + expect($flags->changes()->messages()[0]->has('UID'))->toBeFalse(); + expect($flags->sequenceNumber())->toBe(4); + expect($flags->response())->not->toBeNull(); +}); + +test('selection events expose the reconciliation metadata', function () { + $event = new FolderSelected('INBOX', $selection = new Result(uidValidity: 777, uidNext: 43)); + + expect($event->type())->toBe('SELECT'); + expect($event->selection())->toBe($selection); + expect($event->selection()->uidValidity())->toBe(777); + expect($event->folder())->toBe('INBOX'); +}); + +test('watcher converts responses into typed events preserving response identity', function (string $line, string $class, string $type) { + $stream = new FakeStream; + $stream->open(); + $stream->feed($line); + $parser = new ImapParser(new ImapTokenizer($stream)); + $response = $parser->next(); + + $watch = new class(new Mailbox, 'Archive', 30) extends Watch + { + public function toEvent(UntaggedResponse $response): ResponseEvent + { + return parent::toEvent($response); + } + }; + $event = $watch->toEvent($response); + + expect($event)->toBeInstanceOf($class)->toBeInstanceOf(EventInterface::class); + expect($event->folder())->toBe('Archive'); + expect($event->type())->toBe($type); + expect($event->response())->toBe($response); +})->with([ + 'empty mailbox' => ['* 0 EXISTS', MessagesExist::class, 'EXISTS'], + 'fetch update' => ['* 2 FETCH (FLAGS (\\Seen))', MessageFetched::class, 'FETCH'], + 'expunged message' => ['* 3 EXPUNGE', MessageExpunged::class, 'EXPUNGE'], + 'vanished messages' => ['* VANISHED 7:9', MessagesVanished::class, 'VANISHED'], + 'numbered unmodeled response' => ['* 2 RECENT', UnknownEvent::class, 'RECENT'], + 'status response' => ['* OK [ALERT] Maintenance soon', UnknownEvent::class, 'OK'], + 'extension response' => ['* X-NOTICE changed', UnknownEvent::class, 'X-NOTICE'], +]); + +test('vanished events distinguish earlier changes from live changes', function (string $line, bool $earlier) { + $stream = new FakeStream; + $stream->open(); + $stream->feed($line); + $parser = new ImapParser(new ImapTokenizer($stream)); + + $event = new MessagesVanished('INBOX', $parser->next()); + + expect($event)->toBeInstanceOf(MessagesVanished::class); + expect($event->uids()->all())->toBe([7, 8, 9, 12]); + expect($event->earlier())->toBe($earlier); +})->with([ + 'live removal' => ['* VANISHED 7:9,12', false], + 'checkpoint replay' => ['* VANISHED (EARLIER) 7:9,12', true], +]); + +test('fake folders let selection callbacks query configured messages without supplying events', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ + $first = new FakeMessage(7, flags: ['\\Seen']), + $second = new FakeMessage(8), + ])); + $received = []; + $messages = []; + + $folder->events(function (EventInterface $event) use ($folder, &$received, &$messages) { + $received[] = $event; + + if ($event instanceof FolderSelected) { + $messages = $folder->messages()->orderByUid()->get()->all(); + } + }); + + expect($received)->toHaveCount(1); + expect($received[0])->toBeInstanceOf(FolderSelected::class); + expect($received[0]->folder())->toBe('INBOX'); + expect($messages)->toBe([$first, $second]); +}); + +test('fake folder event callbacks can query messages explicitly', function () { + $message = new FakeMessage(7, flags: ['\\Seen']); + $event = new MessagesExist('INBOX', new UntaggedResponse([ + new Atom('*'), new Number('10'), new Atom('EXISTS'), + ])); + $folder = (new FakeFolder('INBOX', messages: new MessageCollection([$message])))->setIdleEvents([$event]); + $received = []; + + $folder->events(function (EventInterface $event) use ($folder, &$received) { + if ($event instanceof MessagesExist) { + $folder->messages()->get()->each(function ($message) use (&$received) { + $received[] = $message; + }); + + return false; + } + }); + + expect($received)->toBe([$message]); + expect($event->folder())->toBe('INBOX'); + expect($event->type())->toBe('EXISTS'); + expect($event->count())->toBe(10); +}); + +test('fake folders deliver supplied events and stop when requested', function () { + $folder = (new FakeFolder('INBOX'))->setIdleEvents([ + $first = new FolderSelected('INBOX', new Result(uidNext: 2)), + new FolderSelected('INBOX', new Result(uidNext: 3)), + ]); + $received = []; + + $folder->events(function (EventInterface $event) use (&$received, $first) { + $received[] = $event; + + return $event !== $first; + }); + + expect($received)->toHaveCount(2); + expect($received[0])->toBeInstanceOf(FolderSelected::class); + expect($received[1])->toBe($first); +}); + +test('fake folder idle delivers configured messages and supports query customization and stopping', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([new FakeMessage(9), $first = new FakeMessage(7)])); + $received = []; + $queried = false; + + $folder->idle(function (MessageInterface $message) use (&$received) { + $received[] = $message; + + return false; + }, function (MessageQueryInterface $query) use (&$queried) { + $queried = true; + + return $query->with(MessageData::flags()); + }); + + expect($queried)->toBeTrue(); + expect($received)->toBe([$first]); +}); diff --git a/tests/Unit/IdleTest.php b/tests/Unit/IdleTest.php new file mode 100644 index 0000000..731955d --- /dev/null +++ b/tests/Unit/IdleTest.php @@ -0,0 +1,153 @@ +connect($connection); + $folder = new ScriptedFolder($mailbox, 'INBOX', [ + new FolderSelected('INBOX', new Result(uidValidity: 1, uidNext: $uidNext)), + ...array_fill(0, $notifications, new MessagesExist('INBOX', new UntaggedResponse([ + new Atom('*'), new Number('3'), new Atom('EXISTS'), + ]))), + ]); + $received = []; + + $folder->idle(function (MessageInterface $message) use (&$received) { + $received[] = $message->uid(); + }); + + expect($received)->toBe($expected); + $connection->stream()->assertNotWritten('BODY['); + $connection->disconnect(); +})->with([ + 'multiple arrivals and duplicate count notification' => [7, 2, [ + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 7 9', 'TAG3 OK SEARCH completed', + '* 3 FETCH (UID 9 FLAGS ())', '* 2 FETCH (UID 7 FLAGS ())', 'TAG4 OK FETCH completed', + '* OK [UIDVALIDITY 1]', 'TAG5 OK SELECT completed', + '* SEARCH 9', 'TAG6 OK SEARCH completed', + '* 3 FETCH (UID 9 FLAGS ())', 'TAG7 OK FETCH completed', + ], [7, 9]], + 'message removed before fetch' => [7, 1, [ + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 7', 'TAG3 OK SEARCH completed', 'TAG4 OK FETCH completed', + ], []], + 'application connection has different uid validity' => [7, 1, [ + '* OK [UIDVALIDITY 2]', 'TAG2 OK SELECT completed', + ], []], + 'missing uidnext snapshots existing messages' => [null, 1, [ + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 2 6', 'TAG3 OK SEARCH completed', + '* OK [UIDVALIDITY 1]', 'TAG4 OK SELECT completed', + '* SEARCH 7', 'TAG5 OK SEARCH completed', + '* 3 FETCH (UID 7 FLAGS ())', 'TAG6 OK FETCH completed', + ], [7]], + 'initially empty folder delivers its first arrival' => [1, 1, [ + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 1', 'TAG3 OK SEARCH completed', + '* 1 FETCH (UID 1 FLAGS ())', 'TAG4 OK FETCH completed', + ], [1]], +]); + +test('idle reconciles a reconnect selection before waiting for another notification', function (int $validity, array $expected) { + $connection = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 7', 'TAG3 OK SEARCH completed', + '* 1 FETCH (UID 7 FLAGS ())', 'TAG4 OK FETCH completed', + ]); + $mailbox = new ScriptedMailbox; + $mailbox->connect($connection); + $folder = new ScriptedFolder($mailbox, 'INBOX', [ + new FolderSelected('INBOX', new Result(uidValidity: 1, uidNext: 7)), + new FolderSelected('INBOX', new Result(uidValidity: $validity, uidNext: 8)), + ...($validity === 1 ? [new MessagesExist('INBOX', new UntaggedResponse([ + new Atom('*'), new Number('1'), new Atom('EXISTS'), + ]))] : []), + ]); + $received = []; + + $folder->idle(function (MessageInterface $message) use (&$received) { + $received[] = $message->uid(); + + return false; + }); + + expect($received)->toBe($expected); + $connection->disconnect(); +})->with([ + 'unchanged validity delivers pending arrivals and honors stop' => [1, [7]], + 'changed validity starts a new arrival baseline' => [2, []], +]); + +test('idle retries a lost application connection and respects uid validity', function (array $beforeDisconnect, int $validity, array $expected) { + $stream = (new FakeStream)->disconnectWhenEmpty(); + $stream->feed(['* OK Welcome', 'TAG1 OK LOGIN completed', ...$beforeDisconnect]); + $first = new ImapConnection($stream); + $second = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* OK [UIDVALIDITY '.$validity.']', 'TAG2 OK SELECT completed', + '* SEARCH 7', 'TAG3 OK SEARCH completed', + '* 1 FETCH (UID 7 FLAGS ())', 'TAG4 OK FETCH completed', + ]); + $mailbox = new ScriptedMailbox($first, $second); + $mailbox->connect(); + $folder = new ScriptedFolder($mailbox, 'INBOX', [ + new FolderSelected('INBOX', new Result(uidValidity: 1, uidNext: 7)), + new MessagesExist('INBOX', new UntaggedResponse([ + new Atom('*'), new Number('1'), new Atom('EXISTS'), + ])), + ]); + $received = []; + + $folder->idle(function (MessageInterface $message) use (&$received) { + $received[] = $message->uid(); + }); + + expect($received)->toBe($expected); + expect($mailbox->connection())->toBe($second); + $second->disconnect(); +})->with([ + 'lost during select' => [[], 1, [7]], + 'lost during search' => [['* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed'], 1, [7]], + 'lost during fetch' => [['* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', '* SEARCH 7', 'TAG3 OK SEARCH completed'], 1, [7]], + 'changed uid validity' => [[], 2, []], +]); + +test('idle propagates callback connection exceptions without reconnecting', function () { + $connection = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* OK [UIDVALIDITY 1]', 'TAG2 OK SELECT completed', + '* SEARCH 7', 'TAG3 OK SEARCH completed', + '* 1 FETCH (UID 7 FLAGS ())', 'TAG4 OK FETCH completed', + ]); + $mailbox = new ScriptedMailbox; + $mailbox->connect($connection); + $folder = new ScriptedFolder($mailbox, 'INBOX', [ + new FolderSelected('INBOX', new Result(uidValidity: 1, uidNext: 7)), + new MessagesExist('INBOX', new UntaggedResponse([ + new Atom('*'), new Number('1'), new Atom('EXISTS'), + ])), + ]); + $exception = new ImapConnectionClosedException('Application callback failed'); + + expect(fn () => $folder->idle(function () use ($exception) { + throw $exception; + }))->toThrow($exception); + expect($mailbox->connection())->toBe($connection); + $connection->stream()->assertNotWritten('LOGOUT'); + $connection->disconnect(); +}); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php new file mode 100644 index 0000000..e1f1cab --- /dev/null +++ b/tests/Unit/IncrementalSyncTest.php @@ -0,0 +1,452 @@ +open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 3 EXISTS', + '* 0 RECENT', + '* OK [UIDVALIDITY 777] UIDs valid', + '* OK [UIDNEXT 10] Predicted next UID', + '* OK [HIGHESTMODSEQ 42] Highest', + '* OK [PERMANENTFLAGS (\\Seen \\*)] Flags permitted', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->select('INBOX', new CondStore); + + $stream->assertWritten('TAG1 SELECT "INBOX" (CONDSTORE)'); + expect($result->exists())->toBe(3); + expect($result->recent())->toBe(0); + expect($result->uidValidity())->toBe(777); + expect($result->uidNext())->toBe(10); + expect($result->highestModSequence())->toBe(42); + expect($result->permanentFlags())->toBe(['\\Seen', '\\*']); + expect($result->supportsModSequences())->toBeTrue(); +}); + +test('select reports when mod sequences are unavailable', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* OK [NOMODSEQ] No persistent mod sequences', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->select('INBOX', new CondStore); + + expect($result->highestModSequence())->toBeNull(); + expect($result->supportsModSequences())->toBeFalse(); +}); + +test('select does not assume mod sequences are supported without a response code', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* OK [UIDVALIDITY 777] UIDs valid', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->select('INBOX'); + + expect($result->highestModSequence())->toBeNull(); + expect($result->supportsModSequences())->toBeFalse(); +}); + +test('quick resync selection includes the saved checkpoint', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 FETCH (UID 3 FLAGS (\\Seen) MODSEQ (43))', + '* VANISHED (EARLIER) 2', + 'TAG1 OK SELECT completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $result = $connection->select( + 'INBOX', + new QuickResync(777, 42, [1, 2, 3, 7]), + ); + + $stream->assertWritten('TAG1 SELECT "INBOX" (QRESYNC (777 42 1:3,7))'); + expect($result->changes()->messages()[0]->uid())->toBe(3); + expect($result->changes()->vanishedUids()->all())->toBe([2]); +}); + +test('enable sends one or more capabilities', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* ENABLED QRESYNC', + 'TAG1 OK ENABLE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->enable('QRESYNC'); + + $stream->assertWritten('TAG1 ENABLE QRESYNC'); + expect($responses->count())->toBe(1); +}); + +test('fetch changes returns changed and vanished messages', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 2 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (43))', + '* VANISHED (EARLIER) 3:4,6', + 'TAG1 OK FETCH completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $changes = $connection->fetch([1, 2, 3, 4, 6, 7], 'FLAGS', modifiers: new ChangedSince(42, vanished: true)); + + $stream->assertWritten('TAG1 UID FETCH 1:4,6:7 (FLAGS) (CHANGEDSINCE 42 VANISHED)'); + expect($changes->messages())->toHaveCount(1); + expect($changes->messages()[0]->uid())->toBe(7); + expect($changes->messages()[0]->flags())->toBe(['\\Seen']); + expect($changes->messages()[0]->modSequence())->toBe(43); + expect($changes->vanished())->toHaveCount(1); + expect($changes->vanished()[0]->uids()->all())->toBe([3, 4, 6]); + expect($changes->vanished()[0]->earlier())->toBeTrue(); + expect($changes->vanishedUids()->all())->toBe([3, 4, 6]); +}); + +test('conditional store returns updated messages', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + '* 2 FETCH (UID 7 FLAGS (\\Seen \\Flagged) MODSEQ (44))', + 'TAG1 OK STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store(7, '\\Flagged', modifiers: new UnchangedSince(43)); + + $stream->assertWritten('TAG1 UID STORE 7 (UNCHANGEDSINCE 43) +FLAGS.SILENT (\\Flagged)'); + expect($result->response()->successful())->toBeTrue(); + expect($result->modified()->all())->toBe([]); + expect($result->messages())->toHaveCount(1); + expect($result->messages()[0]->modSequence())->toBe(44); +}); + +test('conditional store returns conflicting message uids', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 NO [MODIFIED 8:9] Conditional STORE failed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store([7, 8, 9], '\\Seen', modifiers: new UnchangedSince(43)); + + expect($result->response()->successful())->toBeFalse(); + expect($result->modified()->all())->toBe([8, 9]); +}); + +test('mailbox enables qresync before selecting and keeps the folder selected', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 CONDSTORE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG3 OK ENABLE completed', + '* 3 EXISTS', + '* OK [UIDVALIDITY 777] UIDs valid', + '* OK [HIGHESTMODSEQ 42] Highest', + 'TAG4 OK SELECT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + + $selection = $folder->select(options: new QuickResync(777, 40, [1, 2, 3])); + $folder->messages(); + $mailbox->enable('QRESYNC'); + + $stream->assertWritten('TAG3 ENABLE QRESYNC'); + $stream->assertWritten('TAG4 SELECT "INBOX" (QRESYNC (777 40 1:3))'); + $stream->assertNotWritten('TAG5 SELECT'); + expect($selection->highestModSequence())->toBe(42); +}); + +test('mailbox does not select with qresync when the server does not enable it', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED', + 'TAG3 OK ENABLE completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + + expect(fn () => $folder->select(options: new QuickResync(777, 42)))->toThrow( + ImapCapabilityException::class, + 'Unable to select folder with [QRESYNC]. IMAP server did not enable it.', + ); + + $stream->assertWritten('TAG3 ENABLE QRESYNC'); + $stream->assertNotWritten('TAG4 SELECT'); +}); + +test('mailbox rejects enabling qresync after selecting a folder', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG3 OK CAPABILITY completed', + ]); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + $folder->select(); + + expect(fn () => $folder->select(options: new QuickResync(777, 42)))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.', + ); + $stream->assertNotWritten('ENABLE QRESYNC'); +}); + +test('mailbox rejects enabling qresync after examining a folder', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK EXAMINE completed', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG3 OK CAPABILITY completed', + ]); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + $folder->examine(); + + expect(fn () => $folder->select(options: new QuickResync(777, 42)))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.', + ); + $stream->assertNotWritten('ENABLE QRESYNC'); +}); + +test('message query fetches changes without searching first', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 3 EXISTS', + 'TAG2 OK SELECT completed', + '* CAPABILITY IMAP4rev1 CONDSTORE', + 'TAG3 OK CAPABILITY completed', + '* 2 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (43))', + 'TAG4 OK FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + + $changes = $folder->messages()->changesSince(42, [1, 2, 7]); + + $stream->assertWritten('TAG4 UID FETCH 1:2,7 (FLAGS) (CHANGEDSINCE 42)'); + $stream->assertNotWritten('UID SEARCH'); + expect($changes->messages()[0]->uid())->toBe(7); +}); + +test('vanished synchronization requires qresync to be enabled before selection', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG3 OK CAPABILITY completed', + ]); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $query = (new Folder($mailbox, 'INBOX'))->messages(); + + expect(fn () => $query->changesSince(42, [7], vanished: true))->toThrow( + ImapCapabilityException::class, + 'Enable QRESYNC before selecting a folder to request vanished messages.', + ); + $stream->assertNotWritten('ENABLE QRESYNC'); + $stream->assertNotWritten('UID FETCH'); +}); + +test('vanished synchronization reuses qresync enabled before selection', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG3 OK ENABLE completed', + 'TAG4 OK SELECT completed', + '* VANISHED (EARLIER) 7', + 'TAG5 OK FETCH completed', + 'TAG6 OK FETCH completed', + ]); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $mailbox->enable('qresync'); + $mailbox->enable('QRESYNC'); + $query = (new Folder($mailbox, 'INBOX'))->messages(); + + $result = $query->changesSince(42, [7], vanished: true); + $query->changesSince(42, [7], vanished: true); + + expect($result->vanishedUids()->all())->toBe([7]); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + $stream->assertWritten('TAG3 ENABLE QRESYNC'); + $stream->assertWritten('TAG4 SELECT "INBOX"'); + $stream->assertWritten('TAG5 UID FETCH 7 (FLAGS) (CHANGEDSINCE 42 VANISHED)'); + $stream->assertWritten('TAG6 UID FETCH 7 (FLAGS) (CHANGEDSINCE 42 VANISHED)'); +}); + +test('advertised qresync is not treated as enabled when the server does not acknowledge it', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED', + 'TAG3 OK ENABLE completed', + 'TAG4 OK SELECT completed', + ]); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $mailbox->enable('QRESYNC'); + $query = (new Folder($mailbox, 'INBOX'))->messages(); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect(fn () => $query->changesSince(42, [7], vanished: true))->toThrow(ImapCapabilityException::class); + $stream->assertNotWritten('UID FETCH'); +}); + +test('the maximum rfc7162 checkpoint round trips without losing precision', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '* ENABLED QRESYNC', + 'TAG1 OK ENABLE completed', + '* OK [HIGHESTMODSEQ 9223372036854775807] Highest', + 'TAG2 OK SELECT completed', + '* 1 FETCH (UID 7 FLAGS () MODSEQ (9223372036854775807))', + 'TAG3 OK FETCH completed', + 'TAG4 OK STORE completed', + 'TAG5 OK SELECT completed', + ]); + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->enable('QRESYNC'); + $checkpoint = $connection->select('INBOX', new CondStore)->highestModSequence(); + $result = $connection->fetch(7, 'FLAGS', modifiers: new ChangedSince($checkpoint)); + $connection->store(7, '\\Seen', modifiers: new UnchangedSince($result->messages()[0]->modSequence())); + $connection->select('INBOX', new QuickResync(777, $checkpoint)); + + expect($checkpoint)->toBe(9223372036854775807); + expect($result->messages()[0]->modSequence())->toBe($checkpoint); + $stream->assertWritten('TAG3 UID FETCH 7 (FLAGS) (CHANGEDSINCE 9223372036854775807)'); + $stream->assertWritten('TAG4 UID STORE 7 (UNCHANGEDSINCE 9223372036854775807) +FLAGS.SILENT (\\Seen)'); + $stream->assertWritten('TAG5 SELECT "INBOX" (QRESYNC (777 9223372036854775807))'); +}); + +test('empty synchronization sets return without capability checks or fetch commands', function (bool $vanished) { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $folder = new Folder($mailbox, 'INBOX'); + + $result = $folder->messages()->changesSince(0, [], vanished: $vanished); + + expect($result->messages())->toBeEmpty(); + expect($result->vanishedUids()->all())->toBe([]); + expect($result->responses())->toBeEmpty(); + $stream->assertNotWritten('CAPABILITY'); + $stream->assertNotWritten('FETCH'); +})->with([false, true]); + +test('large vanished ranges can be consumed without expanding the entire response', function () { + $connection = ImapConnection::fake([ + '* OK Welcome', + '* VANISHED (EARLIER) 1:4294967295', + 'TAG1 OK FETCH completed', + ]); + $connection->connect('imap.example.com'); + + $result = $connection->fetch('1:*', 'FLAGS', modifiers: new ChangedSince(1, true)); + + expect($result->vanished()[0]->uids()->take(3)->all())->toBe([1, 2, 3]); + expect($result->vanished()[0]->uids()->take(3)->all())->toBe([1, 2, 3]); + $connection->disconnect(); +}); + +test('large modified ranges do not expand when checking a rejected store', function () { + $connection = ImapConnection::fake([ + '* OK Welcome', + 'TAG1 NO [MODIFIED 1:4294967295] Conditional STORE failed', + ]); + $connection->connect('imap.example.com'); + + $result = $connection->store('1:*', '\\Seen', modifiers: new UnchangedSince(1)); + + expect($result->response()->successful())->toBeFalse(); + expect($result->modified()->take(3)->all())->toBe([1, 2, 3]); + $connection->disconnect(); +}); diff --git a/tests/Unit/MailboxCapabilitiesTest.php b/tests/Unit/MailboxCapabilitiesTest.php new file mode 100644 index 0000000..673edc6 --- /dev/null +++ b/tests/Unit/MailboxCapabilitiesTest.php @@ -0,0 +1,161 @@ +connect(ImapConnection::fake([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG3 OK ENABLE completed', + 'TAG4 OK SELECT completed', + 'TAG5 OK LOGOUT completed', + ])); + + $capabilities = $mailbox->capabilities(); + + $mailbox->enable('qresync'); + $folder = new Folder($mailbox, 'INBOX'); + $folder->select(); + + expect($mailbox->capabilities()->enabled('qresync'))->toBeTrue(); + expect($capabilities->enabled('qresync'))->toBeFalse(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue(); + + $mailbox->disconnect(); + + expect($mailbox->selected($folder))->toBeFalse(); + + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 CONDSTORE', + 'TAG2 OK CAPABILITY completed', + ]); + $mailbox->connect(new ImapConnection($stream)); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('CONDSTORE'))->toBeTrue(); + $stream->assertWritten('TAG2 CAPABILITY'); +}); + +test('clones discover their own capabilities without changing the original mailbox', function () { + $mailbox = Mailbox::make(); + $mailbox->connect(ImapConnection::fake([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG2 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG3 OK ENABLE completed', + ])); + $mailbox->enable('QRESYNC'); + + $clone = clone $mailbox; + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 CONDSTORE', + 'TAG2 OK CAPABILITY completed', + ]); + $clone->connect(new ImapConnection($stream)); + + expect($clone->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($clone->capabilities()->supports('QRESYNC'))->toBeFalse(); + expect($clone->capabilities()->supports('CONDSTORE'))->toBeTrue(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue(); + $stream->assertWritten('TAG2 CAPABILITY'); +}); + +test('enable requires an exact advertised capability', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 ENABLE AUTH=XOAUTH2', + 'TAG2 OK CAPABILITY completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + expect($mailbox->capabilities()->supports('AUTH'))->toBeTrue(); + expect(fn () => $mailbox->enable('AUTH'))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capability [AUTH]. IMAP server does not support it.', + ); + + $stream->assertNotWritten('ENABLE AUTH'); +}); + +test('failed selection clears the previous folder so capabilities can be enabled', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + 'TAG3 NO Mailbox unavailable', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG4 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG5 OK ENABLE completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $inbox = new Folder($mailbox, 'INBOX'); + $missing = new Folder($mailbox, 'Missing'); + + $inbox->select(); + + expect(fn () => $missing->select())->toThrow(ImapCommandException::class); + expect($mailbox->selected($inbox))->toBeFalse(); + + $mailbox->enable('QRESYNC'); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + $stream->assertWritten('TAG5 ENABLE QRESYNC'); +}); + +test('failed examination clears the previous folder so capabilities can be enabled', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK SELECT completed', + 'TAG3 NO Mailbox unavailable', + '* CAPABILITY IMAP4rev1 ENABLE QRESYNC', + 'TAG4 OK CAPABILITY completed', + '* ENABLED QRESYNC', + 'TAG5 OK ENABLE completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $inbox = new Folder($mailbox, 'INBOX'); + $missing = new Folder($mailbox, 'Missing'); + + $inbox->select(); + + expect(fn () => $missing->examine())->toThrow(ImapCommandException::class); + expect($mailbox->selected($inbox))->toBeFalse(); + + $mailbox->enable('QRESYNC'); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + $stream->assertWritten('TAG5 ENABLE QRESYNC'); +}); diff --git a/tests/Unit/MailboxTest.php b/tests/Unit/MailboxTest.php index 8e5f873..4244e27 100644 --- a/tests/Unit/MailboxTest.php +++ b/tests/Unit/MailboxTest.php @@ -1,9 +1,12 @@ '', 'encryption' => 'ssl', 'validate_cert' => true, - 'authentication' => 'plain', + 'authentication' => 'login', 'proxy' => [ 'socket' => null, 'username' => null, @@ -42,7 +45,7 @@ 'password' => 'bar', 'encryption' => 'ssl', 'validate_cert' => true, - 'authentication' => 'plain', + 'authentication' => 'login', 'proxy' => [ 'socket' => null, 'username' => null, @@ -87,6 +90,117 @@ expect($mailbox->connected())->toBeTrue(); }); +test('reconnect preserves the mailbox and its folders while updating only the password', function (?string $password, string $expectedPassword) { + $mailbox = new class(['host' => 'imap.example.com', 'username' => 'foo', 'password' => 'old-password']) extends Mailbox + { + public array $connections = []; + + public function connect(?ConnectionInterface $connection = null): void + { + parent::connect($connection ?? array_shift($this->connections)); + } + }; + + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK Selected', + 'TAG3 OK Logged out', + ]); + $connection = new ImapConnection($stream); + $mailbox->connect($connection); + $config = $mailbox->config(); + $folder = new Folder($mailbox, 'INBOX'); + $folder->select(); + + $reconnected = new FakeStream; + $reconnected->feed([ + '* OK Ready', + 'TAG1 OK Logged in', + 'TAG2 OK Selected', + 'TAG3 OK Logged out', + ]); + $subsequent = new FakeStream; + $subsequent->feed(['* OK Ready', 'TAG1 OK Logged in']); + $mailbox->connections = [new ImapConnection($reconnected), new ImapConnection($subsequent)]; + + $mailbox->reconnect(password: $password); + + expect($connection->connected())->toBeFalse(); + expect($mailbox->connected())->toBeTrue(); + expect($mailbox->config())->toBe([...$config, 'password' => $expectedPassword]); + expect($mailbox->selected($folder))->toBeFalse(); + expect($folder->mailbox())->toBe($mailbox); + expect($folder->messages())->toBeInstanceOf(MessageQuery::class); + expect($mailbox->selected($folder))->toBeTrue(); + $stream->assertWritten("TAG3 LOGOUT\r\n"); + $reconnected->assertWritten("TAG1 LOGIN \"foo\" \"{$expectedPassword}\"\r\n"); + $reconnected->assertWritten("TAG2 SELECT \"INBOX\"\r\n"); + + $mailbox->reconnect(); + + expect($mailbox->config('password'))->toBe($expectedPassword); + $subsequent->assertWritten("TAG1 LOGIN \"foo\" \"{$expectedPassword}\"\r\n"); +})->with([ + 'unchanged password' => [null, 'old-password'], + 'replacement password' => ['new-password', 'new-password'], + 'empty password' => ['', ''], +]); + +test('reconnect retains a replacement token after authentication fails', function () { + $mailbox = new class(['host' => 'imap.example.com', 'username' => 'foo', 'password' => 'old-token', 'authentication' => 'xoauth2']) extends Mailbox + { + public array $connections = []; + + public function connect(?ConnectionInterface $connection = null): void + { + parent::connect($connection ?? array_shift($this->connections)); + } + }; + + $mailbox->connect(ImapConnection::fake([ + '* OK Ready', + '+', + 'TAG1 OK Authenticated', + 'TAG2 OK Logged out', + ])); + $config = $mailbox->config(); + + $failed = new FakeStream; + $failed->feed([ + '* OK Ready', + '+', + 'TAG1 NO Authentication failed', + 'TAG2 OK Logged out', + ]); + $subsequent = new FakeStream; + $subsequent->feed(['* OK Ready', '+', 'TAG1 OK Authenticated']); + $mailbox->connections = [new ImapConnection($failed), new ImapConnection($subsequent)]; + + expect(fn () => $mailbox->reconnect(password: 'new-token'))->toThrow(ImapCommandException::class); + expect($mailbox->config())->toBe([...$config, 'password' => 'new-token']); + $failed->assertWritten("TAG1 AUTHENTICATE XOAUTH2\r\n"); + $failed->assertWritten(base64_encode("user=foo\1auth=Bearer new-token\1\1")."\r\n"); + + $mailbox->reconnect(); + + expect($mailbox->connected())->toBeTrue(); + expect($mailbox->config('password'))->toBe('new-token'); + $subsequent->assertWritten("TAG1 AUTHENTICATE XOAUTH2\r\n"); + $subsequent->assertWritten(base64_encode("user=foo\1auth=Bearer new-token\1\1")."\r\n"); +}); + +test('unsupported authentication mechanisms do not fall back to login', function (string $mechanism) { + $stream = new FakeStream; + $stream->feed('* OK Ready'); + $mailbox = Mailbox::make(['authentication' => $mechanism]); + + expect(fn () => $mailbox->connect(new ImapConnection($stream))) + ->toThrow(InvalidArgumentException::class, 'Unsupported authentication mechanism.'); + $stream->assertNotWritten("TAG1 LOGIN \"\" \"\"\r\n"); +})->with(['plain', 'oauth', 'unsupported']); + test('connect throws exception with bad response', function () { $mailbox = Mailbox::make([ 'username' => 'foo', @@ -113,7 +227,7 @@ expect($folders)->toHaveCount(1); expect($folders[0]->path())->toBe('INBOX'); - expect($folders[0]->flags())->toBe(['\\HasNoChildren']); + expect($folders[0]->attributes())->toBe(['\\HasNoChildren']); }); test('inbox', function () { @@ -131,7 +245,7 @@ expect($folder)->toBeInstanceOf(Folder::class); expect($folder->path())->toBe('INBOX'); - expect($folder->flags())->toBe(['\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\HasNoChildren']); }); test('capabilities', function () { @@ -147,9 +261,15 @@ 'TAG2 OK CAPABILITY completed', ])); - expect($mailbox->capabilities())->toBe([ - 'IMAP4rev1', + expect($mailbox->capabilities()->all())->toBe([ + 'IMAP4REV1', 'STARTTLS', 'AUTH=PLAIN', ]); + + expect($mailbox->capabilities()->supports('imap4rev1'))->toBeTrue(); + expect($mailbox->capabilities()->supports('AUTH'))->toBeTrue(); + expect($mailbox->capabilities()->supports('AUTH=PLAIN'))->toBeTrue(); + expect($mailbox->capabilities()->supports('AUTH=LOGIN'))->toBeFalse(); + expect($mailbox->capabilities()->supports('START'))->toBeFalse(); }); diff --git a/tests/Unit/MessageCursorTest.php b/tests/Unit/MessageCursorTest.php new file mode 100644 index 0000000..4b87330 --- /dev/null +++ b/tests/Unit/MessageCursorTest.php @@ -0,0 +1,39 @@ +connect($connection); + $query = (new Folder($mailbox, 'INBOX'))->messages()->with(MessageData::flags())->orderByUid()->limit(1, 3); + $cursor = $query->cursor(2); + $connection->stream()->assertNotWritten('SEARCH'); + $received = []; + + foreach ($cursor as $key => $message) { + $received[$key] = $message->uid(); + if ($stopEarly) { + break; + } + } + + expect($received)->toBe($stopEarly ? [1] : [1, 2, 3]); + expect($query->getLimit())->toBe(1); + expect($query->getPage())->toBe(3); + $connection->stream()->assertWritten('UID FETCH 1:2'); + if ($stopEarly) { + $connection->stream()->assertNotWritten('UID FETCH 3'); + } else { + $connection->stream()->assertWritten('UID FETCH 3'); + } + $connection->disconnect(); +})->with(['all batches' => false, 'early stop' => true]); diff --git a/tests/Unit/MessageDataTest.php b/tests/Unit/MessageDataTest.php new file mode 100644 index 0000000..dbe6643 --- /dev/null +++ b/tests/Unit/MessageDataTest.php @@ -0,0 +1,37 @@ +key())->toBe($command) + ->and($item->toImap())->toBe($command); +})->with([ + [MessageData::flags(), 'FLAGS'], + [MessageData::size(), 'RFC822.SIZE'], + [MessageData::bodyStructure(), 'BODYSTRUCTURE'], + [MessageData::modSequence(), 'MODSEQ'], +]); + +test('it creates body section data items', function (FetchItemInterface $item, string $command) { + expect($item->toImap())->toBe($command); +})->with([ + [MessageData::headers(), 'BODY[HEADER]'], + [MessageData::text(), 'BODY[TEXT]'], + [MessageData::section('1.2'), 'BODY[1.2]'], +]); + +test('body section data items can be fetched without setting the seen flag', function (FetchItemInterface $item, string $command) { + expect($item->peek()->toImap())->toBe($command); +})->with([ + [MessageData::headers(), 'BODY.PEEK[HEADER]'], + [MessageData::text(), 'BODY.PEEK[TEXT]'], + [MessageData::section('1.2'), 'BODY.PEEK[1.2]'], +]); + +test('peeking does not modify the original body section data item', function () { + $headers = MessageData::headers(); + + expect($headers->peek())->not->toBe($headers) + ->and($headers->toImap())->toBe('BODY[HEADER]'); +}); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index d9876b3..4a2a274 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -4,11 +4,15 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Enums\ImapFlag; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; +use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQuery; +use Illuminate\Support\ItemNotFoundException; function query(?Mailbox $mailbox = null): MessageQuery { @@ -18,6 +22,120 @@ function query(?Mailbox $mailbox = null): MessageQuery ); } +test('find resolves the uid from fetched attributes regardless of their order', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (FLAGS (\\Seen) UID 42)', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); + $message = $query->find(1, ImapIdentifier::MessageNumber); + + $stream->assertWritten('TAG2 FETCH 1 (UID)'); + expect($message->uid())->toBe(42); +}); + +test('find returns null when fetch returns no messages', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); + + expect($query->find(42))->toBeNull(); +}); + +test('find or fail throws when fetch returns no messages', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); + + expect(fn () => $query->findOrFail(42))->toThrow(ItemNotFoundException::class); +}); + +test('search sends international text with the appropriate session charset', function (string $capabilities, ?string $enabled, string $charset) { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY '.$capabilities, + 'TAG2 OK CAPABILITY completed', + ...($enabled ? ['* ENABLED '.$enabled, 'TAG3 OK ENABLE completed'] : []), + '+ Ready for literal', + '* SEARCH 42', + ($enabled ? 'TAG4' : 'TAG3').' OK SEARCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + if ($enabled) { + $mailbox->enable($enabled); + } + + $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); + + expect($query->subject('café')->get()->first()->uid())->toBe(42); + + $tag = $enabled ? 'TAG4' : 'TAG3'; + $stream->assertWritten("{$tag} UID SEARCH {$charset}SUBJECT {5}"); + $stream->assertWritten('café'); +})->with([ + 'rev1' => ['IMAP4rev1', null, 'CHARSET UTF-8 '], + 'UTF-8 advertised' => ['IMAP4rev1 ENABLE UTF8=ACCEPT', null, 'CHARSET UTF-8 '], + 'UTF-8 enabled' => ['IMAP4rev1 ENABLE UTF8=ACCEPT', 'UTF8=ACCEPT', ''], + 'rev2 only' => ['IMAP4rev2', null, ''], + 'dual-version server' => ['IMAP4rev1 IMAP4rev2 ENABLE', null, 'CHARSET UTF-8 '], + 'rev2 enabled' => ['IMAP4rev1 IMAP4rev2 ENABLE', 'IMAP4REV2', ''], +]); + +test('sort sends international search literals using UTF-8', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SORT', + 'TAG2 OK CAPABILITY completed', + '+ Ready for literal', + '* SORT 42', + 'TAG3 OK SORT completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); + + expect($query->subject('日本語')->sortBy(ImapSortKey::Subject)->get()->first()->uid())->toBe(42); + + $stream->assertWritten('TAG3 UID SORT (SUBJECT) UTF-8 SUBJECT {9}'); + $stream->assertWritten('日本語'); +}); + test('passthru', function () { $query = query(); @@ -25,6 +143,14 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($query->isEmpty())->toBeTrue(); }); +test('forwards compiled search tokens from the query builder', function () { + $query = new MessageQuery(new Folder(new Mailbox, 'INBOX'), new ImapQueryBuilder); + + expect($query->subject('café')->toTokens())->toBe([ + 'SUBJECT', ['{5}', 'café'], + ]); +}); + test('where', function () { $query = query() ->where('subject', 'hello') @@ -40,6 +166,58 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($query->toImap())->toBe('HEADER MESSAGE-ID "unique-message-id@server.example.com"'); }); +test('fetch items can be added and removed', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 1', + 'TAG2 OK UID SEARCH completed', + '* 1 FETCH (UID 1 RFC822.SIZE 1024)', + 'TAG3 OK UID FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox) + ->with(MessageData::flags(), MessageData::size()) + ->without(MessageData::flags()) + ->get(); + + $stream->assertWritten('TAG3 UID FETCH 1 (RFC822.SIZE)'); +}); + +test('fetch items can be replaced', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 1', + 'TAG2 OK UID SEARCH completed', + '* 1 FETCH (UID 1 BODY[HEADER] {0}', + '', + ' BODY[TEXT] {0}', + '', + ')', + 'TAG3 OK UID FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox) + ->with(MessageData::flags()) + ->only(MessageData::headers(), MessageData::text()) + ->get(); + + $stream->assertWritten('TAG3 UID FETCH 1 (BODY[HEADER] BODY[TEXT])'); +}); + test('destroy', function () { $stream = new FakeStream; $stream->open(); @@ -78,29 +256,80 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->assertWritten('TAG2 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); }); -test('oldest sets fetch order to asc', function () { - $query = query(); +test('destroy with expunge only expunges the given messages', function () { + $stream = new FakeStream; + $stream->open(); - $query->oldest(); + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK UID STORE completed', + 'TAG3 OK SELECT completed', + 'TAG4 OK UID EXPUNGE completed', + ]); + + $mailbox = Mailbox::make(); + + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->destroy([1, 2, 3], expunge: true); - expect($query->getFetchOrder())->toBe('asc'); + $stream->assertWritten('TAG2 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG4 UID EXPUNGE 1:3'); }); -test('newest sets fetch order to desc', function () { - $query = query(); +test('orderByUid returns messages in ascending UID order', 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', + ]); - $query->newest(); + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $uids = query($mailbox)->orderByUid()->get()->map( + fn ($message) => $message->uid() + )->all(); - expect($query->getFetchOrder())->toBe('desc'); + expect($uids)->toBe([1, 2, 3]); }); -test('oldest and newest return query instance for chaining', function () { +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', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + $uids = query($mailbox)->orderByUid(SortDirection::Descending)->get()->map( + fn ($message) => $message->uid() + )->all(); + + expect($uids)->toBe([3, 2, 1]); +}); + +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(); @@ -199,6 +428,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->feed([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', + '+ Ready', 'TAG2 OK [APPENDUID 1234567890 1] APPEND completed', ]); @@ -208,10 +438,12 @@ function query(?Mailbox $mailbox = null): MessageQuery $folder = new Folder($mailbox, 'INBOX'); $query = new MessageQuery($folder, new ImapQueryBuilder); - $uid = $query->append('Hello world', $flag); + $result = $query->append('Hello world', $flag); - expect($uid)->toBe(1); - $stream->assertWritten('TAG2 APPEND "INBOX" (\\Seen) "Hello world"'); + expect($result->uidValidity())->toBe(1234567890); + expect($result->uid())->toBe(1); + $stream->assertWritten('TAG2 APPEND "INBOX" (\\Seen) {11}'); + $stream->assertWritten('Hello world'); })->with([ImapFlag::Seen, '\\Seen']); test('flag adds flag to all matching messages', function () { @@ -410,7 +642,8 @@ function query(?Mailbox $mailbox = null): MessageQuery '* SEARCH 1 2', 'TAG2 OK SEARCH completed', 'TAG3 OK UID STORE completed', - 'TAG4 OK EXPUNGE completed', + 'TAG4 OK SELECT completed', + 'TAG5 OK UID EXPUNGE completed', ]); $mailbox = Mailbox::make(); @@ -423,7 +656,7 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($count)->toBe(2); $stream->assertWritten('TAG3 UID STORE 1:2 +FLAGS.SILENT (\Deleted)'); - $stream->assertWritten('TAG4 EXPUNGE'); + $stream->assertWritten('TAG5 UID EXPUNGE 1:2'); }); test('move moves all matching messages to folder', function () { @@ -510,6 +743,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(); @@ -521,6 +758,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(); @@ -547,11 +817,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(); @@ -589,7 +907,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/MessageTest.php b/tests/Unit/MessageTest.php index 2850b14..28d5443 100644 --- a/tests/Unit/MessageTest.php +++ b/tests/Unit/MessageTest.php @@ -1,8 +1,10 @@ 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); $newUid = $message->move('INBOX.Sent'); @@ -47,13 +54,53 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $message = new Message($folder, 1, [], 'header', 'body'); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); $newUid = $message->move('INBOX.Sent'); expect($newUid)->toBe(123); }); +test('it only expunges the deleted message', function () { + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'bar', + ]); + + $stream = new FakeStream; + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK STORE completed', + 'TAG3 OK SELECT completed', + 'TAG4 OK UID EXPUNGE completed', + ]); + + $connection = new ImapConnection($stream); + + $mailbox->connect($connection); + + $folder = new Folder($mailbox, 'INBOX', [], '/'); + + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 42, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); + + $message->delete(expunge: true); + + $stream->assertWritten('TAG2 UID STORE 42 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG4 UID EXPUNGE 42'); +}); + test('it throws exception when server does not support MOVE or UIDPLUS capabilities', function () { $mailbox = Mailbox::make([ 'username' => 'foo', @@ -69,7 +116,12 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $message = new Message($folder, 1, [], 'header', 'body'); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); $message->move('INBOX.Sent'); })->throws(ImapCapabilityException::class); @@ -90,7 +142,12 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $message = new Message($folder, 1, [], 'header', 'body'); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); expect($message->isFlagged())->toBeFalse(); expect($message->flags())->not->toContain('\\Flagged'); @@ -108,6 +165,33 @@ expect($message->hasFlag(ImapFlag::Flagged))->toBeFalse(); }); +test('it retains data returned while updating a message flag', function () { + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(ImapConnection::fake([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 1 FLAGS (\\Seen \\Flagged) MODSEQ (44))', + 'TAG2 OK STORE completed', + ])); + + $folder = new Folder($mailbox, 'INBOX', [], '/'); + + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'MODSEQ' => [43], + ])); + + $message->markFlagged(); + + expect($message->flags())->toBe(['\\Seen', '\\Flagged']); + expect($message->modSequence())->toBe(44); +}); + test('it can determine if two messages are the same', function () { $mailbox = Mailbox::make([ 'username' => 'foo', @@ -123,12 +207,42 @@ $folder2 = new Folder($mailbox, 'INBOX.Sent', [], '/'); // Create messages with different properties - $message1 = new Message($folder1, 1, [], 'header1', 'body1'); - $message2 = new Message($folder1, 1, [], 'header1', 'body1'); // Same as message1 - $message3 = new Message($folder1, 2, [], 'header1', 'body1'); // Different UID - $message4 = new Message($folder2, 1, [], 'header1', 'body1'); // Different folder - $message5 = new Message($folder1, 1, [], 'header2', 'body1'); // Different header - $message6 = new Message($folder1, 1, [], 'header1', 'body2'); // Different body + $message1 = new Message($folder1, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header1', + 'BODY[TEXT]' => 'body1', + ])); + $message2 = new Message($folder1, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header1', + 'BODY[TEXT]' => 'body1', + ])); // Same as message1 + $message3 = new Message($folder1, new FetchedMessageData([ + 'UID' => 2, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header1', + 'BODY[TEXT]' => 'body1', + ])); // Different UID + $message4 = new Message($folder2, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header1', + 'BODY[TEXT]' => 'body1', + ])); // Different folder + $message5 = new Message($folder1, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header2', + 'BODY[TEXT]' => 'body1', + ])); // Different header + $message6 = new Message($folder1, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header1', + 'BODY[TEXT]' => 'body2', + ])); // Different body // Same message expect($message1->is($message2))->toBeTrue(); @@ -159,14 +273,13 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $originalMessage = new Message( - $folder, - 123, - ['\\Seen', '\\Flagged'], - 'From: test@example.com', - 'This is the message body content', - 1024 - ); + $originalMessage = new Message($folder, new FetchedMessageData([ + 'UID' => 123, + 'FLAGS' => ['\\Seen', '\\Flagged'], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODY[TEXT]' => 'This is the message body content', + 'RFC822.SIZE' => 1024, + ])); $serialized = serialize($originalMessage); $unserializedMessage = unserialize($serialized); @@ -199,7 +312,12 @@ '* 1 FETCH (BODYSTRUCTURE ("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 12 1 NIL NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); @@ -227,7 +345,12 @@ '* 1 FETCH (BODYSTRUCTURE ("text" "html" ("charset" "utf-8") NIL NIL "7bit" 19 1 NIL NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); @@ -257,7 +380,12 @@ '* 1 FETCH (BODYSTRUCTURE ("text" "plain" ("charset" "utf-8") NIL NIL "base64" 16 1 NIL NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->text(fetch: true))->toBe('Hello World!'); }); @@ -285,7 +413,12 @@ '* 1 FETCH (BODYSTRUCTURE ("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 14 1 NIL NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->text(fetch: true))->toBe('Hello World!'); }); @@ -315,7 +448,12 @@ '* 1 FETCH (BODYSTRUCTURE ("text" "plain" ("charset" "iso-8859-1") NIL NIL "7bit" 5 1 NIL NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->text(fetch: true))->toBe($originalContent); }); @@ -343,7 +481,12 @@ $body = 'Hello from parsed body!'; - $message = new Message($folder, 1, [], $head, $body); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => $head, + 'BODY[TEXT]' => $body, + ])); expect($message->hasBody())->toBeTrue(); expect($message->text())->toBe('Hello from parsed body!'); @@ -371,7 +514,12 @@ '* 1 FETCH (BODYSTRUCTURE (("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 12 1 NIL NIL NIL) ("text" "html" ("charset" "utf-8") NIL NIL "7bit" 24 1 NIL NIL NIL) "alternative" ("boundary" "abc") NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); @@ -400,7 +548,12 @@ '* 1 FETCH (BODYSTRUCTURE (("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 12 1 NIL NIL NIL) ("text" "html" ("charset" "utf-8") NIL NIL "7bit" 19 1 NIL NIL NIL) "alternative" ("boundary" "abc") NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->html(fetch: true))->toBe('

Hello World!

'); }); @@ -411,7 +564,7 @@ 'password' => 'bar', ]); - // This simulates a message fetched without withBodyStructure(), then accessing text() + // This simulates a message fetched without its body structure, then accessing text(). // The server will respond with: 1) body structure fetch, 2) body part fetch $mailbox->connect(ImapConnection::fake([ '* OK Welcome to IMAP', @@ -428,8 +581,12 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - // Message created without body structure data - simulates fetching without withBodyStructure() - $message = new Message($folder, 1, [], 'From: test@example.com', ''); + // Message created without body structure data. + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + ])); expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeFalse(); @@ -459,7 +616,11 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $message = new Message($folder, 1, [], 'From: test@example.com', ''); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + ])); expect($message->hasBodyStructure())->toBeFalse(); expect($message->html(fetch: true))->toBe('

Hello World!

'); @@ -476,10 +637,11 @@ $mailbox->connect(ImapConnection::fake([ '* OK Welcome to IMAP', 'TAG1 OK Logged in', - '* 1 FETCH (UID 1 BODY[2] {'.(strlen($encodedContent) + 2).'}', + 'TAG2 OK Selected', + '* 1 FETCH (UID 1 BODY[2]<0> {'.(strlen($encodedContent) + 2).'}', $encodedContent, ')', - 'TAG2 OK FETCH completed', + 'TAG3 OK FETCH completed', ])); $folder = new Folder($mailbox, 'INBOX', [], '/'); @@ -489,7 +651,12 @@ '* 1 FETCH (BODYSTRUCTURE (("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 100 5 NIL NIL NIL) ("application" "pdf" ("name" "document.pdf") NIL NIL "base64" 5000 NIL ("attachment" ("filename" "document.pdf")) NIL NIL) "mixed" ("boundary" "abc") NIL NIL) UID 1)' ); - $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + 'BODY[HEADER]' => 'From: test@example.com', + 'BODYSTRUCTURE' => $bodyStructureData, + ])); expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); @@ -524,7 +691,10 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); // Create a message with just the UID - no headers or body - $message = new Message($folder, 1, [], '', ''); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 1, + 'FLAGS' => [], + ])); expect($message->hasHead())->toBeFalse(); expect($message->hasBody())->toBeFalse(); diff --git a/tests/Unit/PartialBodyTest.php b/tests/Unit/PartialBodyTest.php new file mode 100644 index 0000000..8f0e742 --- /dev/null +++ b/tests/Unit/PartialBodyTest.php @@ -0,0 +1,149 @@ +partial(12, 64)->peek(); + + expect($partial->key())->toBe('BODY[1.2]<12>') + ->and($partial->toImap())->toBe('BODY.PEEK[1.2]<12.64>') + ->and($item->toImap())->toBe('BODY[1.2]'); +}); + +test('partial ranges reject invalid bounds', function (int $offset, int $length): void { + MessageData::text()->partial($offset, $length); +})->with([[-1, 1], [0, 0], [0, -1]])->throws(InvalidArgumentException::class); + +test('partial fetches parse short and empty responses without caching them as full parts', function (): void { + $mailbox = new Mailbox; + $wire = new FakeStream; + $wire->feed([ + '* OK Welcome', + 'TAG1 OK Logged in', + 'TAG2 OK Selected', + '* 1 FETCH (UID 7 BODY[2]<0> "abc")', + 'TAG3 OK Fetched', + '* 1 FETCH (UID 7 BODY[2]<3> "")', + 'TAG4 OK Fetched', + '* 1 FETCH (UID 7 BODY[2] "abcdef")', + 'TAG5 OK Fetched', + ]); + $mailbox->connect(new ImapConnection($wire)); + $message = new Message(new Folder($mailbox, 'INBOX'), new FetchedMessageData(['UID' => 7])); + + expect($message->bodyPart('2', offset: 0, length: 10))->toBe('abc') + ->and($message->bodyPart('2', offset: 3, length: 10))->toBe('') + ->and($message->bodyPart('2'))->toBe('abcdef'); + + $wire->assertWritten('TAG3 UID FETCH 7 (BODY.PEEK[2]<0.10>)'); + $wire->assertWritten('TAG4 UID FETCH 7 (BODY.PEEK[2]<3.10>)'); +}); + +test('missing body parts fail instead of producing a truncated attachment', function (): void { + $message = new class extends Message + { + public function __construct() {} + + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string + { + return null; + } + }; + $stream = new LazyBodyPartStream($message, new BodyStructurePart('2', 'application', 'octet-stream')); + + $stream->read(1); +})->throws(RuntimeException::class, 'The body part is no longer available.'); + +test('attachment streams decode across chunk boundaries and preserve binary bytes', function (string $encoding, int $chunkSize): void { + $contents = "Hello\0world\r\n\xff\r\n"; + $encoded = match ($encoding) { + 'base64' => chunk_split(base64_encode($contents), 4), + 'quoted-printable' => quoted_printable_encode($contents), + default => $contents, + }; + $message = new class($encoded) extends Message + { + public function __construct( + /** + * The transfer-encoded body part returned by partial fetches. + */ + protected string $encoded, + ) {} + + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string + { + return substr($this->encoded, $offset, $length); + } + }; + $stream = new LazyBodyPartStream($message, new BodyStructurePart('2', 'application', 'octet-stream', encoding: $encoding), $chunkSize); + + expect($stream->getSize())->toBeNull() + ->and($stream->read(2))->toBe('He') + ->and($stream->tell())->toBe(2) + ->and($stream->getContents())->toBe(substr($contents, 2)) + ->and($stream->eof())->toBeTrue() + ->and($stream->getSize())->toBe(strlen($contents)); + + $stream->seek(-2, SEEK_END); + expect($stream->read(2))->toBe("\r\n") + ->and((string) $stream)->toBe($contents); +})->with(['base64', 'quoted-printable', '8bit'])->with([1, 3, 5, 64]); + +test('a small read only fetches the required chunk and rewind reuses it', function (): void { + $message = new class extends Message + { + /** + * The body part fetch arguments recorded by the test message. + */ + public array $requests = []; + + public function __construct() {} + + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string + { + $this->requests[] = [$partNumber, $peek, $offset, $length]; + + return substr('abcdefgh', $offset, $length); + } + }; + $stream = new LazyBodyPartStream($message, new BodyStructurePart('2', 'application', 'octet-stream'), 4); + + expect($stream->read(1))->toBe('a'); + $stream->rewind(); + expect($stream->read(4))->toBe('abcd') + ->and($message->requests)->toBe([['2', true, 0, 4]]); + $stream->close(); + expect($stream->isReadable())->toBeFalse(); +}); + +test('saving an attachment copies decoded chunks and returns its byte count', function (): void { + $message = new class extends Message + { + public function __construct() {} + + public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string + { + return substr('YWJjZA==', $offset, $length); + } + }; + $stream = new LazyBodyPartStream($message, new BodyStructurePart('2', 'application', 'octet-stream', encoding: 'base64'), 4); + $attachment = new Attachment('file.bin', null, 'application/octet-stream', 'attachment', $stream); + $path = tempnam(sys_get_temp_dir(), 'imap-partial-'); + + try { + expect($attachment->save($path))->toBe(4) + ->and(file_get_contents($path))->toBe('abcd'); + } finally { + unlink($path); + } +}); diff --git a/tests/Unit/PollTest.php b/tests/Unit/PollTest.php new file mode 100644 index 0000000..f103e3f --- /dev/null +++ b/tests/Unit/PollTest.php @@ -0,0 +1,124 @@ + (new Poll($mailbox, 'INBOX', 1))->start(function () use ($exception) { + throw $exception; + }, fn ($query) => $query))->toThrow($exception); + + expect($mailbox->connected())->toBeFalse(); + expect($connection->stream()->opened())->toBeFalse(); + $connection->stream()->assertWritten('TAG8 LOGOUT'); +})->with([RuntimeException::class, Exception::class, ImapConnectionClosedException::class]); + +test('poll still reconnects when retrieving messages loses the connection', function () { + $first = (new FakeStream)->disconnectWhenEmpty()->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 1]', 'TAG3 OK SELECT completed', + '* LIST () "/" "INBOX"', 'TAG4 OK LIST completed', + '* OK [UIDVALIDITY 1]', 'TAG5 OK SELECT completed', + ]); + $second = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 2]', 'TAG3 OK SELECT completed', + '* LIST () "/" "INBOX"', 'TAG4 OK LIST completed', + '* OK [UIDVALIDITY 1]', 'TAG5 OK SELECT completed', + '* SEARCH 1', 'TAG6 OK SEARCH completed', + '* 1 FETCH (UID 1 FLAGS ())', 'TAG7 OK FETCH completed', + '* BYE Logging out', 'TAG8 OK LOGOUT completed', + ]); + $mailbox = new ScriptedMailbox(new ImapConnection($first), $second); + $received = []; + + (new Poll($mailbox, 'INBOX', 1))->start(function ($message) use (&$received) { + $received[] = $message->uid(); + + return false; + }, fn ($query) => $query); + + expect($received)->toBe([1]); + expect($mailbox->connected())->toBeFalse(); + $first->assertWritten('TAG6 UID SEARCH UID 1:*'); + $second->stream()->assertWritten('TAG6 UID SEARCH UID 1:*'); + expect($second->stream()->opened())->toBeFalse(); +}); + +test('poll stops and disconnects immediately when the callback returns false', function () { + $connection = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDNEXT 1]', 'TAG3 OK SELECT completed', + '* LIST () "/" "INBOX"', 'TAG4 OK LIST completed', + '* OK [UIDNEXT 3]', 'TAG5 OK SELECT completed', + '* SEARCH 1 2', 'TAG6 OK SEARCH completed', + '* 1 FETCH (UID 1 FLAGS ())', '* 2 FETCH (UID 2 FLAGS ())', 'TAG7 OK FETCH completed', + '* BYE Logging out', 'TAG8 OK LOGOUT completed', + ]); + $mailbox = new ScriptedMailbox($connection); + $received = []; + + (new Poll($mailbox, 'INBOX', 60))->start(function ($message) use (&$received) { + $received[] = $message->uid(); + + return false; + }, fn ($query) => $query); + + expect($received)->toBe([1]); + expect($mailbox->connected())->toBeFalse(); + expect($connection->stream()->opened())->toBeFalse(); + $connection->stream()->assertWritten('TAG8 LOGOUT'); +}); + +test('poll preserves or resets its uid baseline when selecting again', function (int $validity, int $uidNext, array $expected) { + $connection = ImapConnection::fake([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 1]', 'TAG3 OK SELECT completed', + '* LIST () "/" "INBOX"', 'TAG4 OK LIST completed', + '* OK [UIDVALIDITY 1]', '* OK [UIDNEXT 4]', 'TAG5 OK SELECT completed', + '* SEARCH 3 1 2', 'TAG6 OK SEARCH completed', + '* 3 FETCH (UID 3 FLAGS ())', '* 1 FETCH (UID 1 FLAGS ())', '* 2 FETCH (UID 2 FLAGS ())', 'TAG7 OK FETCH completed', + '* LIST () "/" "INBOX"', 'TAG8 OK LIST completed', + "* OK [UIDVALIDITY {$validity}]", "* OK [UIDNEXT {$uidNext}]", 'TAG9 OK SELECT completed', + '* SEARCH 1 3 4', 'TAG10 OK SEARCH completed', + '* 1 FETCH (UID 1 FLAGS ())', '* 3 FETCH (UID 3 FLAGS ())', '* 4 FETCH (UID 4 FLAGS ())', 'TAG11 OK FETCH completed', + '* BYE Logging out', 'TAG12 OK LOGOUT completed', + ]); + $mailbox = new ScriptedMailbox($connection); + $received = []; + + (new Poll($mailbox, 'INBOX', 1))->start(function ($message) use (&$received) { + $received[] = $message->uid(); + + return $message->uid() !== 4; + }, fn ($query) => $query); + + expect($received)->toBe([1, 2, 3, ...$expected]); + $start = $validity === 1 ? 4 : $uidNext; + $connection->stream()->assertWritten("TAG10 UID SEARCH UID {$start}:*"); + expect($connection->stream()->opened())->toBeFalse(); +})->with([ + 'same validity excludes delivered and older UIDs' => [1, 5, [4]], + 'changed validity starts a new baseline' => [2, 3, [3, 4]], +]); diff --git a/tests/Unit/Selection/QuickResyncTest.php b/tests/Unit/Selection/QuickResyncTest.php new file mode 100644 index 0000000..3aaac2b --- /dev/null +++ b/tests/Unit/Selection/QuickResyncTest.php @@ -0,0 +1,19 @@ +toImap())->toBe('QRESYNC (4294967295 1)'); +}); + +test('it rejects invalid uid validity values', function (int $uidValidity) { + expect(fn () => new QuickResync($uidValidity, 1)) + ->toThrow(InvalidArgumentException::class); +})->with([0, 4294967296]); + +test('it rejects invalid modification sequences', function (int $modSequence) { + expect(fn () => new QuickResync(1, $modSequence)) + ->toThrow(InvalidArgumentException::class); +})->with([-1, 0]); diff --git a/tests/Unit/Store/UnchangedSinceTest.php b/tests/Unit/Store/UnchangedSinceTest.php new file mode 100644 index 0000000..0babb06 --- /dev/null +++ b/tests/Unit/Store/UnchangedSinceTest.php @@ -0,0 +1,14 @@ +toImap())->toBe('UNCHANGEDSINCE 0'); +}); + +test('it rejects negative modification sequences', function () { + expect(fn () => new UnchangedSince(-1)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/Support/EnumTest.php b/tests/Unit/Support/EnumTest.php new file mode 100644 index 0000000..1f75cbc --- /dev/null +++ b/tests/Unit/Support/EnumTest.php @@ -0,0 +1,43 @@ +toBe('\Seen'); +}); + +test('enums returns an array of enum values for an array of backed enums', function () { + $result = Enum::values([ImapFlag::Seen, ImapFlag::Draft]); + + expect($result)->toBeArray(); + expect($result)->toEqual(['\Seen', '\Draft']); +}); + +test('enums returns the string when a string is provided', function () { + $input = 'example string'; + + $result = Enum::values($input); + + expect($result)->toBe($input); +}); + +test('enums handles nested arrays containing backed enums and strings', function () { + $input = [ + [ImapFlag::Seen, 'nested string'], + ImapFlag::Draft, + 'another string', + ]; + + $expected = [ + ['\Seen', 'nested string'], + '\Draft', + 'another string', + ]; + + $result = Enum::values($input); + + expect($result)->toEqual($expected); +}); diff --git a/tests/Unit/Support/IdleAcknowledgementLoggerTest.php b/tests/Unit/Support/IdleAcknowledgementLoggerTest.php new file mode 100644 index 0000000..eedad4d --- /dev/null +++ b/tests/Unit/Support/IdleAcknowledgementLoggerTest.php @@ -0,0 +1,66 @@ +sent('TAG1 APPEND "INBOX" {10}'); + $logger->received('+ Ready for literal'); + + $logger->sent('TAG2 IDLE'); + $logger->received('* 1 EXISTS'); + + expect($calls)->toBe(0); + expect($logger->acknowledged)->toBeFalse(); + + $logger->received('+ Idling'); + + expect($calls)->toBe(1); + expect($logger->acknowledged)->toBeTrue(); +}); + +test('the idle hook invokes its callback only once across renewals', function () { + $calls = 0; + + $logger = new IdleAcknowledgementLogger(function () use (&$calls) { + $calls++; + }); + + $logger->sent('TAG1 IDLE'); + $logger->received('+ Idling'); + + $logger->sent('DONE'); + $logger->received('TAG1 OK IDLE completed'); + + $logger->sent('TAG2 IDLE'); + $logger->received('+ Idling'); + + expect($calls)->toBe(1); +}); + +test('a rejected idle command does not arm the next unrelated continuation', function () { + $calls = 0; + + $logger = new IdleAcknowledgementLogger(function () use (&$calls) { + $calls++; + }); + + $logger->sent('TAG1 IDLE'); + $logger->received('TAG1 NO IDLE unavailable'); + + $logger->sent('TAG2 APPEND "INBOX" {10}'); + $logger->received('+ Ready for literal'); + + expect($calls)->toBe(0); + expect($logger->acknowledged)->toBeFalse(); + + $logger->sent('TAG3 IDLE'); + $logger->received('+ Idling'); + + expect($calls)->toBe(1); +}); diff --git a/tests/Unit/Support/MessageSetMatcherTest.php b/tests/Unit/Support/MessageSetMatcherTest.php new file mode 100644 index 0000000..4f2ca40 --- /dev/null +++ b/tests/Unit/Support/MessageSetMatcherTest.php @@ -0,0 +1,56 @@ +contains($number))->toBe($matches); +})->with([ + [2, true], + [5, true], + [7, true], + [10, true], + [18, true], + [20, true], + [1, false], + [4, false], + [11, false], + [21, false], +]); + +test('it matches unordered overlapping and adjacent ranges', function (int $number, bool $matches) { + $matcher = new MessageSetMatcher('20:18,1:4,10:5,3:8,17:15'); + + expect($matcher->contains($number))->toBe($matches); +})->with([ + [1, true], + [10, true], + [15, true], + [20, true], + [11, false], + [14, false], + [21, false], +]); + +test('it matches sets containing many compact ranges', function () { + $ranges = []; + + for ($number = 1; $number <= 10000; $number += 4) { + $ranges[] = $number.':'.($number + 1); + } + + $matcher = new MessageSetMatcher(implode(',', $ranges)); + + expect($matcher->contains(1))->toBeTrue(); + expect($matcher->contains(5001))->toBeTrue(); + expect($matcher->contains(9999))->toBeFalse(); + expect($matcher->contains(10001))->toBeFalse(); +}); + +test('it preserves server-resolved sets without guessing their bounds', function (string $set) { + $matcher = new MessageSetMatcher($set); + + expect($matcher->contains(1))->toBeTrue(); + expect($matcher->contains(4294967295))->toBeTrue(); +})->with(['*', '999:*', '*:999', '$']); diff --git a/tests/Unit/Support/ScriptedMailboxTest.php b/tests/Unit/Support/ScriptedMailboxTest.php new file mode 100644 index 0000000..917630c --- /dev/null +++ b/tests/Unit/Support/ScriptedMailboxTest.php @@ -0,0 +1,31 @@ +connect(); + $mailbox->connect(); + + expect($mailbox->connection())->toBe($first); + + $watcher = clone $mailbox; + $watcher->connect(); + $mailbox->reconnect(); + + expect($watcher->connection())->toBe($second); + expect($mailbox->connection())->toBe($third); + expect($first->stream()->opened())->toBeFalse(); + + $second->disconnect(); + $third->disconnect(); + + expect(fn () => $mailbox->reconnect())->toThrow(LogicException::class, 'No scripted connections remain.'); +}); diff --git a/tests/Unit/Support/SequenceSetTest.php b/tests/Unit/Support/SequenceSetTest.php new file mode 100644 index 0000000..fb168c4 --- /dev/null +++ b/tests/Unit/Support/SequenceSetTest.php @@ -0,0 +1,73 @@ +toBeInstanceOf(LazyCollection::class); + expect($values->all())->toBe([1, 2, 3, 9, 8, 7, 5, 4294967294, 4294967295]); +}); + +test('large ranges can be read lazily more than once', function () { + $values = SequenceSet::parse('1:4294967295'); + + expect($values->take(3)->all())->toBe([1, 2, 3]); + expect($values->take(3)->all())->toBe([1, 2, 3]); +}); + +test('large descending ranges can be read lazily', function () { + $values = SequenceSet::parse('4294967295:1'); + + expect($values->take(3)->all())->toBe([4294967295, 4294967294, 4294967293]); +}); + +test('set', function () { + expect(SequenceSet::format(5, 10))->toBe('5:10'); + expect(SequenceSet::format('5', '10'))->toBe('5:10'); + expect(SequenceSet::format(5, INF))->toBe('5:*'); + expect(SequenceSet::format([5, 10]))->toBe('5,10'); + expect(SequenceSet::format(['5', '10']))->toBe('5,10'); + expect(SequenceSet::format([5]))->toBe('5'); + expect(SequenceSet::format(5))->toBe('5'); + expect(SequenceSet::format('*'))->toBe('*'); + expect(SequenceSet::format('$'))->toBe('$'); + expect(SequenceSet::format('4294967295'))->toBe('4294967295'); +}); + +test('set rejects invalid sequence sets', function (array|int|string $from, int|float|string|null $to) { + expect(fn () => SequenceSet::format($from, $to))->toThrow(InvalidArgumentException::class); +})->with([ + ['1'."\r\n".'TAG2 LOGOUT', null], + [[1, '2'."\r\n".'TAG2 LOGOUT'], null], + [[1, '2.0', 3], null], + ['1 2', null], + ['1,$', null], + ['1::2', null], + ['1:0', null], + ['0', null], + [0, null], + [-1, null], + ['4294967296', null], + [1, '4294967296'], + [[], null], +]); + +test('set converts consecutive values into sequence ranges', function () { + expect(SequenceSet::format([1, 2, 3, 5, 7, 8, 9]))->toBe('1:3,5,7:9'); + expect(SequenceSet::format([9, 8, 7, 5, 3, 2, 1]))->toBe('9:7,5,3:1'); + expect(SequenceSet::format(range(16902, 15146)))->toBe('16902:15146'); + expect(SequenceSet::format([1, 3, 5]))->toBe('1,3,5'); + expect(SequenceSet::format([1, 2, 3, 8, 7, 6]))->toBe('1:3,8:6'); + expect(SequenceSet::format(['1', '2', '3', '5']))->toBe('1:3,5'); + expect(SequenceSet::format([1, '*']))->toBe('1,*'); +}); + +test('set ignores $to when $from is a single-element array', function () { + expect(SequenceSet::format([5], 10))->toBe('5'); +}); + +test('set ignores $to when $from is a multi-element array', function () { + expect(SequenceSet::format([5, 6], 10))->toBe('5:6'); +}); diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php index 630ccf2..39a27a8 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -1,40 +1,7 @@ toBe('5:10'); - expect(Str::set('5', '10'))->toBe('5:10'); - expect(Str::set(5, INF))->toBe('5:*'); - expect(Str::set([5, 10]))->toBe('5,10'); - expect(Str::set(['5', '10']))->toBe('5,10'); - expect(Str::set([5]))->toBe('5'); - expect(Str::set(5))->toBe('5'); -}); - -test('set converts consecutive values into sequence ranges', function () { - expect(Str::set([1, 2, 3, 5, 7, 8, 9]))->toBe('1:3,5,7:9'); - expect(Str::set([9, 8, 7, 5, 3, 2, 1]))->toBe('9:7,5,3:1'); - expect(Str::set(range(16902, 15146)))->toBe('16902:15146'); - expect(Str::set([1, 3, 5]))->toBe('1,3,5'); - expect(Str::set([1, 2, 3, 8, 7, 6]))->toBe('1:3,8:6'); - expect(Str::set(['1', '2', '3', '5']))->toBe('1:3,5'); - expect(Str::set([1, '*']))->toBe('1,*'); -}); - -test('credentials', function () { - expect(Str::credentials('foo', 'bar'))->toBe('dXNlcj1mb28BYXV0aD1CZWFyZXIgYmFyAQE='); -}); - -test('set ignores $to when $from is a single-element array', function () { - expect(Str::set([5], 10))->toBe('5'); -}); - -test('set ignores $to when $from is a multi-element array', function () { - expect(Str::set([5, 6], 10))->toBe('5:6'); -}); - test('escape removes newlines/control characters and escapes backslashes and double quotes', function () { // Newlines and control characters removed expect(Str::escape("Hello\nWorld"))->toBe('HelloWorld'); @@ -48,72 +15,6 @@ expect(Str::escape('C:\Path\to\file'))->toBe('C:\\\\Path\\\\to\\\\file'); }); -test('literal returns a double-quoted escaped string when no newline is present', function () { - expect(Str::literal('hello'))->toBe('"hello"'); - expect(Str::literal('He said: "Hi"'))->toBe('"He said: \\"Hi\\""'); -}); - -test('literal returns a literal indicator and the original string if it contains a newline', function () { - $input = "hello\nworld"; - $expected = ['{'.strlen($input).'}', $input]; - expect(Str::literal($input))->toBe($expected); -}); - -test('literal handles an array of literals', function () { - expect(Str::literal(['first', 'second']))->toBe(['"first"', '"second"']); -}); - -test('list returns a properly formatted parenthesized list for a flat array', function () { - expect(Str::list(['"a"', '"b"', '"c"']))->toBe('("a" "b" "c")'); -}); - -test('list handles nested arrays recursively', function () { - expect(Str::list(['"a"', ['"b"', '"c"']]))->toBe('("a" ("b" "c"))'); -}); - -test('list returns empty parentheses for an empty array', function () { - expect(Str::list([]))->toBe('()'); -}); - -test('enums returns value for a single backed enum', function () { - $result = Str::enums(ImapFlag::Seen); - - expect($result)->toBe('\Seen'); -}); - -test('enums returns an array of enum values for an array of backed enums', function () { - $result = Str::enums([ImapFlag::Seen, ImapFlag::Draft]); - - expect($result)->toBeArray(); - expect($result)->toEqual(['\Seen', '\Draft']); -}); - -test('enums returns the string when a string is provided', function () { - $input = 'example string'; - - $result = Str::enums($input); - - expect($result)->toBe($input); -}); - -test('enums handles nested arrays containing backed enums and strings', function () { - $input = [ - [ImapFlag::Seen, 'nested string'], - ImapFlag::Draft, - 'another string', - ]; - - $expected = [ - ['\Seen', 'nested string'], - '\Draft', - 'another string', - ]; - - $result = Str::enums($input); - - expect($result)->toEqual($expected); -}); - test('fromImapUtf7 decodes UTF-7 encoded folder names', function () { // Russian Cyrillic example from the bug report. $encoded = '&BBoEPgRABDcEOAQ9BDA-'; diff --git a/tests/Unit/Testing/FakeFolderRepositoryTest.php b/tests/Unit/Testing/FakeFolderRepositoryTest.php index db915a1..499d1ec 100644 --- a/tests/Unit/Testing/FakeFolderRepositoryTest.php +++ b/tests/Unit/Testing/FakeFolderRepositoryTest.php @@ -1,25 +1,29 @@ new FakeFolder('inbox'), 'sent' => new FakeFolder('sent'), ]; - $repository = new FakeFolderRepository($mailbox, $folders); + $repository = new FakeFolderRepository($mailbox, new FolderCollection($folders)); expect($repository)->toBeInstanceOf(FakeFolderRepository::class); }); test('it can find folder by path', function () { - $mailbox = new FakeMailbox; + $mailbox = FakeMailbox::make(); + $inbox = new FakeFolder('inbox'); $sent = new FakeFolder('sent'); @@ -28,7 +32,7 @@ 'sent' => $sent, ]; - $repository = new FakeFolderRepository($mailbox, $folders); + $repository = new FakeFolderRepository($mailbox, new FolderCollection($folders)); expect($repository->find('inbox'))->toBe($inbox); expect($repository->find('sent'))->toBe($sent); @@ -36,15 +40,17 @@ }); test('it throws exception when folder not found with findOrFail', function () { - $mailbox = new FakeMailbox; - $repository = new FakeFolderRepository($mailbox, []); + $mailbox = FakeMailbox::make(); + + $repository = new FakeFolderRepository($mailbox, new FolderCollection([])); $repository->findOrFail('nonexistent'); })->throws(ItemNotFoundException::class); test('it can create new folder', function () { - $mailbox = new FakeMailbox; - $repository = new FakeFolderRepository($mailbox, []); + $mailbox = FakeMailbox::make(); + + $repository = new FakeFolderRepository($mailbox, new FolderCollection([])); $folder = $repository->create('new_folder'); @@ -54,10 +60,11 @@ }); test('it can find or create folder', function () { - $mailbox = new FakeMailbox; + $mailbox = FakeMailbox::make(); + $inbox = new FakeFolder('inbox'); - $repository = new FakeFolderRepository($mailbox, ['inbox' => $inbox]); + $repository = new FakeFolderRepository($mailbox, new FolderCollection(['inbox' => $inbox])); // Should find existing folder $found = $repository->firstOrCreate('inbox'); @@ -70,7 +77,8 @@ }); test('it can get folders with pattern matching', function () { - $mailbox = new FakeMailbox; + $mailbox = FakeMailbox::make(); + $inbox = new FakeFolder('inbox'); $sent = new FakeFolder('sent'); $drafts = new FakeFolder('drafts'); @@ -83,7 +91,7 @@ 'archive' => $archive, ]; - $repository = new FakeFolderRepository($mailbox, $folders); + $repository = new FakeFolderRepository($mailbox, new FolderCollection($folders)); // Get all folders $allFolders = $repository->get(); @@ -99,3 +107,58 @@ $noMatches = $repository->get('nonexistent*'); expect($noMatches)->toBeEmpty(); }); + +test('it resolves special-use folders from the collection', function () { + $mailbox = FakeMailbox::make(); + + $sentByName = new FakeFolder('Sent'); + $sentByAttribute = new FakeFolder('Outgoing', [ImapSpecialUse::Sent->value]); + + $repository = new FakeFolderRepository($mailbox, new FolderCollection([ + $sentByName, + $sentByAttribute, + new FakeFolder('Drafts', [ImapSpecialUse::Drafts->value]), + new FakeFolder('Starred', [ImapSpecialUse::Flagged->value]), + new FakeFolder('Junk Email', [ImapSpecialUse::Junk->value]), + new FakeFolder('Deleted Items', [ImapSpecialUse::Trash->value]), + new FakeFolder('Archive', [ImapSpecialUse::Archive->value]), + new FakeFolder('[Gmail]/All Mail', [ImapSpecialUse::All->value]), + ])); + + $folders = $repository + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent))->toBe($sentByAttribute); + expect($folders->findBySpecialUse(ImapSpecialUse::Drafts)?->name())->toBe('Drafts'); + expect($folders->findBySpecialUse(ImapSpecialUse::Flagged)?->name())->toBe('Starred'); + expect($folders->findBySpecialUse(ImapSpecialUse::Junk)?->name())->toBe('Junk Email'); + expect($folders->findBySpecialUse(ImapSpecialUse::Trash)?->name())->toBe('Deleted Items'); + expect($folders->findBySpecialUse(ImapSpecialUse::Archive)?->name())->toBe('Archive'); + expect($folders->findBySpecialUse(ImapSpecialUse::All)?->name())->toBe('All Mail'); +}); + +test('folder collections share storage while returned snapshots remain independent', function () { + $inbox = new FakeFolder('INBOX'); + $folders = new FolderCollection([$inbox]); + $repository = new FakeFolderRepository(FakeMailbox::make(), $folders); + $snapshot = $repository->get(); + + $archive = $repository->create('Archive'); + + expect($snapshot->all())->toBe([$inbox]); + expect($folders->all())->toBe([$inbox, $archive]); + + $snapshot->pop(); + + expect($repository->get()->all())->toBe([$inbox, $archive]); +}); + +test('created folders persist across mailbox repositories', function () { + $mailbox = FakeMailbox::make(); + $archive = $mailbox->folders()->create('Archive'); + + expect($mailbox->folders()->find('Archive'))->toBe($archive); + expect($mailbox->folders()->firstOrCreate('Archive'))->toBe($archive); + expect($archive->mailbox())->toBe($mailbox); +}); diff --git a/tests/Unit/Testing/FakeFolderTest.php b/tests/Unit/Testing/FakeFolderTest.php index 0a1d1e9..4101b7b 100644 --- a/tests/Unit/Testing/FakeFolderTest.php +++ b/tests/Unit/Testing/FakeFolderTest.php @@ -1,5 +1,6 @@ toBeInstanceOf(FakeFolder::class); expect($folder->path())->toBe('INBOX'); - expect($folder->flags())->toBe(['\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\HasNoChildren']); expect($folder->delimiter())->toBe('/'); }); @@ -31,13 +32,13 @@ }); test('it compares folders correctly', function () { - $mailbox1 = new FakeMailbox(['host' => 'imap.example.com', 'username' => 'user1']); - $mailbox2 = new FakeMailbox(['host' => 'imap.example.com', 'username' => 'user2']); + $mailbox1 = FakeMailbox::make(['host' => 'imap.example.com', 'username' => 'user1']); + $mailbox2 = FakeMailbox::make(['host' => 'imap.example.com', 'username' => 'user2']); - $folder1 = new FakeFolder('INBOX', [], [], '/', $mailbox1); - $folder2 = new FakeFolder('INBOX', [], [], '/', $mailbox1); - $folder3 = new FakeFolder('Sent', [], [], '/', $mailbox1); - $folder4 = new FakeFolder('INBOX', [], [], '/', $mailbox2); + $folder1 = new FakeFolder('INBOX', [], new MessageCollection([]), '/', $mailbox1); + $folder2 = new FakeFolder('INBOX', [], new MessageCollection([]), '/', $mailbox1); + $folder3 = new FakeFolder('Sent', [], new MessageCollection([]), '/', $mailbox1); + $folder4 = new FakeFolder('INBOX', [], new MessageCollection([]), '/', $mailbox2); expect($folder1->is($folder2))->toBeTrue(); expect($folder1->is($folder3))->toBeFalse(); // Different path @@ -45,7 +46,7 @@ }); test('it returns message query', function () { - $folder = new FakeFolder('INBOX', [], [new FakeMessage(1)]); + $folder = new FakeFolder('INBOX', [], new MessageCollection([new FakeMessage(1)])); $query = $folder->messages(); @@ -61,18 +62,18 @@ expect($folder->path())->toBe('Sent'); }); -test('it can set flags', function () { +test('it can set attributes', function () { $folder = new FakeFolder('INBOX'); - $folder->setFlags(['\\Seen', '\\HasNoChildren']); + $folder->setAttributes(['\\Seen', '\\HasNoChildren']); - expect($folder->flags())->toBe(['\\Seen', '\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\Seen', '\\HasNoChildren']); }); test('it can set mailbox', function () { $folder = new FakeFolder('INBOX'); - $mailbox = new FakeMailbox(['host' => 'imap.example.com']); + $mailbox = FakeMailbox::make(['host' => 'imap.example.com']); $folder->setMailbox($mailbox); @@ -82,10 +83,10 @@ test('it can set messages', function () { $folder = new FakeFolder('INBOX'); - $folder->setMessages([ + $folder->setMessages(new MessageCollection([ new FakeMessage(1), new FakeMessage(2), - ]); + ])); expect($folder->messages()->count())->toBe(2); }); @@ -99,11 +100,11 @@ }); test('it can query messages from a fake mailbox folder', function () { - $folder = new FakeFolder('inbox', ['\\HasNoChildren'], [ + $folder = new FakeFolder('inbox', ['\\HasNoChildren'], new MessageCollection([ new FakeMessage(1, [''], 'Message 1'), new FakeMessage(2, [''], 'Message 2'), new FakeMessage(3, ['\\Seen'], 'Message 3'), - ]); + ])); // These should all have the same count because // no filtering should actually take place @@ -128,3 +129,42 @@ ], ]); }); + +test('fake examination invalidates the previous selection', function (string $path) { + $mailbox = FakeMailbox::make(); + + $inbox = new FakeFolder('INBOX', mailbox: $mailbox); + $examined = new FakeFolder($path, mailbox: $mailbox); + + $inbox->select(); + expect($mailbox->selected($inbox))->toBeTrue(); + expect($examined->examine())->toBe([]); + expect($mailbox->selected($inbox))->toBeFalse(); + expect($mailbox->selected($examined))->toBeFalse(); + + $inbox->select(); + expect($mailbox->selected($inbox))->toBeTrue(); +})->with(['Archive', 'INBOX']); + +test('message collections remain independent of inputs snapshots and cloned folders', function () { + $first = new FakeMessage(1); + $second = new FakeMessage(2); + $messages = new MessageCollection([$first]); + $folder = new FakeFolder('INBOX', messages: $messages); + $messages->pop(); + + $snapshot = $folder->getMessages(); + $clone = clone $folder; + $clone->addMessage($second); + $snapshot->pop(); + + expect($folder->getMessages()->all())->toBe([$first]); + expect($clone->getMessages()->all())->toBe([$first, $second]); + + $replacement = new MessageCollection([$second]); + $folder->setMessages($replacement); + $replacement->pop(); + + expect($folder->getMessages()->all())->toBe([$second]); + expect($folder->nextUid())->toBe(3); +}); diff --git a/tests/Unit/Testing/FakeMailboxTest.php b/tests/Unit/Testing/FakeMailboxTest.php index c257505..30d47b2 100644 --- a/tests/Unit/Testing/FakeMailboxTest.php +++ b/tests/Unit/Testing/FakeMailboxTest.php @@ -1,11 +1,14 @@ 'imap.example.com', 'username' => 'user1'], [new FakeFolder('inbox')], ['IMAP4rev1', 'STARTTLS'] @@ -14,11 +17,13 @@ expect($mailbox)->toBeInstanceOf(FakeMailbox::class); expect($mailbox->config('host'))->toBe('imap.example.com'); expect($mailbox->config('username'))->toBe('user1'); - expect($mailbox->capabilities())->toBe(['IMAP4rev1', 'STARTTLS']); + expect($mailbox->capabilities()->all())->toBe(['IMAP4REV1', 'STARTTLS']); + expect($mailbox->capabilities()->supports('imap4rev1'))->toBeTrue(); + expect($mailbox->capabilities()->supports('START'))->toBeFalse(); }); test('it returns config values correctly', function () { - $mailbox = new FakeMailbox([ + $mailbox = FakeMailbox::make([ 'host' => 'imap.example.com', 'port' => 993, 'encryption' => 'ssl', @@ -35,28 +40,115 @@ ]); }); +test('it reconnects while updating only the password', function (?string $password, string $expectedPassword) { + $folder = new FakeFolder('inbox'); + $config = ['host' => 'imap.example.com', 'username' => 'foo', 'password' => 'old-password']; + $mailbox = FakeMailbox::make($config, [$folder]); + $mailbox->select($folder); + + $mailbox->reconnect(password: $password); + + expect($mailbox->config())->toBe([...$config, 'password' => $expectedPassword]); + expect($mailbox->selected($folder))->toBeFalse(); + expect($folder->mailbox())->toBe($mailbox); + expect($mailbox->inbox())->toBe($folder); + + $mailbox->reconnect(); + + expect($mailbox->config('password'))->toBe($expectedPassword); +})->with([ + 'unchanged password' => [null, 'old-password'], + 'replacement password' => ['new-password', 'new-password'], + 'empty password' => ['', ''], +]); + test('it is always connected', function () { - $mailbox = new FakeMailbox; + $mailbox = FakeMailbox::make(); expect($mailbox->connected())->toBeTrue(); }); +test('it tracks enabled capabilities until reconnection', function () { + $mailbox = FakeMailbox::make(capabilities: ['QRESYNC']); + $capabilities = $mailbox->capabilities(); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + + $mailbox->enable('qresync'); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + expect($capabilities->enabled('QRESYNC'))->toBeFalse(); + + $mailbox->reconnect(); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue(); +}); + +test('it resets connection state when disconnected', function () { + $folder = new FakeFolder('inbox'); + $mailbox = FakeMailbox::make(folders: [$folder], capabilities: ['QRESYNC']); + + $mailbox->enable('QRESYNC'); + $mailbox->select($folder); + $mailbox->disconnect(); + + expect($mailbox->selected($folder))->toBeFalse() + ->and($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue() + ->and($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); +}); + +test('it resets connection state when cloned', function () { + $folder = new FakeFolder('inbox'); + $mailbox = FakeMailbox::make(folders: [$folder], capabilities: ['QRESYNC']); + + $mailbox->enable('QRESYNC'); + $mailbox->select($folder); + + $clone = clone $mailbox; + + expect($clone->selected($folder))->toBeFalse() + ->and($clone->capabilities()->supports('QRESYNC'))->toBeTrue() + ->and($clone->capabilities()->enabled('QRESYNC'))->toBeFalse() + ->and($mailbox->selected($folder))->toBeTrue() + ->and($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); +}); + +test('it rejects enabling unsupported capabilities', function () { + $mailbox = FakeMailbox::make(capabilities: ['QRESYNC']); + + expect(fn () => $mailbox->enable('CONDSTORE'))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capability [CONDSTORE]. IMAP server does not support it.', + ); +}); + +test('it requires an exact advertised capability to enable', function () { + $mailbox = FakeMailbox::make(capabilities: ['AUTH=XOAUTH2']); + + expect($mailbox->capabilities()->supports('AUTH'))->toBeTrue(); + expect(fn () => $mailbox->enable('AUTH'))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capability [AUTH]. IMAP server does not support it.', + ); +}); + test('it returns folder repository', function () { - $mailbox = new FakeMailbox; + $mailbox = FakeMailbox::make(); expect($mailbox->folders())->toBeInstanceOf(FakeFolderRepository::class); }); test('it can access inbox folder', function () { $inbox = new FakeFolder('inbox'); - $mailbox = new FakeMailbox(folders: [$inbox]); + $mailbox = FakeMailbox::make(folders: [$inbox]); expect($mailbox->inbox())->toBe($inbox); }); test('it can select and check selected folders', function () { $folder = new FakeFolder('inbox'); - $mailbox = new FakeMailbox(folders: [$folder]); + $mailbox = FakeMailbox::make(folders: [$folder]); expect($mailbox->selected($folder))->toBeFalse(); @@ -64,3 +156,76 @@ expect($mailbox->selected($folder))->toBeTrue(); }); + +test('it rejects selection options for unsupported capabilities', function () { + $folder = new FakeFolder('inbox'); + $mailbox = FakeMailbox::make(folders: [$folder]); + + expect(fn () => $mailbox->select($folder, options: new CondStore))->toThrow( + ImapCapabilityException::class, + 'Unable to select folder with [CONDSTORE]. IMAP server does not support it.', + ); + + expect($mailbox->selected($folder))->toBeFalse(); +}); + +test('it enables selection options that require enablement', function () { + $folder = new FakeFolder('inbox'); + $mailbox = FakeMailbox::make(folders: [$folder], capabilities: ['QRESYNC']); + + $mailbox->select($folder, options: new QuickResync(777, 42)); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + expect($mailbox->selected($folder))->toBeTrue(); +}); + +test('it cannot enable selection options after selecting a folder', function () { + $inbox = new FakeFolder('inbox'); + $archive = new FakeFolder('archive'); + $mailbox = FakeMailbox::make(folders: [$inbox, $archive], capabilities: ['QRESYNC']); + + $mailbox->select($inbox); + + expect(fn () => $mailbox->select($archive, options: new QuickResync(777, 42)))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.', + ); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->selected($inbox))->toBeTrue(); + expect($mailbox->selected($archive))->toBeFalse(); +}); + +test('it cannot enable selection options after examining a folder', function () { + $inbox = new FakeFolder('inbox'); + $archive = new FakeFolder('archive'); + $mailbox = FakeMailbox::make(folders: [$inbox, $archive], capabilities: ['QRESYNC']); + + $mailbox->examine($inbox); + + expect($mailbox->selected($inbox))->toBeFalse(); + expect(fn () => $mailbox->select($archive, options: new QuickResync(777, 42)))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capabilities while a folder is selected or examined. Reconnect before enabling them.', + ); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->selected($archive))->toBeFalse(); +}); + +test('cloned folders belong to and select the cloned mailbox', function () { + $mailbox = FakeMailbox::make(folders: [new FakeFolder('inbox')], capabilities: ['QRESYNC']); + $clone = clone $mailbox; + + $clone->inbox()->select(); + + expect($clone->inbox())->not->toBe($mailbox->inbox()) + ->and($clone->inbox()->mailbox())->toBe($clone) + ->and($mailbox->inbox()->mailbox())->toBe($mailbox) + ->and($clone->selected($clone->inbox()))->toBeTrue() + ->and($mailbox->selected($mailbox->inbox()))->toBeFalse(); + + $mailbox->enable('QRESYNC'); + + expect($clone->capabilities()->enabled('QRESYNC'))->toBeFalse(); +}); diff --git a/tests/Unit/Testing/FakeMessageQueryTest.php b/tests/Unit/Testing/FakeMessageQueryTest.php index b0e4009..217561c 100644 --- a/tests/Unit/Testing/FakeMessageQueryTest.php +++ b/tests/Unit/Testing/FakeMessageQueryTest.php @@ -1,27 +1,43 @@ toBeInstanceOf(FakeMessageQuery::class); }); +test('it forwards compiled search tokens from the query builder', function () { + $query = new FakeMessageQuery(new FakeFolder('INBOX')); + + expect($query->subject('café')->toTokens())->toBe([ + 'SUBJECT', ['{5}', 'café'], + ]); +}); + test('it returns message collection', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), - ]); + ])); $query = new FakeMessageQuery($folder); $collection = $query->get(); @@ -30,12 +46,54 @@ expect($collection)->toHaveCount(2); }); +test('it orders messages by uid', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ + 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 MessageCollection([ + 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: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), new FakeMessage(3), - ]); + ])); $query = new FakeMessageQuery($folder); @@ -43,17 +101,17 @@ }); test('it returns first message', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), - ]); + ])); $query = new FakeMessageQuery($folder); $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 () { @@ -74,35 +132,47 @@ $folder = new FakeFolder('INBOX'); $query = new FakeMessageQuery($folder); - $uid1 = $query->append('First message'); - expect($uid1)->toBe(1); + $result1 = $query->append('First message'); + expect($result1->uid())->toBe(1); - $uid2 = $query->append('Second message'); - expect($uid2)->toBe(2); + $result2 = $query->append('Second message'); + expect($result2->uid())->toBe(2); - $uid3 = $query->append('Third message'); - expect($uid3)->toBe(3); + $result3 = $query->append('Third message'); + expect($result3->uid())->toBe(3); expect($query->count())->toBe(3); }); test('it continues auto-incrementing from last message uid', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(5), - ]); + ])); $query = new FakeMessageQuery($folder); - $uid = $query->append('New message'); - expect($uid)->toBe(6); + $result = $query->append('New message'); + expect($result->uid())->toBe(6); +}); + +test('it does not reuse vanished message uids when appending', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ + new FakeMessage(5), + ])); + + $folder->vanish(uid: 5, modSequence: 42); + + $query = new FakeMessageQuery($folder); + + expect($query->append('New message')->uid())->toBe(6); }); test('it can find message by uid', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), new FakeMessage(3), - ]); + ])); $query = new FakeMessageQuery($folder); @@ -112,6 +182,20 @@ expect($message->uid())->toBe(2); }); +test('it can find a message by message number', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ + new FakeMessage(30), + new FakeMessage(10), + new FakeMessage(20), + ])); + + $query = new FakeMessageQuery($folder); + + expect($query->find(2, ImapIdentifier::MessageNumber)?->uid())->toBe(20); + expect($query->findOrFail(3, ImapIdentifier::MessageNumber)->uid())->toBe(30); + expect($query->find(4, ImapIdentifier::MessageNumber))->toBeNull(); +}); + test('it returns null when message not found', function () { $folder = new FakeFolder('INBOX', [ new FakeMessage(1), @@ -124,10 +208,10 @@ }); test('it throws exception when message not found with findOrFail', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), - ]); + ])); $query = new FakeMessageQuery($folder); @@ -135,11 +219,11 @@ })->throws(ItemNotFoundException::class); test('it can destroy messages by uid', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), new FakeMessage(3), - ]); + ])); $query = new FakeMessageQuery($folder); @@ -154,12 +238,12 @@ }); test('it can destroy multiple messages', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1), new FakeMessage(2), new FakeMessage(3), new FakeMessage(4), - ]); + ])); $query = new FakeMessageQuery($folder); @@ -175,13 +259,13 @@ }); test('each breaks when callback returns false', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1, [], 'Message 1'), new FakeMessage(2, [], 'Message 2'), new FakeMessage(3, [], 'Message 3'), new FakeMessage(4, [], 'Message 4'), new FakeMessage(5, [], 'Message 5'), - ]); + ])); $query = new FakeMessageQuery($folder); $processedUids = []; @@ -195,18 +279,18 @@ } }, 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 () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1, [], 'Message 1'), new FakeMessage(2, [], 'Message 2'), new FakeMessage(3, [], 'Message 3'), new FakeMessage(4, [], 'Message 4'), new FakeMessage(5, [], 'Message 5'), - ]); + ])); $query = new FakeMessageQuery($folder); $processedChunks = []; @@ -225,11 +309,11 @@ }); test('each processes all messages when callback never returns false', function () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1, [], 'Message 1'), new FakeMessage(2, [], 'Message 2'), new FakeMessage(3, [], 'Message 3'), - ]); + ])); $query = new FakeMessageQuery($folder); $processedUids = []; @@ -240,17 +324,17 @@ }); // 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 () { - $folder = new FakeFolder('INBOX', messages: [ + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ new FakeMessage(1, [], 'Message 1'), new FakeMessage(2, [], 'Message 2'), new FakeMessage(3, [], 'Message 3'), new FakeMessage(4, [], 'Message 4'), new FakeMessage(5, [], 'Message 5'), - ]); + ])); $query = new FakeMessageQuery($folder); $processedChunks = []; @@ -263,3 +347,91 @@ // Should process all chunks (1, 2, 3) expect($processedChunks)->toBe([1, 2, 3]); }); + +test('fake synchronization also returns no changes for an empty uid set', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([new FakeMessage(7)])); + + $result = $folder->messages()->changesSince(0, [], vanished: true); + + expect($result->messages())->toBeEmpty(); + expect($result->vanishedUids()->all())->toBe([]); +}); + +test('fake synchronization rejects negative checkpoints for a non-empty uid set', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([new FakeMessage(7)])); + + expect(fn () => $folder->messages()->changesSince(-1, [7])) + ->toThrow(InvalidArgumentException::class); +}); + +test('fake synchronization returns messages that vanished after the checkpoint', function () { + $folder = new FakeFolder( + 'INBOX', + messages: new MessageCollection([new FakeMessage(3), new FakeMessage(4), new FakeMessage(6), new FakeMessage(7)]), + ); + + $folder + ->vanish(uid: 3, modSequence: 43) + ->vanish(uid: 4, modSequence: 42) + ->vanish(uid: 6, modSequence: 44); + + $mailbox = FakeMailbox::make(folders: [$folder], capabilities: ['QRESYNC']); + + $mailbox->select($folder, options: new QuickResync(777, 42)); + + $changes = $folder->messages()->changesSince(42, [3, 4, 6, 7], vanished: true); + $laterChanges = $folder->messages()->changesSince(43, [3, 4, 6, 7], vanished: true); + $withoutVanished = $folder->messages()->changesSince(42, [3, 4, 6, 7]); + + expect($changes->vanishedUids()->all())->toBe([3, 6]); + expect($changes->vanished())->toHaveCount(1); + expect($changes->vanished()[0]->earlier())->toBeTrue(); + expect($laterChanges->vanishedUids()->all())->toBe([6]); + expect($withoutVanished->vanishedUids()->all())->toBe([]); + expect($folder->messages()->get()->map(fn (FakeMessage $message) => $message->uid())->all())->toBe([7]); +}); + +test('fake synchronization returns only the configured fetch items', function () { + $bodyStructure = new BodyStructureCollection; + + $folder = new FakeFolder('INBOX', messages: new MessageCollection([ + new FakeMessage( + 7, + flags: ['\\Seen'], + size: 123, + bodyStructure: $bodyStructure, + modSequence: 43, + ), + ])); + + FakeMailbox::make(folders: [$folder], capabilities: ['CONDSTORE']); + + $message = $folder->messages() + ->only(MessageData::size(), MessageData::bodyStructure()) + ->changesSince(42, [7]) + ->messages()[0]; + + expect($message->uid())->toBe(7) + ->and($message->modSequence())->toBe(43) + ->and($message->size())->toBe(123) + ->and($message->get('BODYSTRUCTURE'))->toBe($bodyStructure) + ->and($message->has('FLAGS'))->toBeFalse(); +}); + +test('fake synchronization requires the advertised capability', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([new FakeMessage(7)])); + + FakeMailbox::make(folders: [$folder]); + + expect(fn () => $folder->messages()->changesSince(42, [7])) + ->toThrow(ImapCapabilityException::class); +}); + +test('fake vanished synchronization requires qresync to be enabled', function () { + $folder = new FakeFolder('INBOX', messages: new MessageCollection([new FakeMessage(7)])); + + FakeMailbox::make(folders: [$folder], capabilities: ['QRESYNC']); + + expect(fn () => $folder->messages()->changesSince(42, [7], vanished: true)) + ->toThrow(ImapCapabilityException::class); +}); diff --git a/tests/Unit/WatchTest.php b/tests/Unit/WatchTest.php new file mode 100644 index 0000000..1acbff8 --- /dev/null +++ b/tests/Unit/WatchTest.php @@ -0,0 +1,231 @@ +open(); + $stream->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* 1 EXISTS', '* OK [UIDVALIDITY 777]', 'TAG3 OK SELECT completed', + '+ idling', '* 4 EXISTS', '* 2 FETCH (FLAGS (\\Seen))', '* 3 EXPUNGE', + ]); + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $received = []; + + (new Watch($mailbox, 'INBOX', 30))->await(function (EventInterface $event) use (&$received) { + $received[] = $event; + + return ! $event instanceof MessageExpunged; + }); + + expect(array_map(fn (EventInterface $event) => $event::class, $received))->toBe([ + FolderSelected::class, MessagesExist::class, MessageFetched::class, MessageExpunged::class, + ]); + expect($received[0]->selection()->uidValidity())->toBe(777); + expect($received[1]->count())->toBe(4); + expect($stream->opened())->toBeFalse(); + $stream->assertNotWritten('FETCH'); + $stream->assertNotWritten('LOGOUT'); +}); + +test('callback connection exceptions propagate instead of causing a reconnect', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + 'TAG3 OK SELECT completed', '+ idling', '* 4 EXISTS', + ]); + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $exception = new ImapConnectionClosedException('Application callback failed'); + + expect(fn () => (new Watch($mailbox, 'INBOX', 30))->await(function (EventInterface $event) use ($exception) { + if ($event instanceof MessagesExist) { + throw $exception; + } + }))->toThrow($exception); + + expect($stream->opened())->toBeFalse(); +}); + +test('watcher emits a fresh selection after reconnecting', function () { + $first = new FakeStream; + $first->open(); + $first->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + '* OK [UIDVALIDITY 777]', 'TAG3 OK SELECT completed', + '+ idling', '* BYE Restarting', + ]); + $second = new FakeStream; + $second->open(); + $second->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* OK [UIDVALIDITY 888]', 'TAG2 OK SELECT completed', + ]); + $mailbox = new ScriptedMailbox(new ImapConnection($first), new ImapConnection($second)); + $validities = []; + + (new Watch($mailbox, 'INBOX', 30))->await(function (EventInterface $event) use (&$validities) { + expect($event)->toBeInstanceOf(FolderSelected::class); + $validities[] = $event->selection()->uidValidity(); + + return count($validities) < 2; + }); + + expect($validities)->toBe([777, 888]); + expect($first->opened())->toBeFalse(); + expect($second->opened())->toBeFalse(); + $second->assertWritten('TAG2 SELECT "INBOX"'); + $second->assertNotWritten('LIST'); +}); + +test('watcher does not resolve the folder when stopped before starting', function (bool $callable) { + $mailbox = new ScriptedMailbox; + $received = []; + $timeout = $callable ? fn () => false : 0; + + (new Watch($mailbox, 'INBOX', $timeout))->await(function (EventInterface $event) use (&$received) { + $received[] = $event; + }); + + expect($received)->toBeEmpty(); +})->with([ + 'zero interval' => false, + 'stopped callback' => true, +]); + +test('watcher honors renewal intervals longer than 29 minutes', function () { + Carbon::setTestNow('2026-09-27 12:00:00'); + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + 'TAG3 OK SELECT completed', + '+ idling', '* 4 EXISTS', '* 5 EXISTS', '* 3 EXPUNGE', 'TAG4 OK IDLE completed', + '+ idling', '* 6 EXISTS', + ]); + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $received = []; + + try { + (new Watch($mailbox, 'INBOX', 3600))->await(function (EventInterface $event) use ($stream, &$received) { + $received[] = $event::class; + + if ($event instanceof MessagesExist && $event->count() === 4) { + Carbon::setTestNow(Carbon::now()->addMinutes(30)); + } + + if ($event instanceof MessagesExist && $event->count() === 5) { + $stream->assertNotWritten('DONE'); + Carbon::setTestNow(Carbon::now()->addMinutes(31)); + } + + return ! ($event instanceof MessagesExist && $event->count() === 6); + }); + } finally { + Carbon::setTestNow(); + } + + expect($received)->toBe([ + FolderSelected::class, MessagesExist::class, MessagesExist::class, + MessageExpunged::class, MessagesExist::class, + ]); + $stream->assertWritten('DONE'); + $stream->assertWritten('TAG5 IDLE'); +}); + +test('watcher reads the mailbox timeout when finishing idle', function () { + Carbon::setTestNow('2026-09-27 12:00:00'); + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + 'TAG3 OK SELECT completed', + '+ idling', '* 4 EXISTS', '* 3 EXPUNGE', 'TAG4 OK IDLE completed', + ]); + $mailbox = new class extends Mailbox + { + public int $timeout = 5; + + public function config(?string $key = null, mixed $default = null): mixed + { + return $key === 'timeout' ? $this->timeout : parent::config($key, $default); + } + }; + $completionTimeout = null; + + try { + $mailbox->connect(new ImapConnection($stream)); + + (new Watch($mailbox, 'INBOX', 30))->await(function (EventInterface $event) use ($mailbox, $stream, &$completionTimeout) { + if ($event instanceof MessagesExist) { + $mailbox->timeout = 90; + Carbon::setTestNow(Carbon::now()->addSeconds(31)); + } + + if ($event instanceof MessageExpunged) { + $completionTimeout = $stream->timeout(); + + return false; + } + }); + + expect($completionTimeout)->toBe(90); + $stream->assertWritten('DONE'); + } finally { + $mailbox->disconnect(); + Carbon::setTestNow(); + } +}); + +test('watcher renews busy idle sessions and preserves queued updates', function () { + Carbon::setTestNow('2026-09-27 12:00:00'); + $stream = new FakeStream; + $stream->open(); + $stream->feed([ + '* OK Welcome', 'TAG1 OK LOGIN completed', + '* LIST () "/" "INBOX"', 'TAG2 OK LIST completed', + 'TAG3 OK SELECT completed', + '+ idling', '* 4 EXISTS', '* 3 EXPUNGE', 'TAG4 OK IDLE completed', + '+ idling', '* 5 EXISTS', + ]); + $mailbox = new Mailbox; + $mailbox->connect(new ImapConnection($stream)); + $received = []; + + try { + (new Watch($mailbox, 'INBOX', 30))->await(function (EventInterface $event) use (&$received) { + $received[] = $event::class; + + if ($event instanceof MessagesExist && $event->count() === 4) { + Carbon::setTestNow(Carbon::now()->addSeconds(31)); + } + + return ! ($event instanceof MessagesExist && $event->count() === 5); + }); + } finally { + Carbon::setTestNow(); + } + + expect($received)->toBe([FolderSelected::class, MessagesExist::class, MessageExpunged::class, MessagesExist::class]); + $stream->assertWritten('DONE'); + $stream->assertWritten('TAG5 IDLE'); +}); diff --git a/tests/docker/dovecot.conf b/tests/docker/dovecot.conf new file mode 100644 index 0000000..4ed8f5b --- /dev/null +++ b/tests/docker/dovecot.conf @@ -0,0 +1,2 @@ +# Plaintext login is only for this isolated, loopback-bound test server. +auth_allow_cleartext = yes diff --git a/tests/wait-for-mailbox.php b/tests/wait-for-mailbox.php new file mode 100644 index 0000000..0257d7f --- /dev/null +++ b/tests/wait-for-mailbox.php @@ -0,0 +1,31 @@ + getenv('MAILBOX_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('MAILBOX_PORT') ?: 3143), + 'username' => getenv('MAILBOX_USERNAME') ?: 'user@localhost.com', + 'password' => getenv('MAILBOX_PASSWORD') ?: 'password', + 'encryption' => getenv('MAILBOX_ENCRYPTION') ?: null, + 'timeout' => 1, +]); + +for ($attempt = 1; $attempt <= 30; $attempt++) { + try { + $mailbox->connect(); + $mailbox->disconnect(); + + exit(0); + } catch (Exception $exception) { + $mailbox->disconnect(); + + if ($attempt === 30) { + throw $exception; + } + + sleep(1); + } +}