diff --git a/src/Authentication.php b/src/Authentication.php new file mode 100644 index 0000000..868d68d --- /dev/null +++ b/src/Authentication.php @@ -0,0 +1,51 @@ +authenticator->initial(); + $sent = $initial && $response !== null; + + $exchange = $this->connection->authenticate( + $this->authenticator->mechanism(), + $sent ? $response : null, + ); + + foreach ($exchange as $challenge) { + try { + if (! $sent && $response !== null) { + $answer = $response; + $sent = true; + } else { + $answer = $this->authenticator->respond($challenge); + } + } catch (Throwable $e) { + $this->connection->disconnect(); + + throw $e; + } + + $this->connection->respond($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 = []; + + /** + * Create a capability collection from the given values. + */ + public static function from(iterable $capabilities): static + { + $instance = new static; + + foreach ($capabilities as $capability) { + $item = new Capability($capability); + + $instance->items[$item->name()] = $item; + } + + return $instance; + } + + /** + * Get all supported capabilities. + */ + public function all(): array + { + return array_keys($this->items); + } + + /** + * 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; + } + + /** + * Mark the given capabilities as enabled. + */ + public function enable(string ...$capabilities): void + { + foreach ($capabilities as $capability) { + ($this->items[strtoupper($capability)] ?? null)?->enable(); + } + } + + /** + * 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..f5af594 --- /dev/null +++ b/src/Capability.php @@ -0,0 +1,53 @@ +name = strtoupper($name); + } + + /** + * Get the capability name. + */ + public function name(): string + { + return $this->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; + } + + /** + * Enable the capability. + */ + public function enable(): static + { + $this->enabled = true; + + return $this; + } +} diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 32ec711..d75282f 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -7,8 +7,14 @@ use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +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 @@ -51,11 +57,19 @@ public function logout(): void; /** * Send an "AUTHENTICATE" command. * - * Authenticate the current session. + * Authenticate using a SASL mechanism. Initial data requires SASL-IR support. * + * @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; + + /** + * Respond to the current authentication challenge. + */ + public function respond(?string $response): void; /** * Send a "STARTTLS" command. @@ -74,7 +88,7 @@ public function startTls(): void; public function idle(int $timeout): Generator; /** - * Send a "DONE" command. + * Send the DONE continuation to finish the current IDLE command. * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13 */ @@ -88,13 +102,20 @@ public function done(): void; public function noop(): TaggedResponse; /** - * Send a "EXPUNGE" command. + * Send an "ENABLE" command. + * + * @see https://datatracker.ietf.org/doc/html/rfc5161 + */ + public function enable(string ...$capabilities): ResponseCollection; + + /** + * 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(array|int|null $uids = null): ResponseCollection; + public function expunge(array|int|string|null $uids = null): ResponseCollection; /** * Send a "CAPABILITY" command. @@ -108,101 +129,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(ImapSort $sort, 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. - * - * 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. + * @param array|null $parameters * - * 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. @@ -216,7 +179,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. @@ -225,16 +188,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 = '*', array $return = []): ResponseCollection; + public function list(string $reference = '', array|string $pattern = '*', array $selection = [], array $return = []): ResponseCollection; /** * Send a "STATUS" command. @@ -243,16 +209,17 @@ public function list(string $reference = '', string $folder = '*', array $return * * @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. @@ -264,22 +231,22 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st 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. @@ -333,7 +300,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. @@ -342,5 +309,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/ImapConnection.php b/src/Connection/ImapConnection.php index 7080f66..c36cd8d 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -8,21 +8,27 @@ 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\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\Fetch\ModifierInterface as FetchModifierInterface; +use DirectoryTree\ImapEngine\FetchedMessageData; +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\Str; use Exception; use Generator; @@ -194,19 +200,52 @@ public function login(string $user, string $password): TaggedResponse */ public function logout(): void { - $this->send('LOGOUT', tag: $tag); + 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 = [$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; + } + + yield base64_decode(trim(substr((string) $response, 1))); + } + + throw new ImapResponseException('No authentication response found'); + } + + /** + * {@inheritDoc} + */ + public function respond(?string $response): void + { + $this->write($response === null ? '*' : base64_encode($response), sensitive: true); } /** @@ -224,39 +263,62 @@ public function startTls(): void /** * {@inheritDoc} */ - public function select(string $folder = 'INBOX'): ResponseCollection + public function enable(string ...$capabilities): ResponseCollection { - return $this->examineOrSelect('SELECT', $folder); + $this->send('ENABLE', $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 = [Str::literal($folder)]; + + if ($options) { + $tokens[] = Str::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 { $this->send('STATUS', [ Str::literal($folder), - Str::list($arguments), + Str::list($items), ], $tag); $this->assertTaggedResponse($tag); @@ -323,7 +385,7 @@ 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); @@ -337,23 +399,27 @@ 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->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 = '*', array $return = []): ResponseCollection + public function list(string $reference = '', array|string $pattern = '*', array $selection = [], array $return = []): ResponseCollection { - $tokens = Str::literal([$reference, $folder]); + $tokens = $selection ? [Str::list($selection)] : []; + + $tokens[] = Str::literal($reference); + + array_push($tokens, ...(is_array($pattern) ? Str::literalList($pattern) : [Str::literal($pattern)])); if ($return) { $tokens[] = 'RETURN'; @@ -364,9 +430,7 @@ public function list(string $reference = '', string $folder = '*', array $return $this->assertTaggedResponse($tag); - return $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('LIST') - ); + return $this->result->responses()->untagged(); } /** @@ -386,7 +450,7 @@ public function append(string $folder, string $message, ?array $flags = null, ?D $tokens[] = Str::literal($date->format('d-M-Y H:i:s O')); } - $tokens[] = Str::literal($message); + $tokens[] = ['{'.strlen($message).'}', $message]; $this->send('APPEND', $tokens, tag: $tag); @@ -398,10 +462,10 @@ public function append(string $folder, string $message, ?array $flags = null, ?D /** * {@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), + $this->send($identifier === ImapIdentifier::Uid ? 'UID COPY' : 'COPY', [ + Str::set($set), Str::literal($folder), ], $tag); @@ -411,10 +475,10 @@ 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), + $this->send($identifier === ImapIdentifier::Uid ? 'UID MOVE' : 'MOVE', [ + Str::set($set), Str::literal($folder), ], $tag); @@ -424,87 +488,44 @@ 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); + $tokens = [Str::set($set)]; - $flags = Str::list((array) $flags); - - $item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : ''); + if ($modifiers) { + $tokens[] = Str::list(array_map( + fn (StoreModifierInterface $modifier) => $modifier->toImap(), + $modifiers, + )); + } - $this->send('UID STORE', [$set, $item, $flags], tag: $tag); + $tokens[] = $mode.'FLAGS'.($silent ? '.SILENT' : ''); + $tokens[] = Str::list((array) $flags); - $this->assertTaggedResponse($tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID STORE' : 'STORE', $tokens, $tag); - return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('FETCH') + $response = $this->taggedResponse($tag); + $result = StoreResult::fromResponses( + $this->result->responses(), + $response, + fn (FetchedMessageData $data, UntaggedResponse $response) => $this->matchesMessageSet($data, $response, $tokens[0], $identifier), ); - } - - /** - * {@inheritDoc} - */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection - { - return $this->fetch(['UID'], (array) $ids, null, $identifier); - } - /** - * {@inheritDoc} - */ - public function bodyText(int|array $ids, bool $peek = true): ResponseCollection - { - return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids); - } - - /** - * {@inheritDoc} - */ - public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection - { - return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids); - } - - /** - * Fetch the BODYSTRUCTURE for the given message(s). - */ - public function bodyStructure(int|array $ids): ResponseCollection - { - return $this->fetch(['BODYSTRUCTURE'], (array) $ids); - } - - /** - * 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]"; - - return $this->fetch([$part], (array) $ids); - } + if ($response->status()->is('BAD') || ($response->failed() && empty($result->modified()))) { + throw ImapCommandException::make($this->result->command(), $response); + } - /** - * {@inheritDoc} - */ - public function flags(int|array $ids): ResponseCollection - { - return $this->fetch(['FLAGS'], (array) $ids); + return $result; } /** * {@inheritDoc} */ - public function size(int|array $ids): ResponseCollection + public function search(array $criteria, ?string $charset = null, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { - return $this->fetch(['RFC822.SIZE'], (array) $ids); - } + $tokens = $charset === null ? $criteria : ['CHARSET', Str::literal($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); @@ -516,9 +537,9 @@ public function search(array $params): UntaggedResponse /** * {@inheritDoc} */ - public function sort(ImapSort $sort, array $params): UntaggedResponse + public function sort(ImapSort $sort, array $criteria, string $charset = 'UTF-8', ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { - $this->send('UID SORT', ["({$sort->toImap()})", 'UTF-8', ...$params], tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", $charset, ...$criteria], tag: $tag); $this->assertTaggedResponse($tag); @@ -544,21 +565,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'] : Str::literalList($values), tag: $tag); $this->assertTaggedResponse($tag); @@ -570,7 +586,7 @@ public function id(?array $ids = null): UntaggedResponse /** * {@inheritDoc} */ - public function expunge(array|int|null $uids = null): ResponseCollection + public function expunge(array|int|string|null $uids = null): ResponseCollection { $this->send( $uids === null ? 'EXPUNGE' : 'UID EXPUNGE', @@ -620,7 +636,7 @@ public function done(): void { $this->write('DONE'); - // After issuing a "DONE" command, the server must eventually respond with a + // After sending the DONE continuation, the server must 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( @@ -649,7 +665,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); @@ -660,52 +676,99 @@ public function send(string $name, array $tokens = [], ?string &$tag = null): vo /** * Write data to the connected stream. */ - protected function write(string $data): void + protected 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' : ''; + $prefix = ($identifier === ImapIdentifier::Uid) ? 'UID' : ''; - $this->send(trim($prefix.' FETCH'), [ - Str::set($from, $to), - Str::list((array) $items), - ], $tag); + $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 = [ + Str::set($set), + Str::list($items), + ]; + + if ($modifiers) { + $tokens[] = Str::list(array_map( + fn (FetchModifierInterface $modifier) => $modifier->toImap(), + $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 FetchResult::fromResponses($this->result->responses(), function (FetchedMessageData $data, UntaggedResponse $response) use ($items, $identifier, $tokens) { + if (! $this->matchesMessageSet($data, $response, $tokens[0], $identifier)) { return false; } - 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'), + foreach ($items as $item) { + $key = str_replace(['BODY.PEEK[', 'BINARY.PEEK['], ['BODY[', 'BINARY['], strtoupper($item)); + $key = preg_replace('/<(\\d+)\\.\\d+>$/', '<$1>', $key); - // If we're fetching message numbers, we can check if the requested items are all contained in the list. - ImapFetchIdentifier::MessageNumber => $data->contains($items), - }; + if (! $data->has($key)) { + return false; + } + } + + return true; }); } + /** + * Determine whether a fetched message belongs to the command's message set. + */ + protected function matchesMessageSet(FetchedMessageData $data, UntaggedResponse $response, string $set, ImapIdentifier $identifier): bool + { + if ($identifier === ImapIdentifier::Uid && ! $data->has('UID')) { + return false; + } + + // Wildcards and saved searches require server state we do not have. + // Do not discard potentially requested messages by guessing their bounds. + if (str_contains($set, '*') || $set === '$') { + return true; + } + + $number = $identifier === ImapIdentifier::Uid ? $data->uid() : (int) $response->type()->value; + + foreach (explode(',', $set) as $sequence) { + [$start, $end] = array_pad(explode(':', $sequence, 2), 2, $sequence); + $start = (int) $start; + $end = (int) $end; + + if ($number >= min($start, $end) && $number <= max($start, $end)) { + return true; + } + } + + return false; + } + /** * Set the current result instance. */ @@ -759,6 +822,21 @@ protected function assertTaggedResponse(string $tag, ?callable $exception = null return $response; } + /** + * Get the tagged response for the given command without asserting its status. + */ + protected function taggedResponse(string $tag): TaggedResponse + { + /** @var TaggedResponse $response */ + $response = $this->assertNextResponse( + fn (Response $response) => $response instanceof TaggedResponse && $response->tag()->is($tag), + fn (TaggedResponse $response) => true, + fn (TaggedResponse $response) => ImapCommandException::make($this->result->command(), $response), + ); + + return $response; + } + /** * Assert the server is ready to receive literal data. */ diff --git a/src/Connection/ImapTokenizer.php b/src/Connection/ImapTokenizer.php index f43a735..48141b5 100644 --- a/src/Connection/ImapTokenizer.php +++ b/src/Connection/ImapTokenizer.php @@ -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->ensureBuffer(2); + + if (substr($this->buffer, $this->position, 2) === '~{') { + $this->advance(); + + return $this->readLiteral(); + } + } + // Otherwise, parse a number or atom. return $this->readNumberOrAtom(); } 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/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/Fetch/ChangedSince.php b/src/Fetch/ChangedSince.php new file mode 100644 index 0000000..0fe4aa6 --- /dev/null +++ b/src/Fetch/ChangedSince.php @@ -0,0 +1,30 @@ +modSequence.($this->vanished ? ' VANISHED' : ''); + } +} diff --git a/src/Fetch/ModifierInterface.php b/src/Fetch/ModifierInterface.php new file mode 100644 index 0000000..2be0d53 --- /dev/null +++ b/src/Fetch/ModifierInterface.php @@ -0,0 +1,11 @@ +untagged() as $response) { + if ($response->type()->is('VANISHED')) { + $vanished[] = Vanished::fromResponse($response); + } elseif ( + ($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH') + ) { + $message = FetchedMessageData::fromResponse($response); + + if (! $filter || $filter($message, $response)) { + $messages[] = $message; + } + } + } + + return new static($messages, $vanished, $responses); + } + + /** + * Get the fetched messages. + * + * @return FetchedMessageData[] + */ + public function messages(): array + { + return $this->messages; + } + + /** + * Get the vanished message groups. + * + * @return Vanished[] + */ + public function vanished(): array + { + return $this->vanished; + } + + /** + * Get all vanished message UIDs. + */ + public function vanishedUids(): array + { + return array_values(array_unique(array_merge(...array_map( + fn (Vanished $vanished) => $vanished->uids(), + $this->vanished, + )))); + } + + /** + * Get the raw IMAP responses. + */ + public function responses(): ResponseCollection + { + return $this->responses ?? new ResponseCollection; + } +} diff --git a/src/FetchedMessageData.php b/src/FetchedMessageData.php index f38fcb1..187411f 100644 --- a/src/FetchedMessageData.php +++ b/src/FetchedMessageData.php @@ -2,23 +2,26 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Connection\Responses\Data\Data; use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; +use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; +use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress; +use DirectoryTree\ImapEngine\Connection\Tokens\Nil; +use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Exceptions\RuntimeException; +use Illuminate\Contracts\Support\Arrayable; -class FetchedMessageData +class FetchedMessageData implements Arrayable { /** * Constructor. */ public function __construct( - protected int $uid, - protected array $flags = [], - protected string $head = '', - protected string $body = '', - protected ?int $size = null, - protected ?ListData $bodyStructure = null, - ) {} + protected array $attributes = [] + ) { + $this->attributes = array_change_key_case($attributes, CASE_UPPER); + } /** * Create message data from an IMAP FETCH response. @@ -35,16 +38,61 @@ public static function fromResponse(UntaggedResponse $response): static )); } - return new static( - uid: (int) $data->lookup('UID')->value, - flags: $data->lookup('FLAGS')?->values() ?? [], - head: $data->lookup('[HEADER]')->value ?? '', - body: $data->lookup('[TEXT]')->value ?? '', - size: ($size = $data->lookup('RFC822.SIZE')?->value) ? (int) $size : null, - bodyStructure: ($bodyStructure = $data->lookup('BODYSTRUCTURE')) instanceof ListData - ? $bodyStructure - : null, - ); + $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); + } + + /** + * 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), + )); } /** @@ -52,7 +100,83 @@ public static function fromResponse(UntaggedResponse $response): static */ public function uid(): int { - return $this->uid; + 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, + }; } /** @@ -60,14 +184,6 @@ public function uid(): int */ public function toMessage(FolderInterface $folder): Message { - return new Message( - $folder, - $this->uid, - $this->flags, - $this->head, - $this->body, - $this->size, - $this->bodyStructure, - ); + return new Message($folder, $this); } } diff --git a/src/FileMessage.php b/src/FileMessage.php index b3b7cba..29d3634 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} */ diff --git a/src/Folder.php b/src/Folder.php index a05f328..4fb79f7 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -5,9 +5,11 @@ use Closure; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; 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; @@ -85,7 +87,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); } @@ -95,7 +97,7 @@ public function messages(): MessageQuery */ public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void { - if (! $this->mailbox->hasCapability('IDLE')) { + if (! $this->mailbox->capabilities()->supports('IDLE')) { throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.'); } @@ -109,7 +111,7 @@ public function idle(callable $callback, ?callable $query = null, callable|int $ // Fetch the message by message number. $fetch = fn (int $msgn) => ( - $query($this->messages())->findOrFail($msgn, ImapFetchIdentifier::MessageNumber) + $query($this->messages())->findOrFail($msgn, ImapIdentifier::MessageNumber) ); (new Idle(clone $this->mailbox, $this->path, $timeout))->await( @@ -173,9 +175,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 +185,15 @@ public function select(bool $force = false): void */ public function quota(): array { - if (! $this->mailbox->hasCapability('QUOTA')) { + 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 +237,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(); } diff --git a/src/FolderData.php b/src/FolderData.php index eaaedd3..e1dd897 100644 --- a/src/FolderData.php +++ b/src/FolderData.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine; -enum FolderData: string implements FolderDataItem +enum FolderData: string implements FolderDataItemInterface { case SpecialUse = 'SPECIAL-USE'; diff --git a/src/FolderDataItem.php b/src/FolderDataItemInterface.php similarity index 91% rename from src/FolderDataItem.php rename to src/FolderDataItemInterface.php index bf4b8f2..5d574e5 100644 --- a/src/FolderDataItem.php +++ b/src/FolderDataItemInterface.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine; -interface FolderDataItem +interface FolderDataItemInterface { /** * Get the unique folder data item key. diff --git a/src/FolderInterface.php b/src/FolderInterface.php index 1688d53..63fd1d0 100644 --- a/src/FolderInterface.php +++ b/src/FolderInterface.php @@ -2,6 +2,9 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; + interface FolderInterface { /** @@ -59,7 +62,7 @@ public function move(string $newPath): void; /** * Select the current folder. */ - public function select(bool $force = false): void; + public function select(bool $force = false, OptionInterface ...$options): Result; /** * Get the folder's quotas. diff --git a/src/FolderRepository.php b/src/FolderRepository.php index 49032c2..8473c71 100644 --- a/src/FolderRepository.php +++ b/src/FolderRepository.php @@ -12,7 +12,7 @@ class FolderRepository implements FolderRepositoryInterface /** * The data items to include in the folder LIST request. * - * @var array + * @var array */ protected array $dataItems = []; @@ -26,7 +26,7 @@ public function __construct( /** * {@inheritDoc} */ - public function with(FolderDataItem ...$items): static + public function with(FolderDataItemInterface ...$items): static { foreach ($items as $item) { $this->dataItems[$item->key()] = $item; @@ -76,8 +76,8 @@ public function firstOrCreate(string $path): FolderInterface */ public function get(?string $match = '*', ?string $reference = ''): FolderCollection { - $return = array_map(function (FolderDataItem $item) { - if (! $this->mailbox->hasCapability($item->capability())) { + $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." ); @@ -86,7 +86,9 @@ public function get(?string $match = '*', ?string $reference = ''): FolderCollec return $item->toImap(); }, $this->dataItems); - return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match), $return)->map( + 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, diff --git a/src/FolderRepositoryInterface.php b/src/FolderRepositoryInterface.php index 25b4ea6..0da999e 100644 --- a/src/FolderRepositoryInterface.php +++ b/src/FolderRepositoryInterface.php @@ -9,7 +9,7 @@ interface FolderRepositoryInterface /** * Add items to the folder LIST request. */ - public function with(FolderDataItem ...$items): static; + public function with(FolderDataItemInterface ...$items): static; /** * Find a folder. diff --git a/src/HasCapabilities.php b/src/HasCapabilities.php deleted file mode 100644 index c398009..0000000 --- a/src/HasCapabilities.php +++ /dev/null @@ -1,24 +0,0 @@ -capabilities() as $supported) { - $supported = strtoupper($supported); - - if ($supported === $capability || str_starts_with($supported, "{$capability}=")) { - return true; - } - } - - return false; - } -} diff --git a/src/Mailbox.php b/src/Mailbox.php index ef4c206..66f4ab1 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -2,18 +2,22 @@ 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\RequiresEnableInterface; +use DirectoryTree\ImapEngine\Selection\Result; use Exception; +use InvalidArgumentException; class Mailbox implements MailboxInterface { - use HasCapabilities; - /** * The mailbox configuration. */ @@ -26,7 +30,7 @@ class Mailbox implements MailboxInterface 'password' => '', 'encryption' => 'ssl', 'validate_cert' => true, - 'authentication' => 'plain', + 'authentication' => 'login', 'proxy' => [ 'socket' => null, 'username' => null, @@ -40,12 +44,17 @@ class Mailbox implements MailboxInterface * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1 */ - protected ?array $capabilities = null; + protected ?Capabilities $capabilities = null; + + /** + * The currently selected or examined folder. + */ + protected ?FolderInterface $folder = null; /** - * The currently selected folder. + * The result from the currently selected folder. */ - protected ?FolderInterface $selected = null; + protected ?Result $selection = null; /** * The mailbox connection. @@ -66,6 +75,9 @@ public function __construct(array $config = []) public function __clone(): void { $this->connection = null; + $this->capabilities = null; + $this->folder = null; + $this->selection = null; } /** @@ -81,10 +93,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); } @@ -111,10 +119,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(); } @@ -152,17 +164,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.' + ), + }; } /** @@ -177,6 +191,9 @@ public function disconnect(): void // Do nothing. } finally { $this->connection = null; + $this->capabilities = null; + $this->folder = null; + $this->selection = null; } } @@ -204,24 +221,104 @@ 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) => $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($capabilities); + + foreach ($requested->all() as $capability) { + if (! $current->supports($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.' + ); + } + + $responses = $this->connection()->enable(...$requested); + + foreach ($responses as $response) { + if ($response->type()->is('ENABLED')) { + $this->capabilities->enable( + ...array_map(fn (Token $token) => $token->value, $response->tokensAfter(2)) + ); + } } - $this->selected = $folder; + 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 RequiresEnableInterface) { + $this->enable($option->capability()); + } + } + + if (! $this->selected($folder) || $force || $options) { + $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->selection = null; + + $selection = $this->connection()->examine($folder->path()); + + $this->folder = $folder; + + return $selection; } /** @@ -229,6 +326,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 0c0792b..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,17 +55,24 @@ public function folders(): FolderRepositoryInterface; /** * Get the mailbox's capabilities. */ - public function capabilities(): array; + public function capabilities(): Capabilities; /** - * Determine if the mailbox supports the given capability. + * Enable the given mailbox capabilities for the current connection. + * + * Call this before selecting or examining any folder. */ - public function hasCapability(string $capability): bool; + 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 db78a17..93b6643 100644 --- a/src/Message.php +++ b/src/Message.php @@ -33,12 +33,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 +42,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 +53,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 +74,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 +90,7 @@ public function size(): ?int */ public function flags(): array { - return $this->flags; + return $this->data->flags(); } /** @@ -87,11 +98,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 +110,7 @@ public function head(bool $fetch = false): string */ public function hasHead(): bool { - return ! empty($this->head); + return $this->head() !== ''; } /** @@ -107,7 +118,7 @@ public function hasHead(): bool */ public function body(): string { - return $this->body; + return $this->data->body(); } /** @@ -115,7 +126,7 @@ public function body(): string */ public function hasBody(): bool { - return ! empty($this->body); + return $this->body() !== ''; } /** @@ -127,18 +138,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 +159,7 @@ public function bodyStructure(bool $fetch = false): ?BodyStructureCollection */ public function hasBodyStructure(): bool { - return (bool) $this->bodyStructureData; + return ! is_null($this->data->bodyStructure()); } /** @@ -155,7 +168,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); } @@ -168,16 +181,18 @@ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = $this->folder->mailbox() ->connection() - ->store($flag, $this->uid, mode: $operation); + ->store($this->uid(), $flag, mode: $operation); if ($expunge) { - $this->folder->expunge($this->uid); + $this->folder->expunge($this->uid()); } - $this->flags = match ($operation) { - '+' => array_unique(array_merge($this->flags, [$flag])), - '-' => array_diff($this->flags, [$flag]), - }; + $this->data = $this->data->merge([ + 'FLAGS' => match ($operation) { + '+' => array_unique(array_merge($this->flags(), [$flag])), + '-' => array_diff($this->flags(), [$flag]), + }, + ]); } /** @@ -187,13 +202,13 @@ public function copy(string $folder): ?int { $mailbox = $this->folder->mailbox(); - if (! $mailbox->hasCapability('UIDPLUS')) { + 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); } @@ -208,12 +223,12 @@ public function move(string $folder, bool $expunge = false): ?int $mailbox = $this->folder->mailbox(); switch (true) { - case $mailbox->hasCapability('MOVE'): - $response = $mailbox->connection()->move($folder, $this->uid); + case $mailbox->capabilities()->supports('MOVE'): + $response = $mailbox->connection()->move($this->uid(), $folder); return MessageResponseParser::getUidFromCopy($response); - case $mailbox->hasCapability('UIDPLUS'): + case $mailbox->capabilities()->supports('UIDPLUS'): $uid = $this->copy($folder); $this->delete($expunge); @@ -445,21 +460,23 @@ public function attachmentCount(): int */ public function bodyPart(string $partNumber, bool $peek = true): ?string { - $response = $this->folder->mailbox() - ->connection() - ->bodyPart($partNumber, $this->uid, $peek); + $key = "BODY[$partNumber]"; - if ($response->isEmpty()) { - return null; + if ($peek && $this->data->has($key)) { + return $this->data->get($key); } - $data = $response->first()->tokenAt(3); + $response = $this->folder->mailbox() + ->connection() + ->fetch($this->uid(), $peek ? "BODY.PEEK[$partNumber]" : "BODY[$partNumber]"); - if (! $data instanceof ListData) { + if (! $data = $response->messages()[0] ?? null) { return null; } - return $data->lookup("[$partNumber]")?->value; + $this->data = $this->data->merge($data); + + return $data->get($key); } /** @@ -483,13 +500,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(); } /** @@ -498,8 +509,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()), ])); } @@ -527,19 +538,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()[0] ?? null) { return null; } - $data = $response->first()->tokenAt(3); + $this->data = $this->data->merge($data); - if (! $data instanceof ListData) { - return null; - } - - return $data->lookup('[HEADER]')?->value; + return $data->get('BODY[HEADER]'); } /** @@ -550,18 +557,14 @@ protected function fetchBodyStructureData(): ?ListData $response = $this->folder ->mailbox() ->connection() - ->bodyStructure($this->uid); + ->fetch($this->uid(), 'BODYSTRUCTURE'); - if ($response->isEmpty()) { + if (! $data = $response->messages()[0] ?? null) { 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 index e5fa7ec..e9fc2dc 100644 --- a/src/MessageData.php +++ b/src/MessageData.php @@ -31,6 +31,14 @@ public static function bodyStructure(): Attribute return Attribute::BodyStructure; } + /** + * Create a MODSEQ message data item. + */ + public static function modSequence(): Attribute + { + return Attribute::ModSequence; + } + /** * Create a message header data item. */ diff --git a/src/MessageData/Attribute.php b/src/MessageData/Attribute.php index 2a391fc..c5aeca5 100644 --- a/src/MessageData/Attribute.php +++ b/src/MessageData/Attribute.php @@ -2,11 +2,12 @@ namespace DirectoryTree\ImapEngine\MessageData; -enum Attribute: string implements FetchItem +enum Attribute: string implements FetchItemInterface { case Flags = 'FLAGS'; case Size = 'RFC822.SIZE'; case BodyStructure = 'BODYSTRUCTURE'; + case ModSequence = 'MODSEQ'; /** * {@inheritDoc} diff --git a/src/MessageData/Body.php b/src/MessageData/Body.php index 6fcbf90..ed1d786 100644 --- a/src/MessageData/Body.php +++ b/src/MessageData/Body.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine\MessageData; -class Body implements FetchItem +class Body implements FetchItemInterface { /** * Constructor. diff --git a/src/MessageData/FetchItem.php b/src/MessageData/FetchItemInterface.php similarity index 90% rename from src/MessageData/FetchItem.php rename to src/MessageData/FetchItemInterface.php index ad1366b..04062e3 100644 --- a/src/MessageData/FetchItem.php +++ b/src/MessageData/FetchItemInterface.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine\MessageData; -interface FetchItem +interface FetchItemInterface { /** * Get the unique message data item key. diff --git a/src/MessageInterface.php b/src/MessageInterface.php index be9fbd8..7595e90 100644 --- a/src/MessageInterface.php +++ b/src/MessageInterface.php @@ -20,6 +20,11 @@ public function uid(): int; */ public function size(): ?int; + /** + * Get the message modification sequence. + */ + public function modSequence(): ?int; + /** * Get the message date and time. */ diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 8f9747e..d7b0e33 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -5,17 +5,16 @@ use BackedEnum; use DateTimeInterface; use DirectoryTree\ImapEngine\Collections\MessageCollection; -use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; -use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Connection\Tokens\Token; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapFlag; +use DirectoryTree\ImapEngine\Enums\ImapIdentifier; use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; -use DirectoryTree\ImapEngine\MessageData\FetchItem; +use DirectoryTree\ImapEngine\Fetch\ChangedSince; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Support\Collection; @@ -74,6 +73,46 @@ public function get(): MessageCollection return $this->process($this->uids()); } + /** + * {@inheritDoc} + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult + { + 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, + ); + + 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. */ @@ -165,34 +204,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(); - - $uid = $response->tokenAt(3) // ListData - ->tokenAt(1) // Atom - ->value; // UID + $data = $this->id($id, $identifier) ?? throw new ItemNotFoundException; - 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(); } /** @@ -204,7 +234,7 @@ 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($uids); @@ -222,11 +252,7 @@ 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) Str::enums($flag), mode: $operation); if ($expunge) { $this->folder->expunge($uids); @@ -286,7 +312,7 @@ public function move(string $folder, bool $expunge = false): int return 0; } - $this->connection()->move($folder, $uids); + $this->connection()->move($uids, $folder); return count($uids); } @@ -302,7 +328,7 @@ public function copy(string $folder): int return 0; } - $this->connection()->copy($folder, $uids); + $this->connection()->copy($uids, $folder); return count($uids); } @@ -350,21 +376,18 @@ protected function fetch(Collection $messages): array $uids = $messages->forPage($this->page, $this->limit)->values(); $fetch = array_map( - fn (FetchItem $item) => $item->toImap(), + fn (FetchItemInterface $item) => $item->toImap(), $this->fetchItems, ); if (empty($fetch)) { return $uids->mapWithKeys(fn (string|int $uid) => [ - $uid => new FetchedMessageData((int) $uid), + $uid => new FetchedMessageData(['UID' => (int) $uid]), ])->all(); } - $fetched = $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) { - $data = FetchedMessageData::fromResponse($response); - - return [$data->uid() => $data]; - }); + $fetched = (new Collection($this->connection()->fetch($uids->all(), $fetch)->messages())) + ->keyBy(fn (FetchedMessageData $data) => $data->uid()); return $uids ->map(fn (string|int $uid) => $fetched->get($uid)) @@ -409,7 +432,7 @@ protected function search(): Collection */ protected function sort(ImapSort $sort): Collection { - if (! $this->folder->mailbox()->hasCapability('SORT')) { + if (! $this->folder->mailbox()->capabilities()->supports('SORT')) { throw new ImapCapabilityException( 'Unable to sort messages. IMAP server does not support SORT capability.' ); @@ -432,20 +455,20 @@ protected function sort(ImapSort $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()[0] ?? null; } 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. diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index ccf5cad..d6b2239 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -6,10 +6,10 @@ 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\FetchItem; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; /** @@ -45,17 +45,17 @@ public function setPage(int $page): MessageQueryInterface; /** * Add items to the message FETCH request. */ - public function with(FetchItem ...$items): static; + public function with(FetchItemInterface ...$items): static; /** * Remove items from the message FETCH request. */ - public function without(FetchItem ...$items): static; + public function without(FetchItemInterface ...$items): static; /** * Replace the items in the message FETCH request. */ - public function only(FetchItem ...$items): static; + public function only(FetchItemInterface ...$items): static; /** * Order messages locally by UID, replacing any server-side sort criteria. @@ -94,6 +94,13 @@ public function firstOrFail(): MessageInterface; */ public function get(): MessageCollection; + /** + * 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. */ @@ -117,12 +124,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/QueriesMessages.php b/src/QueriesMessages.php index e6de774..d3a6239 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -5,7 +5,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Enums\SortDirection; -use DirectoryTree\ImapEngine\MessageData\FetchItem; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\Support\ForwardsCalls; use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Support\Traits\Conditionable; @@ -32,7 +32,7 @@ trait QueriesMessages /** * The items to include in message FETCH requests. * - * @var array + * @var array */ protected array $fetchItems = []; @@ -113,7 +113,7 @@ public function setPage(int $page): MessageQueryInterface /** * {@inheritDoc} */ - public function with(FetchItem ...$items): static + public function with(FetchItemInterface ...$items): static { foreach ($items as $item) { $this->fetchItems[$item->key()] = $item; @@ -125,7 +125,7 @@ public function with(FetchItem ...$items): static /** * {@inheritDoc} */ - public function without(FetchItem ...$items): static + public function without(FetchItemInterface ...$items): static { foreach ($items as $item) { unset($this->fetchItems[$item->key()]); @@ -137,7 +137,7 @@ public function without(FetchItem ...$items): static /** * {@inheritDoc} */ - public function only(FetchItem ...$items): static + public function only(FetchItemInterface ...$items): static { $this->fetchItems = []; 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 @@ +uidValidity, $this->highestModSequence]; + + if ($this->knownUids) { + $parameters[] = Str::set($this->knownUids); + } + + if (! is_null($this->sequenceMatch)) { + $parameters[] = Str::list(array_map([Str::class, 'set'], $this->sequenceMatch)); + } + + return 'QRESYNC '.Str::list($parameters); + } +} diff --git a/src/Selection/RequiresEnableInterface.php b/src/Selection/RequiresEnableInterface.php new file mode 100644 index 0000000..392b9aa --- /dev/null +++ b/src/Selection/RequiresEnableInterface.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) { + 'UIDVALIDITY' => $uidValidity = (int) $value->value, + 'UIDNEXT' => $uidNext = (int) $value->value, + 'HIGHESTMODSEQ' => $highestModSequence = (int) $value->value, + 'NOMODSEQ' => $supportsModSequences = false, + 'PERMANENTFLAGS' => $permanentFlags = $value instanceof ListData ? $value->values() : [], + default => null, + }; + } + + return new static( + $exists, + $recent, + $uidValidity, + $uidNext, + $highestModSequence, + $permanentFlags, + $supportsModSequences, + FetchResult::fromResponses($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 $this->supportsModSequences; + } + + /** + * Get changes returned while selecting the folder. + */ + public function changes(): FetchResult + { + return $this->changes ?? new FetchResult; + } + + /** + * Get the raw IMAP responses. + */ + public function responses(): ResponseCollection + { + return $this->responses ?? new ResponseCollection; + } + + /** + * Count the untagged selection responses. + */ + public function count(): int + { + return $this->responses()->untagged()->count(); + } +} 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..8139921 --- /dev/null +++ b/src/StoreResult.php @@ -0,0 +1,72 @@ +messages(); + + $code = $response->tokenAt(2); + $modified = $code instanceof ResponseCodeData && strtoupper($code->first()?->value ?? '') === 'MODIFIED' + ? Str::fromSequenceSet($code->tokenAt(1)->value) + : []; + + return new static($response, $messages, $modified, $responses); + } + + public function response(): TaggedResponse + { + return $this->response; + } + + /** + * Get the messages whose flags were changed. + * + * @return FetchedMessageData[] + */ + public function messages(): array + { + return $this->messages; + } + + /** + * Get the UIDs or message numbers rejected because they changed after the checkpoint. + */ + public function modified(): array + { + return $this->modified; + } + + public function successful(): bool + { + return $this->response->successful(); + } + + public function responses(): ResponseCollection + { + return $this->responses ?? new ResponseCollection; + } +} diff --git a/src/Support/Str.php b/src/Support/Str.php index 7813f3d..c522bd9 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -40,13 +40,46 @@ public static function literal(array|string $string): array|string return $result; } - if (str_contains($string, "\n")) { + if (str_contains($string, "\r") || str_contains($string, "\n")) { return ['{'.strlen($string).'}', $string]; } return '"'.static::escape($string).'"'; } + /** + * 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; + } + /** * Resolve the value of the given enums. */ @@ -98,6 +131,38 @@ public static function set(int|string|array $from, int|float|string|null $to = n return $from.':'.$to; } + /** + * Expand an IMAP sequence set into its individual values. + * + * @return int[] + */ + public static function fromSequenceSet(string $set): array + { + $values = []; + + foreach (explode(',', $set) as $sequence) { + if (! str_contains($sequence, ':')) { + $values[] = (int) $sequence; + + continue; + } + + [$start, $end] = array_map('intval', explode(':', $sequence, 2)); + + $step = $start <= $end ? 1 : -1; + + for ($value = $start; ; $value += $step) { + $values[] = $value; + + if ($value === $end) { + break; + } + } + } + + return $values; + } + /** * Convert the values into an IMAP sequence set. * diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index d1e9d21..46f35aa 100644 --- a/src/Testing/FakeFolder.php +++ b/src/Testing/FakeFolder.php @@ -7,6 +7,8 @@ use DirectoryTree\ImapEngine\FolderInterface; 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 @@ -81,7 +83,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); } @@ -117,9 +119,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,6 +137,8 @@ public function status(): array */ public function examine(): array { + $this->mailbox?->examine($this); + return []; } diff --git a/src/Testing/FakeFolderRepository.php b/src/Testing/FakeFolderRepository.php index e673c6c..88c8082 100644 --- a/src/Testing/FakeFolderRepository.php +++ b/src/Testing/FakeFolderRepository.php @@ -3,7 +3,7 @@ namespace DirectoryTree\ImapEngine\Testing; use DirectoryTree\ImapEngine\Collections\FolderCollection; -use DirectoryTree\ImapEngine\FolderDataItem; +use DirectoryTree\ImapEngine\FolderDataItemInterface; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; use DirectoryTree\ImapEngine\MailboxInterface; @@ -15,7 +15,7 @@ class FakeFolderRepository implements FolderRepositoryInterface /** * The requested folder data items. * - * @var array + * @var array */ protected array $dataItems = []; @@ -31,7 +31,7 @@ public function __construct( /** * {@inheritDoc} */ - public function with(FolderDataItem ...$items): static + public function with(FolderDataItemInterface ...$items): static { foreach ($items as $item) { $this->dataItems[$item->key()] = $item; diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index f7d2275..09d81dc 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -2,22 +2,29 @@ namespace DirectoryTree\ImapEngine\Testing; +use DirectoryTree\ImapEngine\Capabilities; +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\HasCapabilities; use DirectoryTree\ImapEngine\MailboxInterface; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; class FakeMailbox implements MailboxInterface { - use HasCapabilities; - /** * The currently selected folder. */ protected ?FolderInterface $selected = null; + /** + * The mailbox capabilities. + */ + protected Capabilities $capabilities; + /** * Constructor. */ @@ -25,8 +32,10 @@ public function __construct( protected array $config = [], /** @var FakeFolder[] */ protected array $folders = [], - protected array $capabilities = [], + array $capabilities = [], ) { + $this->capabilities = Capabilities::from($capabilities); + foreach ($folders as $folder) { $folder->setMailbox($this); } @@ -37,10 +46,6 @@ public function __construct( */ public function config(?string $key = null, mixed $default = null): mixed { - if (is_null($key)) { - return $this->config; - } - return data_get($this->config, $key, $default); } @@ -63,9 +68,14 @@ 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->selected = null; + $this->capabilities = Capabilities::from($this->capabilities->all()); } /** @@ -103,7 +113,7 @@ public function folders(): FolderRepositoryInterface /** * {@inheritDoc} */ - public function capabilities(): array + public function capabilities(): Capabilities { return $this->capabilities; } @@ -111,9 +121,41 @@ public function capabilities(): array /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false): void + public function enable(string ...$capabilities): ResponseCollection + { + $capabilities = Capabilities::from($capabilities); + + foreach ($capabilities->all() as $capability) { + if (! $this->capabilities->supports($capability)) { + throw new ImapCapabilityException( + "Unable to enable capability [$capability]. IMAP server does not support it." + ); + } + } + + $this->capabilities->enable(...$capabilities->all()); + + return new ResponseCollection; + } + + /** + * {@inheritDoc} + */ + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result { $this->selected = $folder; + + return new Result; + } + + /** + * {@inheritDoc} + */ + public function examine(FolderInterface $folder): Result + { + $this->selected = null; + + return new Result; } /** diff --git a/src/Testing/FakeMessage.php b/src/Testing/FakeMessage.php index b78e8b6..dfb636f 100644 --- a/src/Testing/FakeMessage.php +++ b/src/Testing/FakeMessage.php @@ -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} */ diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index 8c99c72..a1fd9df 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -7,9 +7,11 @@ use DirectoryTree\ImapEngine\AppendResult; 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\FetchedMessageData; +use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -40,6 +42,27 @@ public function get(): MessageCollection )); } + /** + * {@inheritDoc} + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult + { + $uids = (array) $uids; + + $messages = collect($this->folder->getMessages()) + ->filter(fn (FakeMessage $message) => in_array($message->uid(), $uids, true)) + ->filter(fn (FakeMessage $message) => ($message->modSequence() ?? 0) > $modSequence) + ->map(fn (FakeMessage $message) => new FetchedMessageData([ + 'UID' => $message->uid(), + 'FLAGS' => $message->flags(), + 'MODSEQ' => [$message->modSequence()], + ])) + ->values() + ->all(); + + return new FetchResult($messages); + } + /** * {@inheritDoc} */ @@ -168,7 +191,7 @@ 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); } @@ -176,7 +199,7 @@ public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchI /** * {@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); } diff --git a/src/Vanished.php b/src/Vanished.php new file mode 100644 index 0000000..7226308 --- /dev/null +++ b/src/Vanished.php @@ -0,0 +1,49 @@ +tokenAt(2); + $earlier = $data instanceof ListData && $data->contains('EARLIER'); + $sequenceSet = $response->tokenAt($earlier ? 3 : 2); + + return new static( + Str::fromSequenceSet($sequenceSet->value), + $earlier, + ); + } + + /** + * Get the vanished message UIDs. + */ + public function uids(): array + { + return $this->uids; + } + + /** + * Determine if the messages vanished before the requested checkpoint. + */ + public function earlier(): bool + { + return $this->earlier; + } +} diff --git a/tests/Integration/FoldersTest.php b/tests/Integration/FoldersTest.php index 8d86527..d5890a9 100644 --- a/tests/Integration/FoldersTest.php +++ b/tests/Integration/FoldersTest.php @@ -74,7 +74,6 @@ expect($folder->status())->toHaveKeys([ 'MESSAGES', - 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', @@ -86,7 +85,6 @@ expect($folder->status())->toHaveKeys([ 'MESSAGES', - 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', @@ -108,3 +106,17 @@ 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/MailboxTest.php b/tests/Integration/MailboxTest.php index 84ee47d..89626fc 100644 --- a/tests/Integration/MailboxTest.php +++ b/tests/Integration/MailboxTest.php @@ -17,8 +17,8 @@ test('capabilities', function () { $mailbox = mailbox(); - expect(array_flip($mailbox->capabilities()))->toHaveKeys([ - 'IMAP4rev1', + expect(array_flip($mailbox->capabilities()->all()))->toHaveKeys([ + 'IMAP4REV1', 'LITERAL+', 'UIDPLUS', 'SORT', diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php new file mode 100644 index 0000000..554c95b --- /dev/null +++ b/tests/Unit/ArchitectureTest.php @@ -0,0 +1,6 @@ +expect('DirectoryTree\ImapEngine') + ->interfaces() + ->toHaveSuffix('Interface'); 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..1bb10f0 --- /dev/null +++ b/tests/Unit/CapabilitiesTest.php @@ -0,0 +1,40 @@ +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(); +}); + +test('it determines enabled capabilities', function () { + $capabilities = Capabilities::from([ + 'QRESYNC', + 'AUTH=PLAIN', + 'AUTH=XOAUTH2', + ]); + + expect($capabilities->enabled('QRESYNC'))->toBeFalse(); + + $capabilities->enable('qresync', 'QRESYNC', 'AUTH=XOAUTH2'); + + expect($capabilities->enabled('qresync'))->toBeTrue(); + expect($capabilities->enabled('AUTH'))->toBeFalse(); + expect($capabilities->enabled('AUTH=PLAIN'))->toBeFalse(); + expect($capabilities->enabled('AUTH=XOAUTH2'))->toBeTrue(); +}); 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/ImapConnectionFilteringTest.php b/tests/Unit/Connection/ImapConnectionFilteringTest.php new file mode 100644 index 0000000..82242ad --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionFilteringTest.php @@ -0,0 +1,78 @@ +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]->flags())->toBe(['\\Seen']); + expect($result->responses())->toHaveCount(3); +})->with(['fetch', 'store']); + +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..52fc5cd --- /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->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())->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())->toBe([2, 3]); + expect($result->messages()[0]->flags())->toBe(['\\Seen']); + expect($result->messages()[0]->modSequence())->toBe(44); + expect($result->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())->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..304938e --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -0,0 +1,266 @@ +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('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('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('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('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('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('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 c0aa9b5..b0bd63b 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,13 +1,18 @@ feed([ '* OK Welcome to IMAP', + '* BYE Logging out', 'TAG1 OK Logged out', ]); @@ -82,6 +88,7 @@ $connection->logout(); $stream->assertWritten('TAG1 LOGOUT'); + expect($connection->connected())->toBeFalse(); }); test('logout failure', function () { @@ -96,9 +103,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 () { @@ -107,17 +115,19 @@ $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($credentials); }); test('authenticate failure', function () { @@ -132,8 +142,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; @@ -255,7 +265,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(); }); @@ -388,7 +398,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->list('', '*', ['SPECIAL-USE']); + $responses = $connection->list('', '*', return: ['SPECIAL-USE']); $stream->assertWritten('TAG1 LIST "" "*" RETURN (SPECIAL-USE)'); @@ -401,6 +411,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', 'TAG1 OK [APPENDUID 1234567890 42] APPEND completed', ]); @@ -409,11 +420,12 @@ $result = $connection->append('INBOX', 'Test message', ['\\Seen']); - $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "Test message"'); + $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) {12}'); + $stream->assertWritten('Test message'); - expect($result)->toBeInstanceOf(AppendResult::class) - ->and($result->uidValidity())->toBe(1234567890) - ->and($result->uid())->toBe(42); + expect($result)->toBeInstanceOf(AppendResult::class); + expect($result->uidValidity())->toBe(1234567890); + expect($result->uid())->toBe(42); }); test('append message with internal date', function () { @@ -422,6 +434,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', 'TAG1 OK APPEND completed', ]); @@ -435,10 +448,11 @@ new DateTimeImmutable('2026-09-01 12:34:56 -04:00'), ); - $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "01-Sep-2026 12:34:56 -0400" "Test message"'); + $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "01-Sep-2026 12:34:56 -0400" {12}'); + $stream->assertWritten('Test message'); - expect($result->uidValidity())->toBeNull() - ->and($result->uid())->toBeNull(); + expect($result->uidValidity())->toBeNull(); + expect($result->uid())->toBeNull(); }); test('append sends literal data after receiving a continuation response', function () { @@ -513,7 +527,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"'); }); @@ -529,7 +543,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"'); }); @@ -546,11 +560,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->successful())->toBeTrue(); + expect($response->messages())->toBe([]); + expect($response->modified())->toBe([]); }); test('uid fetch with uid', function () { @@ -566,11 +583,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 () { @@ -586,11 +604,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 () { @@ -608,11 +627,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 () { @@ -629,11 +648,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 () { @@ -649,11 +668,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 () { @@ -669,11 +688,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 () { @@ -754,7 +773,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(); }); @@ -765,6 +784,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', '* ID NIL', 'TAG1 OK ID completed', ]); @@ -778,7 +798,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 () { @@ -873,9 +894,141 @@ $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())->toBe([]); + expect($responses->vanishedUids())->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())->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', + '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())->toBe([1, 2, 4]); + 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', + ]); - expect((string) $responses->first())->toBe("* 1 FETCH (UID 123 FLAGS (\Seen))"); + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch([1, 2], 'FLAGS', modifiers: new ChangedSince(42, vanished: true)); + + expect($result->messages())->toBe([]); + expect($result->vanishedUids())->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', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $custom = new class implements ModifierInterface + { + public function toImap(): 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())->toBe([]); + expect($result->vanishedUids())->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/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php index 68d015f..a5401c0 100644 --- a/tests/Unit/FetchedMessageDataTest.php +++ b/tests/Unit/FetchedMessageDataTest.php @@ -1,5 +1,6 @@ toMessage(new Folder(new Mailbox, 'INBOX')); - expect($data->uid())->toBe(42) - ->and($message->uid())->toBe(42) - ->and($message->flags())->toBe(['\\Seen']) - ->and($message->size())->toBe(1024) - ->and($message->head())->toBe('Subject: Test') - ->and($message->body())->toBe('Hello world'); + 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'], + ]); + $changes = new FetchedMessageData(['FLAGS' => [], 'MODSEQ' => [43], 'THREADID' => null]); + + $merged = $original->merge($changes)->merge(['emailid' => ['M123']]); + + expect($merged->uid())->toBe(7); + 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/FolderTest.php b/tests/Unit/FolderTest.php index cc1e483..c021015 100644 --- a/tests/Unit/FolderTest.php +++ b/tests/Unit/FolderTest.php @@ -1,10 +1,30 @@ 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(); @@ -213,3 +233,60 @@ $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"'); +}); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php new file mode 100644 index 0000000..501d22d --- /dev/null +++ b/tests/Unit/IncrementalSyncTest.php @@ -0,0 +1,381 @@ +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('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())->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())->toBe([3, 4, 6]); + expect($changes->vanished()[0]->earlier())->toBeTrue(); + expect($changes->vanishedUids())->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->successful())->toBeTrue(); + expect($result->modified())->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->successful())->toBeFalse(); + expect($result->modified())->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 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())->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())->toBe([]); + expect($result->vanishedUids())->toBe([]); + expect($result->responses())->toBeEmpty(); + $stream->assertNotWritten('CAPABILITY'); + $stream->assertNotWritten('FETCH'); +})->with([false, true]); diff --git a/tests/Unit/MailboxCapabilitiesTest.php b/tests/Unit/MailboxCapabilitiesTest.php new file mode 100644 index 0000000..9f9b1f4 --- /dev/null +++ b/tests/Unit/MailboxCapabilitiesTest.php @@ -0,0 +1,77 @@ +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', + ])); + + $mailbox->enable('qresync'); + $folder = new Folder($mailbox, 'INBOX'); + $folder->select(); + + expect($mailbox->capabilities()->enabled('qresync'))->toBeTrue(); + 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'); +}); diff --git a/tests/Unit/MailboxTest.php b/tests/Unit/MailboxTest.php index ab03302..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', @@ -147,15 +261,15 @@ 'TAG2 OK CAPABILITY completed', ])); - expect($mailbox->capabilities())->toBe([ - 'IMAP4rev1', + expect($mailbox->capabilities()->all())->toBe([ + 'IMAP4REV1', 'STARTTLS', 'AUTH=PLAIN', ]); - expect($mailbox->hasCapability('imap4rev1'))->toBeTrue(); - expect($mailbox->hasCapability('AUTH'))->toBeTrue(); - expect($mailbox->hasCapability('AUTH=PLAIN'))->toBeTrue(); - expect($mailbox->hasCapability('AUTH=LOGIN'))->toBeFalse(); - expect($mailbox->hasCapability('START'))->toBeFalse(); + 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/MessageDataTest.php b/tests/Unit/MessageDataTest.php index 0536943..dbe6643 100644 --- a/tests/Unit/MessageDataTest.php +++ b/tests/Unit/MessageDataTest.php @@ -1,18 +1,19 @@ 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 (FetchItem $item, string $command) { +test('it creates body section data items', function (FetchItemInterface $item, string $command) { expect($item->toImap())->toBe($command); })->with([ [MessageData::headers(), 'BODY[HEADER]'], @@ -20,7 +21,7 @@ [MessageData::section('1.2'), 'BODY[1.2]'], ]); -test('body section data items can be fetched without setting the seen flag', function (FetchItem $item, string $command) { +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]'], diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index bf0f3c1..cf27dcf 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQuery; +use Illuminate\Support\ItemNotFoundException; function query(?Mailbox $mailbox = null): MessageQuery { @@ -20,6 +22,60 @@ 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('passthru', function () { $query = query(); @@ -303,6 +359,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', ]); @@ -314,9 +371,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $result = $query->append('Hello world', $flag); - expect($result->uidValidity())->toBe(1234567890) - ->and($result->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 () { diff --git a/tests/Unit/MessageTest.php b/tests/Unit/MessageTest.php index 14802ac..a1a4eae 100644 --- a/tests/Unit/MessageTest.php +++ b/tests/Unit/MessageTest.php @@ -4,6 +4,7 @@ use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Enums\ImapFlag; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; +use DirectoryTree\ImapEngine\FetchedMessageData; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\Message; @@ -24,7 +25,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', + ])); $newUid = $message->move('INBOX.Sent'); @@ -48,7 +54,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', + ])); $newUid = $message->move('INBOX.Sent'); @@ -76,7 +87,12 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - $message = new Message($folder, 42, [], 'header', 'body'); + $message = new Message($folder, new FetchedMessageData([ + 'UID' => 42, + 'FLAGS' => [], + 'BODY[HEADER]' => 'header', + 'BODY[TEXT]' => 'body', + ])); $message->delete(expunge: true); @@ -99,7 +115,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); @@ -120,7 +141,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'); @@ -153,12 +179,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(); @@ -189,14 +245,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); @@ -229,7 +284,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(); @@ -257,7 +317,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(); @@ -287,7 +352,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!'); }); @@ -315,7 +385,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!'); }); @@ -345,7 +420,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); }); @@ -373,7 +453,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!'); @@ -401,7 +486,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(); @@ -430,7 +520,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!

'); }); @@ -459,7 +554,11 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); // Message created without body structure data. - $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->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeFalse(); @@ -489,7 +588,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!

'); @@ -519,7 +622,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(); @@ -554,7 +662,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/Support/StrTest.php b/tests/Unit/Support/StrTest.php index 630ccf2..36c0526 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -23,6 +23,10 @@ expect(Str::set([1, '*']))->toBe('1,*'); }); +test('parse sequence set expands values and ranges', function () { + expect(Str::fromSequenceSet('1:3,7,10:8'))->toBe([1, 2, 3, 7, 10, 9, 8]); +}); + test('credentials', function () { expect(Str::credentials('foo', 'bar'))->toBe('dXNlcj1mb28BYXV0aD1CZWFyZXIgYmFyAQE='); }); @@ -53,11 +57,10 @@ 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"; +test('literal preserves carriage returns and newlines using literals', function (string $input) { $expected = ['{'.strlen($input).'}', $input]; expect(Str::literal($input))->toBe($expected); -}); +})->with(["hello\nworld", "hello\rworld", "hello\r\nworld"]); test('literal handles an array of literals', function () { expect(Str::literal(['first', 'second']))->toBe(['"first"', '"second"']); @@ -183,3 +186,16 @@ expect(Str::toImapUtf7($input))->toBe($expected); }); + +test('sequence expansion preserves ascending descending and single value ranges', function () { + expect(Str::fromSequenceSet('1:3,9:7,5:5,4294967294:4294967295')) + ->toBe([1, 2, 3, 9, 8, 7, 5, 4294967294, 4294967295]); +}); + +test('sequence expansion handles large compact ranges', function () { + $values = Str::fromSequenceSet('1:100000'); + + expect($values)->toHaveCount(100000); + expect($values[0])->toBe(1); + expect($values[99999])->toBe(100000); +}); diff --git a/tests/Unit/Testing/FakeFolderTest.php b/tests/Unit/Testing/FakeFolderTest.php index 9a3a022..964755b 100644 --- a/tests/Unit/Testing/FakeFolderTest.php +++ b/tests/Unit/Testing/FakeFolderTest.php @@ -128,3 +128,18 @@ ], ]); }); + +test('fake examination invalidates the previous selection', function (string $path) { + $mailbox = new FakeMailbox; + $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']); diff --git a/tests/Unit/Testing/FakeMailboxTest.php b/tests/Unit/Testing/FakeMailboxTest.php index 126ac87..cb55390 100644 --- a/tests/Unit/Testing/FakeMailboxTest.php +++ b/tests/Unit/Testing/FakeMailboxTest.php @@ -1,5 +1,6 @@ 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->hasCapability('imap4rev1'))->toBeTrue(); - expect($mailbox->hasCapability('START'))->toBeFalse(); + 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 () { @@ -37,12 +38,58 @@ ]); }); +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 = new FakeMailbox($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; expect($mailbox->connected())->toBeTrue(); }); +test('it tracks enabled capabilities until reconnection', function () { + $mailbox = new FakeMailbox(capabilities: ['QRESYNC']); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + + $mailbox->enable('qresync'); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); + + $mailbox->reconnect(); + + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue(); +}); + +test('it rejects enabling unsupported capabilities', function () { + $mailbox = new FakeMailbox(capabilities: ['QRESYNC']); + + expect(fn () => $mailbox->enable('CONDSTORE'))->toThrow( + ImapCapabilityException::class, + 'Unable to enable capability [CONDSTORE]. IMAP server does not support it.', + ); +}); + test('it returns folder repository', function () { $mailbox = new FakeMailbox; diff --git a/tests/Unit/Testing/FakeMessageQueryTest.php b/tests/Unit/Testing/FakeMessageQueryTest.php index a223756..2a4f797 100644 --- a/tests/Unit/Testing/FakeMessageQueryTest.php +++ b/tests/Unit/Testing/FakeMessageQueryTest.php @@ -307,3 +307,12 @@ // 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 FakeMessage(7)]); + + $result = $folder->messages()->changesSince(0, [], vanished: true); + + expect($result->messages())->toBe([]); + expect($result->vanishedUids())->toBe([]); +});