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);
+ }
+}