From 4364fad5f9c4234cf6e7cd7bfbe5c36498947aa3 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 14:54:20 -0400 Subject: [PATCH 01/16] Add incremental mailbox synchronization and extensible fetched data --- src/Connection/ConnectionInterface.php | 25 ++- src/Connection/ImapConnection.php | 102 +++++++++++- src/Connection/ImapTokenizer.php | 11 ++ src/FetchedMessageData.php | 170 ++++++++++++++++---- src/FileMessage.php | 8 + src/Folder.php | 8 +- src/FolderInterface.php | 2 +- src/Mailbox.php | 63 +++++++- src/MailboxInterface.php | 8 +- src/Message.php | 125 ++++++++------- src/MessageChanges.php | 76 +++++++++ src/MessageData.php | 8 + src/MessageData/Attribute.php | 1 + src/MessageInterface.php | 5 + src/MessageQuery.php | 32 +++- src/MessageQueryInterface.php | 5 + src/Selection/CondStore.php | 24 +++ src/Selection/QuickResync.php | 40 +++++ src/SelectionOption.php | 16 ++ src/SelectionResult.php | 161 +++++++++++++++++++ src/StoreResult.php | 83 ++++++++++ src/Support/Str.php | 26 +++ src/Testing/FakeFolder.php | 8 +- src/Testing/FakeMailbox.php | 15 +- src/Testing/FakeMessage.php | 9 ++ src/Testing/FakeMessageQuery.php | 23 +++ src/Vanished.php | 49 ++++++ tests/Unit/FetchedMessageDataTest.php | 208 +++++++++++++++++++++++- tests/Unit/FolderTest.php | 18 +++ tests/Unit/IncrementalSyncTest.php | 214 +++++++++++++++++++++++++ tests/Unit/MessageDataTest.php | 1 + tests/Unit/MessageTest.php | 173 ++++++++++++++++---- tests/Unit/Support/StrTest.php | 4 + 33 files changed, 1574 insertions(+), 147 deletions(-) create mode 100644 src/MessageChanges.php create mode 100644 src/Selection/CondStore.php create mode 100644 src/Selection/QuickResync.php create mode 100644 src/SelectionOption.php create mode 100644 src/SelectionResult.php create mode 100644 src/StoreResult.php create mode 100644 src/Vanished.php create mode 100644 tests/Unit/IncrementalSyncTest.php diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 32ec711..7a432cb 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -9,6 +9,10 @@ use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\ImapSort; +use DirectoryTree\ImapEngine\MessageChanges; +use DirectoryTree\ImapEngine\SelectionOption; +use DirectoryTree\ImapEngine\SelectionResult; +use DirectoryTree\ImapEngine\StoreResult; use Generator; interface ConnectionInterface @@ -87,6 +91,13 @@ public function done(): void; */ public function noop(): TaggedResponse; + /** + * Send an "ENABLE" command. + * + * @see https://datatracker.ietf.org/doc/html/rfc5161 + */ + public function enable(string ...$capabilities): ResponseCollection; + /** * Send a "EXPUNGE" command. * @@ -195,6 +206,11 @@ public function flags(int|array $ids): ResponseCollection; */ public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection; + /** + * Fetch messages changed after the given modification sequence. + */ + public function fetchChanges(array|string $items, array|int $uids, int $modSequence, bool $vanished = false): MessageChanges; + /** * Send a "RFC822.SIZE" command. * @@ -216,7 +232,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, SelectionOption ...$options): SelectionResult; /** * Send a "EXAMINE" command. @@ -225,7 +241,7 @@ 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, SelectionOption ...$options): SelectionResult; /** * Send a "LIST" command. @@ -254,6 +270,11 @@ public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', */ public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection; + /** + * Store flags only when messages have not changed after the given modification sequence. + */ + public function storeConditionally(array|string $flags, array|int $uids, int $unchangedSince, ?string $mode = null, bool $silent = true): StoreResult; + /** * Send a "APPEND" command. * diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 7080f66..3098a3f 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -23,6 +23,10 @@ use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; use DirectoryTree\ImapEngine\ImapSort; +use DirectoryTree\ImapEngine\MessageChanges; +use DirectoryTree\ImapEngine\SelectionOption; +use DirectoryTree\ImapEngine\SelectionResult; +use DirectoryTree\ImapEngine\StoreResult; use DirectoryTree\ImapEngine\Support\Str; use Exception; use Generator; @@ -224,29 +228,52 @@ 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', SelectionOption ...$options): SelectionResult { - return $this->examineOrSelect('EXAMINE', $folder); + return $this->examineOrSelect('SELECT', $folder, $options); + } + + /** + * {@inheritDoc} + */ + public function examine(string $folder = 'INBOX', SelectionOption ...$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 (SelectionOption $option) => $option->toImap(), + $options, + )); + } + + $this->send($command, $tokens, $tag); $this->assertTaggedResponse($tag); - return $this->result->responses()->untagged(); + return SelectionResult::fromResponses($this->result->responses()); } /** @@ -441,6 +468,30 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st ); } + /** + * {@inheritDoc} + */ + public function storeConditionally(array|string $flags, array|int $uids, int $unchangedSince, ?string $mode = null, bool $silent = true): StoreResult + { + $item = ($mode === '-' ? '-' : '+').'FLAGS'.($silent ? '.SILENT' : ''); + + $this->send('UID STORE', [ + Str::set($uids), + Str::list(['UNCHANGEDSINCE', $unchangedSince]), + $item, + Str::list((array) $flags), + ], $tag); + + $response = $this->taggedResponse($tag); + $result = StoreResult::fromResponses($this->result->responses(), $response); + + if ($response->status()->is('BAD') || ($response->failed() && empty($result->modified()))) { + throw ImapCommandException::make($this->result->command(), $response); + } + + return $result; + } + /** * {@inheritDoc} */ @@ -706,6 +757,28 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, Im }); } + /** + * {@inheritDoc} + */ + public function fetchChanges(array|string $items, array|int $uids, int $modSequence, bool $vanished = false): MessageChanges + { + $modifiers = ['CHANGEDSINCE', $modSequence]; + + if ($vanished) { + $modifiers[] = 'VANISHED'; + } + + $this->send('UID FETCH', [ + Str::set($uids), + Str::list((array) $items), + Str::list($modifiers), + ], $tag); + + $this->assertTaggedResponse($tag); + + return MessageChanges::fromResponses($this->result->responses()); + } + /** * Set the current result instance. */ @@ -759,6 +832,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/FetchedMessageData.php b/src/FetchedMessageData.php index f38fcb1..5fe8ad5 100644 --- a/src/FetchedMessageData.php +++ b/src/FetchedMessageData.php @@ -2,23 +2,25 @@ 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, - ) {} + public function __construct(protected array $attributes = []) + { + $this->attributes = array_change_key_case($attributes, CASE_UPPER); + } /** * Create message data from an IMAP FETCH response. @@ -35,16 +37,60 @@ 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 +98,81 @@ 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 + { + return ($size = $this->get('RFC822.SIZE')) !== null ? (int) $size : null; + } + + /** + * 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 $sequence !== null ? (int) $sequence : null; + } + + /** + * 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 +180,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..bd73da6 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -85,7 +85,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); } @@ -173,9 +173,9 @@ public function move(string $newPath): void /** * {@inheritDoc} */ - public function select(bool $force = false): void + public function select(bool $force = false, SelectionOption ...$options): SelectionResult { - $this->mailbox->select($this, $force); + return $this->mailbox->select($this, $force, ...$options); } /** @@ -233,7 +233,7 @@ public function status(): array */ public function examine(): array { - return $this->mailbox->connection()->examine($this->path)->map( + return $this->mailbox->connection()->examine($this->path)->responses()->untagged()->map( fn (UntaggedResponse $response) => $response->toArray() )->all(); } diff --git a/src/FolderInterface.php b/src/FolderInterface.php index 1688d53..658b644 100644 --- a/src/FolderInterface.php +++ b/src/FolderInterface.php @@ -59,7 +59,7 @@ public function move(string $newPath): void; /** * Select the current folder. */ - public function select(bool $force = false): void; + public function select(bool $force = false, SelectionOption ...$options): SelectionResult; /** * Get the folder's quotas. diff --git a/src/Mailbox.php b/src/Mailbox.php index ef4c206..9c6c0cb 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -2,12 +2,14 @@ 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 Exception; class Mailbox implements MailboxInterface @@ -47,6 +49,16 @@ class Mailbox implements MailboxInterface */ protected ?FolderInterface $selected = null; + /** + * The result from the currently selected folder. + */ + protected ?SelectionResult $selection = null; + + /** + * The capabilities enabled for the current connection. + */ + protected array $enabled = []; + /** * The mailbox connection. */ @@ -66,6 +78,9 @@ public function __construct(array $config = []) public function __clone(): void { $this->connection = null; + $this->selected = null; + $this->selection = null; + $this->enabled = []; } /** @@ -177,6 +192,9 @@ public function disconnect(): void // Do nothing. } finally { $this->connection = null; + $this->selected = null; + $this->selection = null; + $this->enabled = []; } } @@ -215,13 +233,52 @@ public function capabilities(): array /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false): void + public function enable(string ...$capabilities): ResponseCollection + { + foreach ($capabilities as $capability) { + if (! $this->hasCapability($capability)) { + throw new ImapCapabilityException( + "Unable to enable capability [$capability]. IMAP server does not support it." + ); + } + } + + $capabilities = array_values(array_diff($capabilities, $this->enabled)); + + if (empty($capabilities)) { + return new ResponseCollection; + } + + $responses = $this->connection()->enable(...$capabilities); + $this->enabled = array_unique([...$this->enabled, ...$capabilities]); + + return $responses; + } + + /** + * {@inheritDoc} + */ + public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult { - if (! $this->selected($folder) || $force) { - $this->connection()->select($folder->path()); + foreach ($options as $option) { + if (! $this->hasCapability($option->capability())) { + throw new ImapCapabilityException( + "Unable to select folder with [{$option->capability()}]. IMAP server does not support it." + ); + } + + if ($option->capability() === 'QRESYNC') { + $this->enable('QRESYNC'); + } + } + + if (! $this->selected($folder) || $force || $options) { + $this->selection = $this->connection()->select($folder->path(), ...$options); } $this->selected = $folder; + + return $this->selection ?? new SelectionResult; } /** diff --git a/src/MailboxInterface.php b/src/MailboxInterface.php index 0c0792b..d883995 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -2,6 +2,7 @@ namespace DirectoryTree\ImapEngine; +use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; interface MailboxInterface @@ -56,10 +57,15 @@ public function capabilities(): array; */ public function hasCapability(string $capability): bool; + /** + * Enable the given mailbox capabilities for the current connection. + */ + 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, SelectionOption ...$options): SelectionResult; /** * Determine if the given folder is selected. diff --git a/src/Message.php b/src/Message.php index db78a17..40ec660 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 $this->data->bodyStructure() !== null; } /** @@ -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($flag, $this->uid(), 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]), + }, + ]); } /** @@ -193,7 +208,7 @@ public function copy(string $folder): ?int ); } - $response = $mailbox->connection()->copy($folder, $this->uid); + $response = $mailbox->connection()->copy($folder, $this->uid()); return MessageResponseParser::getUidFromCopy($response); } @@ -209,7 +224,7 @@ public function move(string $folder, bool $expunge = false): ?int switch (true) { case $mailbox->hasCapability('MOVE'): - $response = $mailbox->connection()->move($folder, $this->uid); + $response = $mailbox->connection()->move($folder, $this->uid()); return MessageResponseParser::getUidFromCopy($response); @@ -445,21 +460,25 @@ public function attachmentCount(): int */ public function bodyPart(string $partNumber, bool $peek = true): ?string { + $key = "BODY[$partNumber]"; + + if ($peek && $this->data->has($key)) { + return $this->data->get($key); + } + $response = $this->folder->mailbox() ->connection() - ->bodyPart($partNumber, $this->uid, $peek); + ->bodyPart($partNumber, $this->uid(), $peek); if ($response->isEmpty()) { return null; } - $data = $response->first()->tokenAt(3); + $data = FetchedMessageData::fromResponse($response->first()); - if (! $data instanceof ListData) { - return null; - } + $this->data = $this->data->merge($data); - return $data->lookup("[$partNumber]")?->value; + return $data->get($key); } /** @@ -483,13 +502,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 +511,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 +540,17 @@ protected function fetchHead(): ?string $response = $this->folder ->mailbox() ->connection() - ->bodyHeader($this->uid); + ->bodyHeader($this->uid()); if ($response->isEmpty()) { return null; } - $data = $response->first()->tokenAt(3); + $data = FetchedMessageData::fromResponse($response->first()); - if (! $data instanceof ListData) { - return null; - } + $this->data = $this->data->merge($data); - return $data->lookup('[HEADER]')?->value; + return $data->get('BODY[HEADER]'); } /** @@ -550,18 +561,16 @@ protected function fetchBodyStructureData(): ?ListData $response = $this->folder ->mailbox() ->connection() - ->bodyStructure($this->uid); + ->bodyStructure($this->uid()); if ($response->isEmpty()) { return null; } - $data = $response->first()->tokenAt(3); + $data = FetchedMessageData::fromResponse($response->first()); - if (! $data instanceof ListData) { - return null; - } + $this->data = $this->data->merge($data); - return $data->lookup('BODYSTRUCTURE'); + return $data->bodyStructure(); } } diff --git a/src/MessageChanges.php b/src/MessageChanges.php new file mode 100644 index 0000000..66fdbf6 --- /dev/null +++ b/src/MessageChanges.php @@ -0,0 +1,76 @@ +untagged() as $response) { + if ($response->type()->is('VANISHED')) { + $vanished[] = Vanished::fromResponse($response); + } elseif (($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH')) { + $messages[] = FetchedMessageData::fromResponse($response); + } + } + + return new static($messages, $vanished, $responses); + } + + /** + * Get the changed 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/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..bcf3b01 100644 --- a/src/MessageData/Attribute.php +++ b/src/MessageData/Attribute.php @@ -7,6 +7,7 @@ enum Attribute: string implements FetchItem case Flags = 'FLAGS'; case Size = 'RFC822.SIZE'; case BodyStructure = 'BODYSTRUCTURE'; + case ModSequence = 'MODSEQ'; /** * {@inheritDoc} 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..f9c21c0 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -74,6 +74,36 @@ public function get(): MessageCollection return $this->process($this->uids()); } + /** + * {@inheritDoc} + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): MessageChanges + { + $capability = $vanished ? 'QRESYNC' : 'CONDSTORE'; + + $mailbox = $this->folder->mailbox(); + + $supported = $mailbox->hasCapability($capability) + || ($capability === 'CONDSTORE' && $mailbox->hasCapability('QRESYNC')); + + if (! $supported) { + throw new ImapCapabilityException( + "Unable to fetch message changes. IMAP server does not support $capability capability." + ); + } + + $items = array_map( + fn (FetchItem $item) => $item->toImap(), + $this->fetchItems, + ); + + if (empty($items)) { + $items[] = MessageData::flags()->toImap(); + } + + return $this->connection()->fetchChanges($items, $uids, $modSequence, $vanished); + } + /** * Append a new message to the folder. */ @@ -356,7 +386,7 @@ protected function fetch(Collection $messages): array if (empty($fetch)) { return $uids->mapWithKeys(fn (string|int $uid) => [ - $uid => new FetchedMessageData((int) $uid), + $uid => new FetchedMessageData(['UID' => (int) $uid]), ])->all(); } diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index ccf5cad..c77df40 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -94,6 +94,11 @@ public function firstOrFail(): MessageInterface; */ public function get(): MessageCollection; + /** + * Get messages changed after the given modification sequence. + */ + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): MessageChanges; + /** * Append a new message to the folder. */ diff --git a/src/Selection/CondStore.php b/src/Selection/CondStore.php new file mode 100644 index 0000000..179e681 --- /dev/null +++ b/src/Selection/CondStore.php @@ -0,0 +1,24 @@ +uidValidity, $this->highestModSequence]; + + if ($this->knownUids) { + $parameters[] = Str::set($this->knownUids); + } + + return 'QRESYNC '.Str::list($parameters); + } +} diff --git a/src/SelectionOption.php b/src/SelectionOption.php new file mode 100644 index 0000000..d2ab71f --- /dev/null +++ b/src/SelectionOption.php @@ -0,0 +1,16 @@ +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, + MessageChanges::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(): MessageChanges + { + return $this->changes ?? new MessageChanges; + } + + /** + * 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/StoreResult.php b/src/StoreResult.php new file mode 100644 index 0000000..9897aef --- /dev/null +++ b/src/StoreResult.php @@ -0,0 +1,83 @@ +untagged() + ->filter(fn (UntaggedResponse $response) => ($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH')) + ->map(fn (UntaggedResponse $response) => FetchedMessageData::fromResponse($response)) + ->values() + ->all(); + + $code = $response->tokenAt(2); + $modified = $code instanceof ResponseCodeData && strtoupper($code->first()?->value ?? '') === 'MODIFIED' + ? Str::parseSequenceSet($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 rejected because they were modified after the checkpoint. + */ + public function modified(): array + { + return $this->modified; + } + + /** + * Get the UIDs rejected because they were modified after the checkpoint. + */ + public function modifiedUids(): 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..e85204e 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -98,6 +98,32 @@ 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 parseSequenceSet(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)); + + foreach (range($start, $end) as $value) { + $values[] = $value; + } + } + + return $values; + } + /** * Convert the values into an IMAP sequence set. * diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index d1e9d21..2aad262 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\SelectionOption; +use DirectoryTree\ImapEngine\SelectionResult; 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, SelectionOption ...$options): SelectionResult { - $this->mailbox?->select($this, $force); + return $this->mailbox?->select($this, $force, ...$options) ?? new SelectionResult; } /** diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index f7d2275..2d297f8 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -2,12 +2,15 @@ namespace DirectoryTree\ImapEngine\Testing; +use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; use DirectoryTree\ImapEngine\Exceptions\Exception; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; use DirectoryTree\ImapEngine\HasCapabilities; use DirectoryTree\ImapEngine\MailboxInterface; +use DirectoryTree\ImapEngine\SelectionOption; +use DirectoryTree\ImapEngine\SelectionResult; class FakeMailbox implements MailboxInterface { @@ -111,9 +114,19 @@ public function capabilities(): array /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false): void + public function enable(string ...$capabilities): ResponseCollection + { + return new ResponseCollection; + } + + /** + * {@inheritDoc} + */ + public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult { $this->selected = $folder; + + return new SelectionResult; } /** 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..7ffdcb4 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -10,6 +10,8 @@ use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Enums\SortDirection; +use DirectoryTree\ImapEngine\FetchedMessageData; +use DirectoryTree\ImapEngine\MessageChanges; 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): MessageChanges + { + $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 MessageChanges($messages); + } + /** * {@inheritDoc} */ diff --git a/src/Vanished.php b/src/Vanished.php new file mode 100644 index 0000000..70a8d05 --- /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::parseSequenceSet($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/Unit/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php index 68d015f..f4e3fa9 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('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'); }); diff --git a/tests/Unit/FolderTest.php b/tests/Unit/FolderTest.php index cc1e483..7faac60 100644 --- a/tests/Unit/FolderTest.php +++ b/tests/Unit/FolderTest.php @@ -5,6 +5,24 @@ use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; +test('it examines a folder using the typed selection result', function () { + $mailbox = Mailbox::make(); + $mailbox->connect(ImapConnection::fake([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 3 EXISTS', + '* OK [UIDVALIDITY 777] UIDs valid', + 'TAG2 OK EXAMINE completed', + ])); + + $folder = new Folder($mailbox, 'INBOX'); + + expect($folder->examine())->toBe([ + ['*', '3', 'EXISTS'], + ['*', 'OK', ['UIDVALIDITY', '777'], 'UIDs', 'valid'], + ]); +}); + test('it properly decodes name from UTF-7', function () { $mailbox = Mailbox::make(); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php new file mode 100644 index 0000000..a4cfa92 --- /dev/null +++ b/tests/Unit/IncrementalSyncTest.php @@ -0,0 +1,214 @@ +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->fetchChanges('FLAGS', [1, 2, 3, 4, 6, 7], 42, 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->storeConditionally('\\Flagged', 7, 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->storeConditionally('\\Seen', [7, 8, 9], 43); + + expect($result->successful())->toBeFalse(); + expect($result->modified())->toBe([8, 9]); + expect($result->modifiedUids())->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(); + + $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('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); +}); diff --git a/tests/Unit/MessageDataTest.php b/tests/Unit/MessageDataTest.php index 0536943..20e002b 100644 --- a/tests/Unit/MessageDataTest.php +++ b/tests/Unit/MessageDataTest.php @@ -10,6 +10,7 @@ [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) { 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..8b92087 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::parseSequenceSet('1:3,7,10:8'))->toBe([1, 2, 3, 7, 10, 9, 8]); +}); + test('credentials', function () { expect(Str::credentials('foo', 'bar'))->toBe('dXNlcj1mb28BYXV0aD1CZWFyZXIgYmFyAQE='); }); From 61c0b43d1a6689d85f481185e5153a4dbd6d56b8 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 15:29:48 -0400 Subject: [PATCH 02/16] Support incremental synchronization through fetch modifiers --- src/Connection/ConnectionInterface.php | 25 ++- src/Connection/ImapConnection.php | 56 +++---- src/Fetch/ChangedSince.php | 32 ++++ src/FetchModifier.php | 11 ++ src/{MessageChanges.php => FetchResult.php} | 13 +- src/Message.php | 12 +- src/MessageQuery.php | 43 ++--- src/MessageQueryInterface.php | 2 +- src/SelectionResult.php | 8 +- src/Testing/FakeMessageQuery.php | 6 +- tests/Unit/Connection/ImapConnectionTest.php | 156 ++++++++++++++++++- tests/Unit/IncrementalSyncTest.php | 3 +- tests/Unit/MessageQueryTest.php | 56 +++++++ 13 files changed, 318 insertions(+), 105 deletions(-) create mode 100644 src/Fetch/ChangedSince.php create mode 100644 src/FetchModifier.php rename src/{MessageChanges.php => FetchResult.php} (81%) diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 7a432cb..2135036 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -8,8 +8,9 @@ use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; +use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; -use DirectoryTree\ImapEngine\MessageChanges; use DirectoryTree\ImapEngine\SelectionOption; use DirectoryTree\ImapEngine\SelectionResult; use DirectoryTree\ImapEngine\StoreResult; @@ -150,7 +151,7 @@ public function id(?array $ids = null): UntaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection; + public function uid(int|array $ids, ImapFetchIdentifier $identifier): FetchResult; /** * Send a "FETCH BODY[TEXT]" command. @@ -159,7 +160,7 @@ public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCo * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 */ - public function bodyText(int|array $ids, bool $peek = true): ResponseCollection; + public function bodyText(int|array $ids, bool $peek = true): FetchResult; /** * Send a "FETCH BODY[HEADER]" command. @@ -168,7 +169,7 @@ public function bodyText(int|array $ids, bool $peek = true): ResponseCollection; * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 */ - public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection; + public function bodyHeader(int|array $ids, bool $peek = true): FetchResult; /** * Send a "FETCH BODYSTRUCTURE" command. @@ -177,7 +178,7 @@ public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollectio * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 */ - public function bodyStructure(int|array $ids): ResponseCollection; + public function bodyStructure(int|array $ids): FetchResult; /** * Send a "FETCH BODY[i]" command. @@ -186,7 +187,7 @@ public function bodyStructure(int|array $ids): ResponseCollection; * * @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; + public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): FetchResult; /** * Send a "FETCH FLAGS" command. @@ -195,7 +196,7 @@ public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17 */ - public function flags(int|array $ids): ResponseCollection; + public function flags(int|array $ids): FetchResult; /** * Send a "FETCH" command. @@ -203,13 +204,9 @@ public function flags(int|array $ids): ResponseCollection; * Fetch one or more items for one or more messages. * * @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; - - /** - * Fetch messages changed after the given modification sequence. - */ - public function fetchChanges(array|string $items, array|int $uids, int $modSequence, bool $vanished = false): MessageChanges; + public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; /** * Send a "RFC822.SIZE" command. @@ -218,7 +215,7 @@ public function fetchChanges(array|string $items, array|int $uids, int $modSeque * * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21 */ - public function size(int|array $ids): ResponseCollection; + public function size(int|array $ids): FetchResult; /** * Send an IMAP command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 3098a3f..fc146c2 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -22,8 +22,9 @@ use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; +use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; -use DirectoryTree\ImapEngine\MessageChanges; use DirectoryTree\ImapEngine\SelectionOption; use DirectoryTree\ImapEngine\SelectionResult; use DirectoryTree\ImapEngine\StoreResult; @@ -495,7 +496,7 @@ public function storeConditionally(array|string $flags, array|int $uids, int $un /** * {@inheritDoc} */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection + public function uid(int|array $ids, ImapFetchIdentifier $identifier): FetchResult { return $this->fetch(['UID'], (array) $ids, null, $identifier); } @@ -503,7 +504,7 @@ public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCo /** * {@inheritDoc} */ - public function bodyText(int|array $ids, bool $peek = true): ResponseCollection + public function bodyText(int|array $ids, bool $peek = true): FetchResult { return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids); } @@ -511,7 +512,7 @@ public function bodyText(int|array $ids, bool $peek = true): ResponseCollection /** * {@inheritDoc} */ - public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection + public function bodyHeader(int|array $ids, bool $peek = true): FetchResult { return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids); } @@ -519,7 +520,7 @@ public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollectio /** * Fetch the BODYSTRUCTURE for the given message(s). */ - public function bodyStructure(int|array $ids): ResponseCollection + public function bodyStructure(int|array $ids): FetchResult { return $this->fetch(['BODYSTRUCTURE'], (array) $ids); } @@ -527,7 +528,7 @@ public function bodyStructure(int|array $ids): ResponseCollection /** * 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 + public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): FetchResult { $part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]"; @@ -537,7 +538,7 @@ public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): /** * {@inheritDoc} */ - public function flags(int|array $ids): ResponseCollection + public function flags(int|array $ids): FetchResult { return $this->fetch(['FLAGS'], (array) $ids); } @@ -545,7 +546,7 @@ public function flags(int|array $ids): ResponseCollection /** * {@inheritDoc} */ - public function size(int|array $ids): ResponseCollection + public function size(int|array $ids): FetchResult { return $this->fetch(['RFC822.SIZE'], (array) $ids); } @@ -723,14 +724,23 @@ protected function write(string $data): void /** * Fetch one or more items for one or more messages. */ - public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection + public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid, FetchModifier ...$modifiers): FetchResult { $prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : ''; - $this->send(trim($prefix.' FETCH'), [ + $tokens = [ Str::set($from, $to), Str::list((array) $items), - ], $tag); + ]; + + if ($modifiers) { + $tokens[] = Str::list(array_map( + fn (FetchModifier $modifier) => $modifier->toImap(), + $modifiers, + )); + } + + $this->send(trim($prefix.' FETCH'), $tokens, $tag); $this->assertTaggedResponse($tag); @@ -740,7 +750,7 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, Im // >> TAG123 FETCH (UID 456 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) { + return FetchResult::fromResponses($this->result->responses(), 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) { @@ -757,28 +767,6 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, Im }); } - /** - * {@inheritDoc} - */ - public function fetchChanges(array|string $items, array|int $uids, int $modSequence, bool $vanished = false): MessageChanges - { - $modifiers = ['CHANGEDSINCE', $modSequence]; - - if ($vanished) { - $modifiers[] = 'VANISHED'; - } - - $this->send('UID FETCH', [ - Str::set($uids), - Str::list((array) $items), - Str::list($modifiers), - ], $tag); - - $this->assertTaggedResponse($tag); - - return MessageChanges::fromResponses($this->result->responses()); - } - /** * Set the current result instance. */ diff --git a/src/Fetch/ChangedSince.php b/src/Fetch/ChangedSince.php new file mode 100644 index 0000000..37532d2 --- /dev/null +++ b/src/Fetch/ChangedSince.php @@ -0,0 +1,32 @@ +modSequence.($this->vanished ? ' VANISHED' : ''); + } +} diff --git a/src/FetchModifier.php b/src/FetchModifier.php new file mode 100644 index 0000000..f964b47 --- /dev/null +++ b/src/FetchModifier.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')) { + } elseif ( + ($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH') + && (! $filter || $filter($response)) + ) { $messages[] = FetchedMessageData::fromResponse($response); } } @@ -36,7 +39,7 @@ public static function fromResponses(ResponseCollection $responses): static } /** - * Get the changed messages. + * Get the fetched messages. * * @return FetchedMessageData[] */ diff --git a/src/Message.php b/src/Message.php index 40ec660..a6412b1 100644 --- a/src/Message.php +++ b/src/Message.php @@ -470,12 +470,10 @@ public function bodyPart(string $partNumber, bool $peek = true): ?string ->connection() ->bodyPart($partNumber, $this->uid(), $peek); - if ($response->isEmpty()) { + if (! $data = $response->messages()[0] ?? null) { return null; } - $data = FetchedMessageData::fromResponse($response->first()); - $this->data = $this->data->merge($data); return $data->get($key); @@ -542,12 +540,10 @@ protected function fetchHead(): ?string ->connection() ->bodyHeader($this->uid()); - if ($response->isEmpty()) { + if (! $data = $response->messages()[0] ?? null) { return null; } - $data = FetchedMessageData::fromResponse($response->first()); - $this->data = $this->data->merge($data); return $data->get('BODY[HEADER]'); @@ -563,12 +559,10 @@ protected function fetchBodyStructureData(): ?ListData ->connection() ->bodyStructure($this->uid()); - if ($response->isEmpty()) { + if (! $data = $response->messages()[0] ?? null) { return null; } - $data = FetchedMessageData::fromResponse($response->first()); - $this->data = $this->data->merge($data); return $data->bodyStructure(); diff --git a/src/MessageQuery.php b/src/MessageQuery.php index f9c21c0..930cac0 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -5,16 +5,15 @@ 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\SortDirection; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; +use DirectoryTree\ImapEngine\Fetch\ChangedSince; use DirectoryTree\ImapEngine\MessageData\FetchItem; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; use DirectoryTree\ImapEngine\Support\Str; @@ -77,7 +76,7 @@ public function get(): MessageCollection /** * {@inheritDoc} */ - public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): MessageChanges + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult { $capability = $vanished ? 'QRESYNC' : 'CONDSTORE'; @@ -101,7 +100,9 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = $items[] = MessageData::flags()->toImap(); } - return $this->connection()->fetchChanges($items, $uids, $modSequence, $vanished); + return $this->connection()->fetch( + $items, $uids, modifiers: new ChangedSince($modSequence, $vanished), + ); } /** @@ -197,14 +198,9 @@ public function paginate(int $perPage = 5, $page = null, string $pageName = 'pag */ public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::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(); } /** @@ -212,17 +208,13 @@ public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchI */ public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::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(); } /** @@ -390,11 +382,8 @@ protected function fetch(Collection $messages): array ])->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($fetch, $uids->all())->messages())) + ->keyBy(fn (FetchedMessageData $data) => $data->uid()); return $uids ->map(fn (string|int $uid) => $fetched->get($uid)) @@ -462,20 +451,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, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?FetchedMessageData { try { - return $this->connection()->uid([$id], $identifier); + return $this->connection()->uid([$id], $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 && 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 c77df40..cd96f73 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -97,7 +97,7 @@ public function get(): MessageCollection; /** * Get messages changed after the given modification sequence. */ - public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): MessageChanges; + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult; /** * Append a new message to the folder. diff --git a/src/SelectionResult.php b/src/SelectionResult.php index 5d03f33..b05cbe1 100644 --- a/src/SelectionResult.php +++ b/src/SelectionResult.php @@ -21,7 +21,7 @@ public function __construct( protected ?int $highestModSequence = null, protected array $permanentFlags = [], protected bool $supportsModSequences = true, - protected ?MessageChanges $changes = null, + protected ?FetchResult $changes = null, protected ?ResponseCollection $responses = null, ) {} @@ -74,7 +74,7 @@ public static function fromResponses(ResponseCollection $responses): static $highestModSequence, $permanentFlags, $supportsModSequences, - MessageChanges::fromResponses($responses), + FetchResult::fromResponses($responses), $responses, ); } @@ -138,9 +138,9 @@ public function supportsModSequences(): bool /** * Get changes returned while selecting the folder. */ - public function changes(): MessageChanges + public function changes(): FetchResult { - return $this->changes ?? new MessageChanges; + return $this->changes ?? new FetchResult; } /** diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index 7ffdcb4..fca2566 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -11,7 +11,7 @@ use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Enums\SortDirection; use DirectoryTree\ImapEngine\FetchedMessageData; -use DirectoryTree\ImapEngine\MessageChanges; +use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; @@ -45,7 +45,7 @@ public function get(): MessageCollection /** * {@inheritDoc} */ - public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): MessageChanges + public function changesSince(int $modSequence, array|int $uids, bool $vanished = false): FetchResult { $uids = (array) $uids; @@ -60,7 +60,7 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = ->values() ->all(); - return new MessageChanges($messages); + return new FetchResult($messages); } /** diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index c0aa9b5..ae4cde5 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -8,6 +8,9 @@ use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionException; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException; +use DirectoryTree\ImapEngine\Fetch\ChangedSince; +use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\Support\Str; test('connect success', function () { @@ -570,7 +573,8 @@ $stream->assertWritten('TAG1 UID 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('uid fetch with message number', function () { @@ -590,7 +594,8 @@ $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 () { @@ -612,7 +617,7 @@ $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 () { @@ -633,7 +638,7 @@ $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 () { @@ -653,7 +658,7 @@ $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 () { @@ -673,7 +678,7 @@ $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 () { @@ -877,5 +882,142 @@ $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); - expect((string) $responses->first())->toBe("* 1 FETCH (UID 123 FLAGS (\Seen))"); + 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('FLAGS', 1, INF, 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( + 'FLAGS', [1, 2], identifier: ImapFetchIdentifier::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('FLAGS', [1, 2, 4, 7], 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', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->fetch('FLAGS', [1, 2], 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 FetchModifier + { + public function toImap(): string + { + return 'X-CUSTOM'; + } + }; + + $result = $connection->fetch( + 'FLAGS', [1, 2], null, ImapFetchIdentifier::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('FLAGS', 1, modifiers: new ChangedSince(42))) + ->toThrow(ImapCommandException::class); }); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php index a4cfa92..0ed2c96 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -2,6 +2,7 @@ use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; +use DirectoryTree\ImapEngine\Fetch\ChangedSince; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\Selection\CondStore; @@ -107,7 +108,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $changes = $connection->fetchChanges('FLAGS', [1, 2, 3, 4, 6, 7], 42, true); + $changes = $connection->fetch('FLAGS', [1, 2, 3, 4, 6, 7], modifiers: new ChangedSince(42, vanished: true)); $stream->assertWritten('TAG1 UID FETCH 1:4,6:7 (FLAGS) (CHANGEDSINCE 42 VANISHED)'); expect($changes->messages())->toHaveCount(1); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index bf0f3c1..a4acb0e 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -3,6 +3,7 @@ use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; +use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapFlag; use DirectoryTree\ImapEngine\Enums\ImapSortKey; use DirectoryTree\ImapEngine\Enums\SortDirection; @@ -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, ImapFetchIdentifier::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(); From 95a67d875a56617e593d2afb34c7ad4f7d92c406 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 15:37:09 -0400 Subject: [PATCH 03/16] Remove redundant connection FETCH shortcuts --- src/Connection/ConnectionInterface.php | 63 -------------------- src/Connection/ImapConnection.php | 58 ------------------ src/Message.php | 6 +- src/MessageQuery.php | 2 +- tests/Unit/Connection/ImapConnectionTest.php | 12 ++-- tests/Unit/FetchedMessageDataTest.php | 30 ++++++++++ 6 files changed, 40 insertions(+), 131 deletions(-) diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 2135036..185c527 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -144,60 +144,6 @@ public function sort(ImapSort $sort, array $params): UntaggedResponse; */ 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): FetchResult; - - /** - * 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): FetchResult; - - /** - * Send a "FETCH BODY[HEADER]" command. - * - * Fetch message headers. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9 - */ - public function bodyHeader(int|array $ids, bool $peek = true): FetchResult; - - /** - * 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): FetchResult; - - /** - * 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): FetchResult; - - /** - * Send a "FETCH FLAGS" command. - * - * Fetch a message flags. - * - * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17 - */ - public function flags(int|array $ids): FetchResult; - /** * Send a "FETCH" command. * @@ -208,15 +154,6 @@ public function flags(int|array $ids): FetchResult; */ public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; - /** - * 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): FetchResult; - /** * Send an IMAP command. */ diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index fc146c2..2439d4d 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -493,64 +493,6 @@ public function storeConditionally(array|string $flags, array|int $uids, int $un return $result; } - /** - * {@inheritDoc} - */ - public function uid(int|array $ids, ImapFetchIdentifier $identifier): FetchResult - { - return $this->fetch(['UID'], (array) $ids, null, $identifier); - } - - /** - * {@inheritDoc} - */ - public function bodyText(int|array $ids, bool $peek = true): FetchResult - { - return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids); - } - - /** - * {@inheritDoc} - */ - public function bodyHeader(int|array $ids, bool $peek = true): FetchResult - { - 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): FetchResult - { - 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): FetchResult - { - $part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]"; - - return $this->fetch([$part], (array) $ids); - } - - /** - * {@inheritDoc} - */ - public function flags(int|array $ids): FetchResult - { - return $this->fetch(['FLAGS'], (array) $ids); - } - - /** - * {@inheritDoc} - */ - public function size(int|array $ids): FetchResult - { - return $this->fetch(['RFC822.SIZE'], (array) $ids); - } - /** * {@inheritDoc} */ diff --git a/src/Message.php b/src/Message.php index a6412b1..56158ff 100644 --- a/src/Message.php +++ b/src/Message.php @@ -468,7 +468,7 @@ public function bodyPart(string $partNumber, bool $peek = true): ?string $response = $this->folder->mailbox() ->connection() - ->bodyPart($partNumber, $this->uid(), $peek); + ->fetch($peek ? "BODY.PEEK[$partNumber]" : "BODY[$partNumber]", $this->uid()); if (! $data = $response->messages()[0] ?? null) { return null; @@ -538,7 +538,7 @@ protected function fetchHead(): ?string $response = $this->folder ->mailbox() ->connection() - ->bodyHeader($this->uid()); + ->fetch('BODY.PEEK[HEADER]', $this->uid()); if (! $data = $response->messages()[0] ?? null) { return null; @@ -557,7 +557,7 @@ protected function fetchBodyStructureData(): ?ListData $response = $this->folder ->mailbox() ->connection() - ->bodyStructure($this->uid()); + ->fetch('BODYSTRUCTURE', $this->uid()); if (! $data = $response->messages()[0] ?? null) { return null; diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 930cac0..228dea1 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -454,7 +454,7 @@ protected function sort(ImapSort $sort): Collection protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?FetchedMessageData { try { - return $this->connection()->uid([$id], $identifier)->messages()[0] ?? null; + return $this->connection()->fetch('UID', $id, 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 diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index ae4cde5..681562c 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -569,7 +569,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->uid(1, ImapFetchIdentifier::Uid); + $responses = $connection->fetch('UID', 1); $stream->assertWritten('TAG1 UID FETCH 1 (UID)'); @@ -590,7 +590,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->uid(1, ImapFetchIdentifier::MessageNumber); + $responses = $connection->fetch('UID', 1, identifier: ImapFetchIdentifier::MessageNumber); $stream->assertWritten('TAG1 FETCH 1 (UID)'); @@ -613,7 +613,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->bodyText(1); + $responses = $connection->fetch('BODY.PEEK[TEXT]', 1); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[TEXT])'); @@ -634,7 +634,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->bodyHeader(1); + $responses = $connection->fetch('BODY.PEEK[HEADER]', 1); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[HEADER])'); @@ -654,7 +654,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->flags(1); + $responses = $connection->fetch('FLAGS', 1); $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); @@ -674,7 +674,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->size(1); + $responses = $connection->fetch('RFC822.SIZE', 1); $stream->assertWritten('TAG1 UID FETCH 1 (RFC822.SIZE)'); diff --git a/tests/Unit/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php index f4e3fa9..94d73c0 100644 --- a/tests/Unit/FetchedMessageDataTest.php +++ b/tests/Unit/FetchedMessageDataTest.php @@ -204,6 +204,36 @@ $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(); From e5a2034eaf50e417d1ec495da8f7b575c580562e Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 15:51:20 -0400 Subject: [PATCH 04/16] Unify IMAP operation identifiers and STORE modifiers --- src/Connection/ConnectionInterface.php | 43 ++- src/Connection/ImapConnection.php | 80 +++-- ...FetchIdentifier.php => ImapIdentifier.php} | 2 +- src/Folder.php | 8 +- src/MessageQuery.php | 10 +- src/MessageQueryInterface.php | 6 +- src/Store/UnchangedSince.php | 26 ++ src/StoreModifier.php | 11 + src/StoreResult.php | 10 +- src/Testing/FakeMessageQuery.php | 6 +- .../ImapConnectionOperationsTest.php | 275 ++++++++++++++++++ tests/Unit/Connection/ImapConnectionTest.php | 22 +- tests/Unit/IncrementalSyncTest.php | 6 +- tests/Unit/MessageQueryTest.php | 4 +- 14 files changed, 405 insertions(+), 104 deletions(-) rename src/Enums/{ImapFetchIdentifier.php => ImapIdentifier.php} (78%) create mode 100644 src/Store/UnchangedSince.php create mode 100644 src/StoreModifier.php create mode 100644 tests/Unit/Connection/ImapConnectionOperationsTest.php diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 185c527..14e8ec0 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -7,12 +7,13 @@ 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\FetchModifier; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\SelectionOption; use DirectoryTree\ImapEngine\SelectionResult; +use DirectoryTree\ImapEngine\StoreModifier; use DirectoryTree\ImapEngine\StoreResult; use Generator; @@ -79,7 +80,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 */ @@ -100,9 +101,9 @@ public function noop(): TaggedResponse; public function enable(string ...$capabilities): ResponseCollection; /** - * Send a "EXPUNGE" command. + * Send an "EXPUNGE" or "UID EXPUNGE" command. * - * Apply session saved changes to the server. + * Permanently remove deleted messages, optionally restricted to the given UIDs. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command */ @@ -120,23 +121,23 @@ public function capability(): UntaggedResponse; /** * Send a "SEARCH" command. * - * Execute a search request. + * Execute a search request, returning UIDs by default. * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command */ - public function search(array $params): UntaggedResponse; + public function search(array $params, 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 $params, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse; /** - * Send a "FETCH" command. + * Send an "ID" command. * * Exchange identification information. * @@ -152,7 +153,7 @@ public function id(?array $ids = null): UntaggedResponse; * @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, FetchModifier ...$modifiers): FetchResult; + public function fetch(array|string $items, array|int $from, mixed $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; /** * Send an IMAP command. @@ -198,16 +199,12 @@ public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', /** * 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; - - /** - * Store flags only when messages have not changed after the given modification sequence. - */ - public function storeConditionally(array|string $flags, array|int $uids, int $unchangedSince, ?string $mode = null, bool $silent = true): StoreResult; + public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = '+', bool $silent = true, ?string $item = null, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult; /** * Send a "APPEND" command. @@ -219,22 +216,22 @@ public function storeConditionally(array|string $flags, array|int $uids, int $un 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(string $folder, array|int $from, ?int $to = null, 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(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; /** * Send a "CREATE" command. @@ -288,7 +285,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. @@ -297,5 +294,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 2439d4d..0d48b1d 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -15,7 +15,7 @@ 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; @@ -27,6 +27,7 @@ use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\SelectionOption; use DirectoryTree\ImapEngine\SelectionResult; +use DirectoryTree\ImapEngine\StoreModifier; use DirectoryTree\ImapEngine\StoreResult; use DirectoryTree\ImapEngine\Support\Str; use Exception; @@ -199,7 +200,13 @@ 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(); + } } /** @@ -351,7 +358,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); @@ -365,14 +372,14 @@ 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') ); } @@ -426,9 +433,9 @@ 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(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { - $this->send('UID COPY', [ + $this->send($identifier === ImapIdentifier::Uid ? 'UID COPY' : 'COPY', [ Str::set($from, $to), Str::literal($folder), ], $tag); @@ -439,9 +446,9 @@ 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(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { - $this->send('UID MOVE', [ + $this->send($identifier === ImapIdentifier::Uid ? 'UID MOVE' : 'MOVE', [ Str::set($from, $to), Str::literal($folder), ], $tag); @@ -452,36 +459,21 @@ 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|string $flags, array|int $from, ?int $to = null, ?string $mode = '+', bool $silent = true, ?string $item = null, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult { - $set = Str::set($from, $to); - - $flags = Str::list((array) $flags); - - $item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : ''); + $tokens = [Str::set($from, $to)]; - $this->send('UID STORE', [$set, $item, $flags], tag: $tag); - - $this->assertTaggedResponse($tag); - - return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter( - fn (UntaggedResponse $response) => $response->type()->is('FETCH') - ); - } + if ($modifiers) { + $tokens[] = Str::list(array_map( + fn (StoreModifier $modifier) => $modifier->toImap(), + $modifiers, + )); + } - /** - * {@inheritDoc} - */ - public function storeConditionally(array|string $flags, array|int $uids, int $unchangedSince, ?string $mode = null, bool $silent = true): StoreResult - { - $item = ($mode === '-' ? '-' : '+').'FLAGS'.($silent ? '.SILENT' : ''); + $tokens[] = $mode.($item ?? 'FLAGS').($silent ? '.SILENT' : ''); + $tokens[] = Str::list((array) $flags); - $this->send('UID STORE', [ - Str::set($uids), - Str::list(['UNCHANGEDSINCE', $unchangedSince]), - $item, - Str::list((array) $flags), - ], $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID STORE' : 'STORE', $tokens, $tag); $response = $this->taggedResponse($tag); $result = StoreResult::fromResponses($this->result->responses(), $response); @@ -496,9 +488,9 @@ public function storeConditionally(array|string $flags, array|int $uids, int $un /** * {@inheritDoc} */ - public function search(array $params): UntaggedResponse + public function search(array $params, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { - $this->send('UID SEARCH', $params, tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SEARCH' : 'SEARCH', $params, tag: $tag); $this->assertTaggedResponse($tag); @@ -510,9 +502,9 @@ public function search(array $params): UntaggedResponse /** * {@inheritDoc} */ - public function sort(ImapSort $sort, array $params): UntaggedResponse + public function sort(ImapSort $sort, array $params, 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()})", 'UTF-8', ...$params], tag: $tag); $this->assertTaggedResponse($tag); @@ -614,7 +606,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( @@ -666,9 +658,9 @@ protected function write(string $data): void /** * Fetch one or more items for one or more messages. */ - public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid, FetchModifier ...$modifiers): FetchResult + public function fetch(array|string $items, array|int $from, mixed $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult { - $prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : ''; + $prefix = ($identifier === ImapIdentifier::Uid) ? 'UID' : ''; $tokens = [ Str::set($from, $to), @@ -701,10 +693,10 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, Im 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'), + ImapIdentifier::Uid => $data->contains('UID'), // If we're fetching message numbers, we can check if the requested items are all contained in the list. - ImapFetchIdentifier::MessageNumber => $data->contains($items), + ImapIdentifier::MessageNumber => $data->contains($items), }; }); } 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/Folder.php b/src/Folder.php index bd73da6..d4b29c5 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -5,7 +5,7 @@ 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\Support\Str; @@ -109,7 +109,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( @@ -189,7 +189,9 @@ public function quota(): array ); } - $responses = $this->mailbox->connection()->quotaRoot($this->path); + $responses = $this->mailbox->connection()->getQuotaRoot($this->path)->filter( + fn (UntaggedResponse $response) => $response->type()->is('QUOTA') + ); $values = []; diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 228dea1..1fb666d 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -8,8 +8,8 @@ use DirectoryTree\ImapEngine\Connection\ConnectionInterface; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; 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; @@ -196,7 +196,7 @@ 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 { $data = $this->id($id, $identifier) ?? throw new ItemNotFoundException; @@ -206,7 +206,7 @@ public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchI /** * 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 { $data = $this->id($id, $identifier); @@ -451,7 +451,7 @@ protected function sort(ImapSort $sort): Collection /** * Get the UID for the given identifier. */ - protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?FetchedMessageData + protected function id(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?FetchedMessageData { try { return $this->connection()->fetch('UID', $id, identifier: $identifier)->messages()[0] ?? null; @@ -461,7 +461,7 @@ protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdenti // number is in the command tokens, we can assume this has occurred // and safely ignore the error and return null. if ( - $identifier === ImapFetchIdentifier::MessageNumber + $identifier === ImapIdentifier::MessageNumber && in_array($id, $e->command()->tokens()) ) { return null; diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index cd96f73..5436f63 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -6,7 +6,7 @@ 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; @@ -122,12 +122,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/Store/UnchangedSince.php b/src/Store/UnchangedSince.php new file mode 100644 index 0000000..ecbef30 --- /dev/null +++ b/src/Store/UnchangedSince.php @@ -0,0 +1,26 @@ +modSequence; + } +} diff --git a/src/StoreModifier.php b/src/StoreModifier.php new file mode 100644 index 0000000..59de9e9 --- /dev/null +++ b/src/StoreModifier.php @@ -0,0 +1,11 @@ +modified; } - /** - * Get the UIDs rejected because they were modified after the checkpoint. - */ - public function modifiedUids(): array - { - return $this->modified; - } - public function successful(): bool { return $this->response->successful(); diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index fca2566..a1fd9df 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -7,7 +7,7 @@ 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; @@ -191,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); } @@ -199,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/tests/Unit/Connection/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php new file mode 100644 index 0000000..0313362 --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -0,0 +1,275 @@ +feed([ + '* OK Welcome to IMAP', + 'TAG1 OK STORE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->store('\\Seen', [1, 2, 4], 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('\\Seen', 7, 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('\\Seen', 7); + + $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( + '\\Seen', 1, 3, 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('\\Seen', 7)) + ->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 StoreModifier + { + public function toImap(): string + { + return 'X-CUSTOM'; + } + }; + + $result = $connection->store( + '\\Seen', 7, null, '+', true, null, 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}('Archive', 1, 3, 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/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index 681562c..d5deffa 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,16 +1,16 @@ feed([ '* OK Welcome to IMAP', + '* BYE Logging out', 'TAG1 OK Logged out', ]); @@ -85,6 +86,7 @@ $connection->logout(); $stream->assertWritten('TAG1 LOGOUT'); + expect($connection->connected())->toBeFalse(); }); test('logout failure', function () { @@ -99,9 +101,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 () { @@ -549,11 +552,14 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $response = $connection->store(['\\Seen'], 1, 3, '+FLAGS'); + $response = $connection->store(['\\Seen'], 1, 3); $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 () { @@ -590,7 +596,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('UID', 1, identifier: ImapFetchIdentifier::MessageNumber); + $responses = $connection->fetch('UID', 1, identifier: ImapIdentifier::MessageNumber); $stream->assertWritten('TAG1 FETCH 1 (UID)'); @@ -923,7 +929,7 @@ $connection->connect('imap.example.com'); $result = $connection->fetch( - 'FLAGS', [1, 2], identifier: ImapFetchIdentifier::MessageNumber, + 'FLAGS', [1, 2], identifier: ImapIdentifier::MessageNumber, modifiers: new ChangedSince(0), ); @@ -999,7 +1005,7 @@ public function toImap(): string }; $result = $connection->fetch( - 'FLAGS', [1, 2], null, ImapFetchIdentifier::Uid, new ChangedSince(42), $custom, + 'FLAGS', [1, 2], null, ImapIdentifier::Uid, new ChangedSince(42), $custom, ); $stream->assertWritten('TAG1 UID FETCH 1:2 (FLAGS) (CHANGEDSINCE 42 X-CUSTOM)'); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php index 0ed2c96..1011079 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -7,6 +7,7 @@ use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\Selection\CondStore; use DirectoryTree\ImapEngine\Selection\QuickResync; +use DirectoryTree\ImapEngine\Store\UnchangedSince; test('select returns typed condstore metadata', function () { $stream = new FakeStream; @@ -133,7 +134,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->storeConditionally('\\Flagged', 7, 43); + $result = $connection->store('\\Flagged', 7, modifiers: new UnchangedSince(43)); $stream->assertWritten('TAG1 UID STORE 7 (UNCHANGEDSINCE 43) +FLAGS.SILENT (\\Flagged)'); expect($result->successful())->toBeTrue(); @@ -153,11 +154,10 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->storeConditionally('\\Seen', [7, 8, 9], 43); + $result = $connection->store('\\Seen', [7, 8, 9], modifiers: new UnchangedSince(43)); expect($result->successful())->toBeFalse(); expect($result->modified())->toBe([8, 9]); - expect($result->modifiedUids())->toBe([8, 9]); }); test('mailbox enables qresync before selecting and keeps the folder selected', function () { diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index a4acb0e..e199e9d 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -3,8 +3,8 @@ use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; -use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; 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; @@ -36,7 +36,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $mailbox->connect(new ImapConnection($stream)); $query = new MessageQuery(new Folder($mailbox, 'INBOX'), new ImapQueryBuilder); - $message = $query->find(1, ImapFetchIdentifier::MessageNumber); + $message = $query->find(1, ImapIdentifier::MessageNumber); $stream->assertWritten('TAG2 FETCH 1 (UID)'); expect($message->uid())->toBe(42); From b9f3499fb24076a78f77bc8c2aff3acb743410aa Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 16:07:58 -0400 Subject: [PATCH 05/16] Align connection parameters with IMAP command syntax --- src/Authentication/XOAuth2.php | 40 +++ src/Authenticator.php | 21 ++ src/Connection/ConnectionInterface.php | 41 +-- src/Connection/ImapConnection.php | 147 +++++++---- src/FetchResult.php | 9 +- src/FolderRepository.php | 4 +- src/Mailbox.php | 6 +- src/Message.php | 12 +- src/MessageQuery.php | 20 +- src/Selection/QuickResync.php | 11 +- src/Support/Str.php | 31 ++- tests/Integration/FoldersTest.php | 2 - .../ImapConnectionAuthenticationTest.php | 240 ++++++++++++++++++ .../ImapConnectionOperationsTest.php | 19 +- .../ImapConnectionParametersTest.php | 236 +++++++++++++++++ tests/Unit/Connection/ImapConnectionTest.php | 76 +++--- tests/Unit/IncrementalSyncTest.php | 6 +- tests/Unit/MessageQueryTest.php | 20 +- tests/Unit/Support/StrTest.php | 5 +- 19 files changed, 783 insertions(+), 163 deletions(-) create mode 100644 src/Authentication/XOAuth2.php create mode 100644 src/Authenticator.php create mode 100644 tests/Unit/Connection/ImapConnectionAuthenticationTest.php create mode 100644 tests/Unit/Connection/ImapConnectionParametersTest.php diff --git a/src/Authentication/XOAuth2.php b/src/Authentication/XOAuth2.php new file mode 100644 index 0000000..ac69ea2 --- /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 $challenge === '' ? $this->initialResponse() : ''; + } +} diff --git a/src/Authenticator.php b/src/Authenticator.php new file mode 100644 index 0000000..84f7966 --- /dev/null +++ b/src/Authenticator.php @@ -0,0 +1,21 @@ +|null $parameters + * * @see https://datatracker.ietf.org/doc/html/rfc2971. */ - public function id(?array $ids = null): UntaggedResponse; + 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, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; /** * Send an IMAP command. @@ -167,7 +173,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, SelectionOption ...$options): SelectionResult; + public function select(string $folder = 'INBOX', SelectionOption ...$options): SelectionResult; /** * Send a "EXAMINE" command. @@ -176,16 +182,19 @@ public function select(string $folder, SelectionOption ...$options): SelectionRe * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command */ - public function examine(string $folder, SelectionOption ...$options): SelectionResult; + public function examine(string $folder = 'INBOX', SelectionOption ...$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. @@ -194,7 +203,7 @@ 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. @@ -204,7 +213,7 @@ public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', * @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 = '+', bool $silent = true, ?string $item = null, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult; + public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult; /** * Send a "APPEND" command. @@ -222,7 +231,7 @@ public function append(string $folder, string $message, ?array $flags = null, ?D * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command */ - public function copy(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; + public function copy(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; /** * Send a "MOVE" or "UID MOVE" command. @@ -231,7 +240,7 @@ public function copy(string $folder, array|int $from, ?int $to = null, ImapIdent * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command */ - public function move(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; + public function move(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse; /** * Send a "CREATE" command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 0d48b1d..3bd7ebe 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -4,11 +4,11 @@ use DateTimeInterface; use DirectoryTree\ImapEngine\AppendResult; +use DirectoryTree\ImapEngine\Authenticator; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; use DirectoryTree\ImapEngine\Connection\Responses\Data\Data; -use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Responses\Response; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; @@ -22,6 +22,7 @@ use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; +use DirectoryTree\ImapEngine\FetchedMessageData; use DirectoryTree\ImapEngine\FetchModifier; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; @@ -212,13 +213,40 @@ public function logout(): void /** * {@inheritDoc} */ - public function authenticate(string $user, string $token): TaggedResponse + public function authenticate(Authenticator $authenticator, bool $initialResponse = false): TaggedResponse { - $this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag); + $tokens = [$authenticator->mechanism()]; - return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => ( - ImapCommandException::make($this->result->command()->redacted(), $response) - )); + if ($initialResponse && ($response = $authenticator->initialResponse()) !== null) { + $tokens[] = $response === '' ? '=' : base64_encode($response); + } + + $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; + } + + try { + $answer = $authenticator->respond(base64_decode(trim(substr((string) $response, 1)))); + } catch (Throwable $e) { + $this->disconnect(); + + throw $e; + } + + $this->write($answer === null ? '*' : base64_encode($answer), sensitive: true); + } + + throw new ImapResponseException('No authentication response found'); } /** @@ -287,11 +315,11 @@ protected function examineOrSelect(string $command = 'EXAMINE', string $folder = /** * {@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); @@ -386,9 +414,13 @@ public function getQuotaRoot(string $mailbox): ResponseCollection /** * {@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'; @@ -399,9 +431,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(); } /** @@ -421,7 +451,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); @@ -433,10 +463,10 @@ public function append(string $folder, string $message, ?array $flags = null, ?D /** * {@inheritDoc} */ - public function copy(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse + public function copy(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { $this->send($identifier === ImapIdentifier::Uid ? 'UID COPY' : 'COPY', [ - Str::set($from, $to), + Str::set($set), Str::literal($folder), ], $tag); @@ -446,10 +476,10 @@ public function copy(string $folder, array|int $from, ?int $to = null, ImapIdent /** * {@inheritDoc} */ - public function move(string $folder, array|int $from, ?int $to = null, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse + public function move(array|int|string $set, string $folder, ImapIdentifier $identifier = ImapIdentifier::Uid): TaggedResponse { $this->send($identifier === ImapIdentifier::Uid ? 'UID MOVE' : 'MOVE', [ - Str::set($from, $to), + Str::set($set), Str::literal($folder), ], $tag); @@ -459,9 +489,9 @@ public function move(string $folder, array|int $from, ?int $to = null, ImapIdent /** * {@inheritDoc} */ - public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = '+', bool $silent = true, ?string $item = null, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult + public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult { - $tokens = [Str::set($from, $to)]; + $tokens = [Str::set($set)]; if ($modifiers) { $tokens[] = Str::list(array_map( @@ -470,7 +500,7 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st )); } - $tokens[] = $mode.($item ?? 'FLAGS').($silent ? '.SILENT' : ''); + $tokens[] = $mode.'FLAGS'.($silent ? '.SILENT' : ''); $tokens[] = Str::list((array) $flags); $this->send($identifier === ImapIdentifier::Uid ? 'UID STORE' : 'STORE', $tokens, $tag); @@ -488,9 +518,11 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st /** * {@inheritDoc} */ - public function search(array $params, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse + public function search(array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, ?string $charset = null): UntaggedResponse { - $this->send($identifier === ImapIdentifier::Uid ? 'UID SEARCH' : 'SEARCH', $params, tag: $tag); + $tokens = $charset === null ? $criteria : ['CHARSET', Str::literal($charset), ...$criteria]; + + $this->send($identifier === ImapIdentifier::Uid ? 'UID SEARCH' : 'SEARCH', $tokens, tag: $tag); $this->assertTaggedResponse($tag); @@ -502,9 +534,9 @@ public function search(array $params, ImapIdentifier $identifier = ImapIdentifie /** * {@inheritDoc} */ - public function sort(ImapSort $sort, array $params, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse + public function sort(ImapSort $sort, array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, string $charset = 'UTF-8'): UntaggedResponse { - $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", 'UTF-8', ...$params], tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", Str::literal($charset), ...$criteria], tag: $tag); $this->assertTaggedResponse($tag); @@ -530,21 +562,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 = '('; + $values = []; - foreach ($ids as $id) { - $token .= '"'.Str::escape($id).'" '; - } - - $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); @@ -556,7 +583,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', @@ -635,7 +662,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); @@ -646,25 +673,32 @@ 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, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult { $prefix = ($identifier === ImapIdentifier::Uid) ? 'UID' : ''; + $items = array_merge(...array_map(fn (string $item) => match (strtoupper($item)) { + 'ALL' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE'], + 'FAST' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE'], + 'FULL' => ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY'], + default => [$item], + }, array_values((array) $items))); + $tokens = [ - Str::set($from, $to), - Str::list((array) $items), + Str::set($set), + Str::list($items), ]; if ($modifiers) { @@ -681,23 +715,24 @@ public function fetch(array|string $items, array|int $from, mixed $to = null, Im // 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 FetchResult::fromResponses($this->result->responses(), function (UntaggedResponse $response) use ($items, $identifier) { - // Skip over any untagged responses that are not FETCH responses. - // The third token should always be the list of data items. - if (! ($data = $response->tokenAt(3)) instanceof ListData) { - return false; + return FetchResult::fromResponses($this->result->responses(), function (FetchedMessageData $data) use ($items, $identifier) { + if ($identifier === ImapIdentifier::Uid) { + return $data->has('UID'); } - return match ($identifier) { - // If we're fetching UIDs, we can check if a UID token is contained in the list. - ImapIdentifier::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. - ImapIdentifier::MessageNumber => $data->contains($items), - }; + if (! $data->has($key)) { + return false; + } + } + + return true; }); } diff --git a/src/FetchResult.php b/src/FetchResult.php index b29a6bf..3f27bc3 100644 --- a/src/FetchResult.php +++ b/src/FetchResult.php @@ -18,6 +18,8 @@ public function __construct( /** * Create a fetch result from IMAP responses, optionally filtering fetched messages. + * + * @param (callable(FetchedMessageData): bool)|null $filter */ public static function fromResponses(ResponseCollection $responses, ?callable $filter = null): static { @@ -29,9 +31,12 @@ public static function fromResponses(ResponseCollection $responses, ?callable $f $vanished[] = Vanished::fromResponse($response); } elseif ( ($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH') - && (! $filter || $filter($response)) ) { - $messages[] = FetchedMessageData::fromResponse($response); + $message = FetchedMessageData::fromResponse($response); + + if (! $filter || $filter($message)) { + $messages[] = $message; + } } } diff --git a/src/FolderRepository.php b/src/FolderRepository.php index 49032c2..1b869be 100644 --- a/src/FolderRepository.php +++ b/src/FolderRepository.php @@ -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/Mailbox.php b/src/Mailbox.php index 9c6c0cb..bb147ae 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -168,10 +168,10 @@ class_exists($debug) => new $debug, protected function authenticate(): void { if ($this->config('authentication') === 'oauth') { - $this->connection->authenticate( + $this->connection->authenticate(new Authentication\XOAuth2( $this->config('username'), - $this->config('password') - ); + $this->config('password'), + )); } else { $this->connection->login( $this->config('username'), diff --git a/src/Message.php b/src/Message.php index 56158ff..ea65a6c 100644 --- a/src/Message.php +++ b/src/Message.php @@ -181,7 +181,7 @@ 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()); @@ -208,7 +208,7 @@ public function copy(string $folder): ?int ); } - $response = $mailbox->connection()->copy($folder, $this->uid()); + $response = $mailbox->connection()->copy($this->uid(), $folder); return MessageResponseParser::getUidFromCopy($response); } @@ -224,7 +224,7 @@ public function move(string $folder, bool $expunge = false): ?int switch (true) { case $mailbox->hasCapability('MOVE'): - $response = $mailbox->connection()->move($folder, $this->uid()); + $response = $mailbox->connection()->move($this->uid(), $folder); return MessageResponseParser::getUidFromCopy($response); @@ -468,7 +468,7 @@ public function bodyPart(string $partNumber, bool $peek = true): ?string $response = $this->folder->mailbox() ->connection() - ->fetch($peek ? "BODY.PEEK[$partNumber]" : "BODY[$partNumber]", $this->uid()); + ->fetch($this->uid(), $peek ? "BODY.PEEK[$partNumber]" : "BODY[$partNumber]"); if (! $data = $response->messages()[0] ?? null) { return null; @@ -538,7 +538,7 @@ protected function fetchHead(): ?string $response = $this->folder ->mailbox() ->connection() - ->fetch('BODY.PEEK[HEADER]', $this->uid()); + ->fetch($this->uid(), 'BODY.PEEK[HEADER]'); if (! $data = $response->messages()[0] ?? null) { return null; @@ -557,7 +557,7 @@ protected function fetchBodyStructureData(): ?ListData $response = $this->folder ->mailbox() ->connection() - ->fetch('BODYSTRUCTURE', $this->uid()); + ->fetch($this->uid(), 'BODYSTRUCTURE'); if (! $data = $response->messages()[0] ?? null) { return null; diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 1fb666d..61cb6fa 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -100,9 +100,7 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = $items[] = MessageData::flags()->toImap(); } - return $this->connection()->fetch( - $items, $uids, modifiers: new ChangedSince($modSequence, $vanished), - ); + return $this->connection()->fetch($uids, $items, modifiers: new ChangedSince($modSequence, $vanished)); } /** @@ -226,7 +224,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); @@ -244,11 +242,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); @@ -308,7 +302,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); } @@ -324,7 +318,7 @@ public function copy(string $folder): int return 0; } - $this->connection()->copy($folder, $uids); + $this->connection()->copy($uids, $folder); return count($uids); } @@ -382,7 +376,7 @@ protected function fetch(Collection $messages): array ])->all(); } - $fetched = (new Collection($this->connection()->fetch($fetch, $uids->all())->messages())) + $fetched = (new Collection($this->connection()->fetch($uids->all(), $fetch)->messages())) ->keyBy(fn (FetchedMessageData $data) => $data->uid()); return $uids @@ -454,7 +448,7 @@ protected function sort(ImapSort $sort): Collection protected function id(int $id, ImapIdentifier $identifier = ImapIdentifier::Uid): ?FetchedMessageData { try { - return $this->connection()->fetch('UID', $id, identifier: $identifier)->messages()[0] ?? null; + 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 diff --git a/src/Selection/QuickResync.php b/src/Selection/QuickResync.php index 1cd5be7..c6ff2aa 100644 --- a/src/Selection/QuickResync.php +++ b/src/Selection/QuickResync.php @@ -9,11 +9,16 @@ class QuickResync implements SelectionOption { /** * Constructor. + * + * Sequence matches pair ascending message numbers with their corresponding UIDs. + * + * @param array{0: array|int|string, 1: array|int|string}|null $sequenceMatch */ public function __construct( protected int $uidValidity, protected int $highestModSequence, - protected array $knownUids = [], + protected array|int|string $knownUids = [], + protected ?array $sequenceMatch = null, ) {} /** @@ -35,6 +40,10 @@ public function toImap(): string $parameters[] = Str::set($this->knownUids); } + if ($this->sequenceMatch !== null) { + $parameters[] = Str::list(array_map([Str::class, 'set'], $this->sequenceMatch)); + } + return 'QRESYNC '.Str::list($parameters); } } diff --git a/src/Support/Str.php b/src/Support/Str.php index e85204e..83bcfac 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -40,13 +40,42 @@ 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) => $value === null ? '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. */ diff --git a/tests/Integration/FoldersTest.php b/tests/Integration/FoldersTest.php index 8d86527..8693c2b 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', diff --git a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php new file mode 100644 index 0000000..8aaac8d --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php @@ -0,0 +1,240 @@ +feed(array_filter([ + '* OK Ready', + $initialResponse ? null : '+', + 'TAG1 OK Authenticated', + ])); + + $logger = new class implements LoggerInterface + { + public array $sent = []; + + public function sent(string $message): void + { + $this->sent[] = $message; + } + + public function received(string $message): void {} + }; + + $connection = new ImapConnection($stream, $logger); + $connection->connect('imap.example.com'); + $response = $connection->authenticate(new XOAuth2('foo', 'secret'), initialResponse: $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(); + expect($logger->sent)->toBe(array_fill(0, $initialResponse ? 1 : 2, '[redacted]')); +})->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 () => $connection->authenticate(new XOAuth2('foo', 'secret'), initialResponse: $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 Authenticator + { + public array $challenges = []; + + public function mechanism(): string + { + return 'LOGIN'; + } + + public function initialResponse(): ?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'); + $connection->authenticate($authenticator, initialResponse: 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 Authenticator + { + public function mechanism(): string + { + return 'EXTERNAL'; + } + + public function initialResponse(): string + { + return ''; + } + + public function respond(string $challenge): string + { + return ''; + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + $connection->authenticate($authenticator, initialResponse: 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 Authenticator + { + public function mechanism(): string + { + return 'X-CUSTOM'; + } + + public function initialResponse(): ?string + { + return null; + } + + public function respond(string $challenge): ?string + { + return null; + } + }; + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + expect(fn () => $connection->authenticate($authenticator))->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 Authenticator + { + public function mechanism(): string + { + return 'X-CUSTOM'; + } + + public function initialResponse(): ?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 () => $connection->authenticate($authenticator))->toThrow(RuntimeException::class, 'Unable to respond'); + expect($connection->connected())->toBeFalse(); +}); + +test('mailbox oauth configuration uses challenge based authentication', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Ready', + '+', + 'TAG1 OK Authenticated', + ]); + + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'secret', + 'authentication' => 'oauth', + ]); + $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/Connection/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php index 0313362..0116ff4 100644 --- a/tests/Unit/Connection/ImapConnectionOperationsTest.php +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -22,7 +22,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->store('\\Seen', [1, 2, 4], mode: $mode, silent: $silent); + $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); @@ -47,7 +47,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->store('\\Seen', 7, silent: false); + $result = $connection->store(7, '\\Seen', silent: false); $stream->assertWritten('TAG1 UID STORE 7 +FLAGS (\\Seen)'); expect($result->messages())->toHaveCount(1); @@ -68,7 +68,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->store('\\Seen', 7); + $result = $connection->store(7, '\\Seen'); $stream->assertWritten('TAG1 UID STORE 7 +FLAGS.SILENT (\\Seen)'); expect($result->messages()[0]->modSequence())->toBe(44); @@ -86,7 +86,7 @@ $connection->connect('imap.example.com'); $result = $connection->store( - '\\Seen', 1, 3, mode: null, silent: false, + '1:3', '\\Seen', mode: null, silent: false, identifier: ImapIdentifier::MessageNumber, modifiers: new UnchangedSince(43), ); @@ -108,7 +108,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(fn () => $connection->store('\\Seen', 7)) + expect(fn () => $connection->store(7, '\\Seen')) ->toThrow(ImapCommandException::class); })->with([ 'TAG1 NO Permission denied', @@ -134,10 +134,7 @@ public function toImap(): string } }; - $result = $connection->store( - '\\Seen', 7, null, '+', true, null, ImapIdentifier::Uid, - new UnchangedSince(0), $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]); @@ -177,7 +174,7 @@ public function toImap(): string $response = $connection->sort($sort, ['ALL'], identifier: $identifier); - $stream->assertWritten("TAG1 $command (ARRIVAL) UTF-8 ALL"); + $stream->assertWritten("TAG1 $command (ARRIVAL) \"UTF-8\" ALL"); expect((string) $response)->toBe('* SORT 3 2'); })->with([ [ImapIdentifier::Uid, 'UID SORT'], @@ -194,7 +191,7 @@ public function toImap(): string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $response = $connection->{$method}('Archive', 1, 3, identifier: $identifier); + $response = $connection->{$method}('1:3', 'Archive', identifier: $identifier); $stream->assertWritten("TAG1 $command 1:3 \"Archive\""); expect($response->successful())->toBeTrue(); diff --git a/tests/Unit/Connection/ImapConnectionParametersTest.php b/tests/Unit/Connection/ImapConnectionParametersTest.php new file mode 100644 index 0000000..c737a42 --- /dev/null +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -0,0 +1,236 @@ +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 () { + $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é"'], charset: 'UTF-8'); + + $stream->assertWritten('TAG1 UID SEARCH CHARSET "UTF-8" SUBJECT "été"'); +}); + +test('sort accepts an explicit charset separately from criteria', function () { + $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'], charset: 'US-ASCII'); + + $stream->assertWritten('TAG1 UID SORT (ARRIVAL) "US-ASCII" ALL'); +}); + +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'], +]); diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index d5deffa..2afd500 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,6 +1,7 @@ feed([ '* OK Welcome to IMAP', + '+', 'TAG1 OK Authenticated', ]); $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate('foo', 'bar'); + $connection->authenticate(new XOAuth2('foo', 'bar')); $credentials = Str::credentials('foo', 'bar'); - $stream->assertWritten("TAG1 AUTHENTICATE XOAUTH2 $credentials"); + $stream->assertWritten('TAG1 AUTHENTICATE XOAUTH2'); + $stream->assertWritten($credentials); }); test('authenticate failure', function () { @@ -138,8 +141,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"'); + $connection->authenticate(new XOAuth2('foo', 'bar')); +})->throws(ImapCommandException::class, 'IMAP command "TAG1 AUTHENTICATE [redacted]" failed. Response: "TAG1 BAD Authentication failed"'); test('start tls success', function () { $stream = new FakeStream; @@ -261,7 +264,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(); }); @@ -394,7 +397,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)'); @@ -407,6 +410,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', 'TAG1 OK [APPENDUID 1234567890 42] APPEND completed', ]); @@ -415,11 +419,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 () { @@ -428,6 +433,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', 'TAG1 OK APPEND completed', ]); @@ -441,10 +447,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 () { @@ -519,7 +526,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"'); }); @@ -535,7 +542,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"'); }); @@ -552,7 +559,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $response = $connection->store(['\\Seen'], 1, 3); + $response = $connection->store('1:3', ['\\Seen']); $stream->assertWritten('TAG1 UID STORE 1:3 +FLAGS.SILENT (\\Seen)'); @@ -575,7 +582,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('UID', 1); + $responses = $connection->fetch(1, 'UID'); $stream->assertWritten('TAG1 UID FETCH 1 (UID)'); @@ -596,7 +603,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('UID', 1, identifier: ImapIdentifier::MessageNumber); + $responses = $connection->fetch(1, 'UID', identifier: ImapIdentifier::MessageNumber); $stream->assertWritten('TAG1 FETCH 1 (UID)'); @@ -619,7 +626,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('BODY.PEEK[TEXT]', 1); + $responses = $connection->fetch(1, 'BODY.PEEK[TEXT]'); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[TEXT])'); @@ -640,7 +647,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('BODY.PEEK[HEADER]', 1); + $responses = $connection->fetch(1, 'BODY.PEEK[HEADER]'); $stream->assertWritten('TAG1 UID FETCH 1 (BODY.PEEK[HEADER])'); @@ -660,7 +667,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('FLAGS', 1); + $responses = $connection->fetch(1, 'FLAGS'); $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); @@ -680,7 +687,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('RFC822.SIZE', 1); + $responses = $connection->fetch(1, 'RFC822.SIZE'); $stream->assertWritten('TAG1 UID FETCH 1 (RFC822.SIZE)'); @@ -765,7 +772,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(); }); @@ -776,6 +783,7 @@ $stream->feed([ '* OK Welcome to IMAP', + '+ Ready', '* ID NIL', 'TAG1 OK ID completed', ]); @@ -789,7 +797,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 () { @@ -884,7 +893,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch('FLAGS', 1); + $responses = $connection->fetch(1, 'FLAGS'); $stream->assertWritten('TAG1 UID FETCH 1 (FLAGS)'); @@ -908,7 +917,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->fetch('FLAGS', 1, INF, modifiers: new ChangedSince(42)); + $result = $connection->fetch('1:*', 'FLAGS', modifiers: new ChangedSince(42)); $stream->assertWritten('TAG1 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 42)'); expect($result)->toBeInstanceOf(FetchResult::class); @@ -928,10 +937,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->fetch( - 'FLAGS', [1, 2], identifier: ImapIdentifier::MessageNumber, - modifiers: new ChangedSince(0), - ); + $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); @@ -955,7 +961,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->fetch('FLAGS', [1, 2, 4, 7], modifiers: new ChangedSince(42, vanished: true)); + $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); @@ -979,7 +985,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->fetch('FLAGS', [1, 2], modifiers: new ChangedSince(42, vanished: true)); + $result = $connection->fetch([1, 2], 'FLAGS', modifiers: new ChangedSince(42, vanished: true)); expect($result->messages())->toBe([]); expect($result->vanishedUids())->toBe([1, 2]); @@ -1004,9 +1010,7 @@ public function toImap(): string } }; - $result = $connection->fetch( - 'FLAGS', [1, 2], null, ImapIdentifier::Uid, new ChangedSince(42), $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([]); @@ -1024,6 +1028,6 @@ public function toImap(): string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(fn () => $connection->fetch('FLAGS', 1, modifiers: new ChangedSince(42))) + expect(fn () => $connection->fetch(1, 'FLAGS', modifiers: new ChangedSince(42))) ->toThrow(ImapCommandException::class); }); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php index 1011079..43b058f 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -109,7 +109,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $changes = $connection->fetch('FLAGS', [1, 2, 3, 4, 6, 7], modifiers: new ChangedSince(42, vanished: true)); + $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); @@ -134,7 +134,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->store('\\Flagged', 7, modifiers: new UnchangedSince(43)); + $result = $connection->store(7, '\\Flagged', modifiers: new UnchangedSince(43)); $stream->assertWritten('TAG1 UID STORE 7 (UNCHANGEDSINCE 43) +FLAGS.SILENT (\\Flagged)'); expect($result->successful())->toBeTrue(); @@ -154,7 +154,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $result = $connection->store('\\Seen', [7, 8, 9], modifiers: new UnchangedSince(43)); + $result = $connection->store([7, 8, 9], '\\Seen', modifiers: new UnchangedSince(43)); expect($result->successful())->toBeFalse(); expect($result->modified())->toBe([8, 9]); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index e199e9d..e3dd76f 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -359,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', ]); @@ -370,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 () { @@ -703,7 +705,7 @@ function query(?Mailbox $mailbox = null): MessageQuery ->map(fn ($message) => $message->uid()) ->all(); - $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (DATE) "UTF-8" ALL'); expect($uids)->toBe([3, 1, 2]); }); @@ -726,7 +728,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date')->get(); - $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (DATE) "UTF-8" ALL'); }); test('sortBy sends correct sort command with descending order', function () { @@ -747,7 +749,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date', SortDirection::Descending)->get(); - $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) "UTF-8" ALL'); }); test('sortBy sends multiple sort criteria in priority order', function () { @@ -771,7 +773,7 @@ function query(?Mailbox $mailbox = null): MessageQuery ->sortBy('date', SortDirection::Descending) ->get(); - $stream->assertWritten('TAG3 UID SORT (SUBJECT REVERSE DATE) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (SUBJECT REVERSE DATE) "UTF-8" ALL'); }); test('orderByUid replaces server sorting', function () { @@ -816,7 +818,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy(ImapSortKey::Subject)->get(); - $stream->assertWritten('TAG3 UID SORT (SUBJECT) UTF-8 ALL'); + $stream->assertWritten('TAG3 UID SORT (SUBJECT) "UTF-8" ALL'); }); test('sortBy combined with search criteria', function () { @@ -837,7 +839,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->unseen()->sortBy('arrival', SortDirection::Descending)->get(); - $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); + $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) "UTF-8" UNSEEN'); }); test('sortBy throws exception when SORT capability is not available', function () { diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php index 8b92087..2eac79e 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -57,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"']); From 67b56407107bc89ef3649bca8db8108f097836ca Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 16:12:03 -0400 Subject: [PATCH 06/16] Send SORT charsets unquoted for server compatibility --- src/Connection/ImapConnection.php | 2 +- .../Unit/Connection/ImapConnectionOperationsTest.php | 2 +- .../Unit/Connection/ImapConnectionParametersTest.php | 2 +- tests/Unit/MessageQueryTest.php | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 3bd7ebe..20696a0 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -536,7 +536,7 @@ public function search(array $criteria, ImapIdentifier $identifier = ImapIdentif */ public function sort(ImapSort $sort, array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, string $charset = 'UTF-8'): UntaggedResponse { - $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", Str::literal($charset), ...$criteria], tag: $tag); + $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", $charset, ...$criteria], tag: $tag); $this->assertTaggedResponse($tag); diff --git a/tests/Unit/Connection/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php index 0116ff4..5030dcc 100644 --- a/tests/Unit/Connection/ImapConnectionOperationsTest.php +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -174,7 +174,7 @@ public function toImap(): string $response = $connection->sort($sort, ['ALL'], identifier: $identifier); - $stream->assertWritten("TAG1 $command (ARRIVAL) \"UTF-8\" ALL"); + $stream->assertWritten("TAG1 $command (ARRIVAL) UTF-8 ALL"); expect((string) $response)->toBe('* SORT 3 2'); })->with([ [ImapIdentifier::Uid, 'UID SORT'], diff --git a/tests/Unit/Connection/ImapConnectionParametersTest.php b/tests/Unit/Connection/ImapConnectionParametersTest.php index c737a42..976255c 100644 --- a/tests/Unit/Connection/ImapConnectionParametersTest.php +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -158,7 +158,7 @@ $connection->connect('imap.example.com'); $connection->sort(new ImapSort(new SortCriterion(ImapSortKey::Arrival)), ['ALL'], charset: 'US-ASCII'); - $stream->assertWritten('TAG1 UID SORT (ARRIVAL) "US-ASCII" ALL'); + $stream->assertWritten('TAG1 UID SORT (ARRIVAL) US-ASCII ALL'); }); test('list supports selection options multiple patterns and status return data', function () { diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index e3dd76f..cf27dcf 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -705,7 +705,7 @@ function query(?Mailbox $mailbox = null): MessageQuery ->map(fn ($message) => $message->uid()) ->all(); - $stream->assertWritten('TAG3 UID SORT (DATE) "UTF-8" ALL'); + $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); expect($uids)->toBe([3, 1, 2]); }); @@ -728,7 +728,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date')->get(); - $stream->assertWritten('TAG3 UID SORT (DATE) "UTF-8" ALL'); + $stream->assertWritten('TAG3 UID SORT (DATE) UTF-8 ALL'); }); test('sortBy sends correct sort command with descending order', function () { @@ -749,7 +749,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy('date', SortDirection::Descending)->get(); - $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) "UTF-8" ALL'); + $stream->assertWritten('TAG3 UID SORT (REVERSE DATE) UTF-8 ALL'); }); test('sortBy sends multiple sort criteria in priority order', function () { @@ -773,7 +773,7 @@ function query(?Mailbox $mailbox = null): MessageQuery ->sortBy('date', SortDirection::Descending) ->get(); - $stream->assertWritten('TAG3 UID SORT (SUBJECT REVERSE DATE) "UTF-8" ALL'); + $stream->assertWritten('TAG3 UID SORT (SUBJECT REVERSE DATE) UTF-8 ALL'); }); test('orderByUid replaces server sorting', function () { @@ -818,7 +818,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->sortBy(ImapSortKey::Subject)->get(); - $stream->assertWritten('TAG3 UID SORT (SUBJECT) "UTF-8" ALL'); + $stream->assertWritten('TAG3 UID SORT (SUBJECT) UTF-8 ALL'); }); test('sortBy combined with search criteria', function () { @@ -839,7 +839,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->unseen()->sortBy('arrival', SortDirection::Descending)->get(); - $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) "UTF-8" UNSEEN'); + $stream->assertWritten('TAG3 UID SORT (REVERSE ARRIVAL) UTF-8 UNSEEN'); }); test('sortBy throws exception when SORT capability is not available', function () { From b9a71016e82b9a97c8909c17f506b4182503f1b8 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 17:21:34 -0400 Subject: [PATCH 07/16] Adjust formatting --- src/FetchedMessageData.php | 12 ++++++++---- src/Message.php | 2 +- src/Selection/QuickResync.php | 2 +- src/Store/UnchangedSince.php | 4 +++- src/Support/Str.php | 6 +++++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/FetchedMessageData.php b/src/FetchedMessageData.php index 5fe8ad5..187411f 100644 --- a/src/FetchedMessageData.php +++ b/src/FetchedMessageData.php @@ -17,8 +17,9 @@ class FetchedMessageData implements Arrayable /** * Constructor. */ - public function __construct(protected array $attributes = []) - { + public function __construct( + protected array $attributes = [] + ) { $this->attributes = array_change_key_case($attributes, CASE_UPPER); } @@ -38,6 +39,7 @@ public static function fromResponse(UntaggedResponse $response): static } $tokens = $data->tokens(); + $attributes = []; for ($index = 0; $index < count($tokens);) { @@ -130,7 +132,9 @@ public function body(): string */ public function size(): ?int { - return ($size = $this->get('RFC822.SIZE')) !== null ? (int) $size : null; + $size = $this->get('RFC822.SIZE'); + + return is_null($size) ? null : (int) $size; } /** @@ -150,7 +154,7 @@ public function modSequence(): ?int { $sequence = $this->get('MODSEQ')[0] ?? null; - return $sequence !== null ? (int) $sequence : null; + return is_null($sequence) ? null : (int) $sequence; } /** diff --git a/src/Message.php b/src/Message.php index ea65a6c..b626909 100644 --- a/src/Message.php +++ b/src/Message.php @@ -159,7 +159,7 @@ public function bodyStructure(bool $fetch = false): ?BodyStructureCollection */ public function hasBodyStructure(): bool { - return $this->data->bodyStructure() !== null; + return ! is_null($this->data->bodyStructure()); } /** diff --git a/src/Selection/QuickResync.php b/src/Selection/QuickResync.php index c6ff2aa..e96c0fb 100644 --- a/src/Selection/QuickResync.php +++ b/src/Selection/QuickResync.php @@ -40,7 +40,7 @@ public function toImap(): string $parameters[] = Str::set($this->knownUids); } - if ($this->sequenceMatch !== null) { + if (! is_null($this->sequenceMatch)) { $parameters[] = Str::list(array_map([Str::class, 'set'], $this->sequenceMatch)); } diff --git a/src/Store/UnchangedSince.php b/src/Store/UnchangedSince.php index ecbef30..8a10a02 100644 --- a/src/Store/UnchangedSince.php +++ b/src/Store/UnchangedSince.php @@ -14,7 +14,9 @@ class UnchangedSince implements StoreModifier /** * Constructor. */ - public function __construct(protected int $modSequence) {} + public function __construct( + protected int $modSequence + ) {} /** * {@inheritDoc} diff --git a/src/Support/Str.php b/src/Support/Str.php index 83bcfac..16158cb 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -58,7 +58,11 @@ public static function literalList(array $values): array return ['()']; } - $tokens = array_map(fn (?string $value) => $value === null ? 'NIL' : static::literal($value), array_values($values)); + $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])) { From 4fd92fba05decfce2a48cbda59e2edb195e49264 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 2 Sep 2026 17:27:54 -0400 Subject: [PATCH 08/16] Spacing --- src/Mailbox.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Mailbox.php b/src/Mailbox.php index bb147ae..6841a55 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -250,6 +250,7 @@ public function enable(string ...$capabilities): ResponseCollection } $responses = $this->connection()->enable(...$capabilities); + $this->enabled = array_unique([...$this->enabled, ...$capabilities]); return $responses; From ba8ce9043e79c22eab580b92c2ed933aff591341 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 3 Sep 2026 08:34:55 -0400 Subject: [PATCH 09/16] Address incremental synchronization review feedback --- src/Connection/ImapConnection.php | 4 +- src/Folder.php | 2 +- src/Mailbox.php | 13 ++++ src/MailboxInterface.php | 5 ++ src/MessageQuery.php | 4 ++ src/Support/Str.php | 8 ++- src/Testing/FakeFolder.php | 2 + src/Testing/FakeMailbox.php | 10 ++++ tests/Integration/FoldersTest.php | 14 +++++ .../ImapConnectionParametersTest.php | 24 ++++++++ tests/Unit/FetchedMessageDataTest.php | 20 +++++++ tests/Unit/FolderTest.php | 59 +++++++++++++++++++ tests/Unit/IncrementalSyncTest.php | 21 +++++++ tests/Unit/Support/StrTest.php | 13 ++++ tests/Unit/Testing/FakeFolderTest.php | 15 +++++ tests/Unit/Testing/FakeMessageQueryTest.php | 9 +++ 16 files changed, 219 insertions(+), 4 deletions(-) diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 20696a0..db82c33 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -719,8 +719,8 @@ public function fetch(array|int|string $set, array|string $items, ImapIdentifier // << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!) // << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response return FetchResult::fromResponses($this->result->responses(), function (FetchedMessageData $data) use ($items, $identifier) { - if ($identifier === ImapIdentifier::Uid) { - return $data->has('UID'); + if ($identifier === ImapIdentifier::Uid && ! $data->has('UID')) { + return false; } foreach ($items as $item) { diff --git a/src/Folder.php b/src/Folder.php index d4b29c5..cf3646b 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -235,7 +235,7 @@ public function status(): array */ public function examine(): array { - return $this->mailbox->connection()->examine($this->path)->responses()->untagged()->map( + return $this->mailbox->examine($this)->responses()->untagged()->map( fn (UntaggedResponse $response) => $response->toArray() )->all(); } diff --git a/src/Mailbox.php b/src/Mailbox.php index 6841a55..d15983e 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -282,6 +282,19 @@ public function select(FolderInterface $folder, bool $force = false, SelectionOp return $this->selection ?? new SelectionResult; } + /** + * {@inheritDoc} + */ + public function examine(FolderInterface $folder): SelectionResult + { + // EXAMINE replaces the server selection with a read-only one, even + // for the same folder. The next query must select it again. + $this->selected = null; + $this->selection = null; + + return $this->connection()->examine($folder->path()); + } + /** * {@inheritDoc} */ diff --git a/src/MailboxInterface.php b/src/MailboxInterface.php index d883995..448edc3 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -67,6 +67,11 @@ public function enable(string ...$capabilities): ResponseCollection; */ public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult; + /** + * Examine the given folder, invalidating the cached writable selection. + */ + public function examine(FolderInterface $folder): SelectionResult; + /** * Determine if the given folder is selected. */ diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 61cb6fa..4ce4fb0 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -78,6 +78,10 @@ public function get(): MessageCollection */ 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(); diff --git a/src/Support/Str.php b/src/Support/Str.php index 16158cb..ebb6c58 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -149,8 +149,14 @@ public static function parseSequenceSet(string $set): array [$start, $end] = array_map('intval', explode(':', $sequence, 2)); - foreach (range($start, $end) as $value) { + $step = $start <= $end ? 1 : -1; + + for ($value = $start; ; $value += $step) { $values[] = $value; + + if ($value === $end) { + break; + } } } diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index 2aad262..5a5e71d 100644 --- a/src/Testing/FakeFolder.php +++ b/src/Testing/FakeFolder.php @@ -137,6 +137,8 @@ public function status(): array */ public function examine(): array { + $this->mailbox?->examine($this); + return []; } diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index 2d297f8..28325da 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -129,6 +129,16 @@ public function select(FolderInterface $folder, bool $force = false, SelectionOp return new SelectionResult; } + /** + * {@inheritDoc} + */ + public function examine(FolderInterface $folder): SelectionResult + { + $this->selected = null; + + return new SelectionResult; + } + /** * {@inheritDoc} */ diff --git a/tests/Integration/FoldersTest.php b/tests/Integration/FoldersTest.php index 8693c2b..d5890a9 100644 --- a/tests/Integration/FoldersTest.php +++ b/tests/Integration/FoldersTest.php @@ -106,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/Unit/Connection/ImapConnectionParametersTest.php b/tests/Unit/Connection/ImapConnectionParametersTest.php index 976255c..3a4d59e 100644 --- a/tests/Unit/Connection/ImapConnectionParametersTest.php +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -234,3 +234,27 @@ [[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/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php index 94d73c0..a5401c0 100644 --- a/tests/Unit/FetchedMessageDataTest.php +++ b/tests/Unit/FetchedMessageDataTest.php @@ -255,3 +255,23 @@ $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 7faac60..c021015 100644 --- a/tests/Unit/FolderTest.php +++ b/tests/Unit/FolderTest.php @@ -1,7 +1,9 @@ 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 index 43b058f..f6cffa7 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -213,3 +213,24 @@ $stream->assertNotWritten('UID SEARCH'); expect($changes->messages()[0]->uid())->toBe(7); }); + +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/Support/StrTest.php b/tests/Unit/Support/StrTest.php index 2eac79e..be2b0b1 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -186,3 +186,16 @@ expect(Str::toImapUtf7($input))->toBe($expected); }); + +test('sequence expansion preserves ascending descending and single value ranges', function () { + expect(Str::parseSequenceSet('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::parseSequenceSet('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/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([]); +}); From a4c13d0f29688daa74552b339102cfdc6f958df5 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 3 Sep 2026 08:43:19 -0400 Subject: [PATCH 10/16] Adjust param order --- src/Connection/ConnectionInterface.php | 4 ++-- src/Connection/ImapConnection.php | 4 ++-- .../ImapConnectionParametersTest.php | 22 ++++++++++++------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 7658475..7466fa3 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -128,7 +128,7 @@ public function capability(): UntaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command */ - public function search(array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, ?string $charset = null): UntaggedResponse; + public function search(array $criteria, ?string $charset = null, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse; /** * Send a "SORT" command. @@ -137,7 +137,7 @@ public function search(array $criteria, ImapIdentifier $identifier = ImapIdentif * * @see https://datatracker.ietf.org/doc/html/rfc5256 */ - public function sort(ImapSort $sort, array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, string $charset = 'UTF-8'): UntaggedResponse; + public function sort(ImapSort $sort, array $criteria, string $charset = 'UTF-8', ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse; /** * Send an "ID" command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index db82c33..ddf4ff0 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -518,7 +518,7 @@ public function store(array|int|string $set, array|string $flags, ?string $mode /** * {@inheritDoc} */ - public function search(array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, ?string $charset = null): UntaggedResponse + public function search(array $criteria, ?string $charset = null, ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { $tokens = $charset === null ? $criteria : ['CHARSET', Str::literal($charset), ...$criteria]; @@ -534,7 +534,7 @@ public function search(array $criteria, ImapIdentifier $identifier = ImapIdentif /** * {@inheritDoc} */ - public function sort(ImapSort $sort, array $criteria, ImapIdentifier $identifier = ImapIdentifier::Uid, string $charset = 'UTF-8'): UntaggedResponse + public function sort(ImapSort $sort, array $criteria, string $charset = 'UTF-8', ImapIdentifier $identifier = ImapIdentifier::Uid): UntaggedResponse { $this->send($identifier === ImapIdentifier::Uid ? 'UID SORT' : 'SORT', ["({$sort->toImap()})", $charset, ...$criteria], tag: $tag); diff --git a/tests/Unit/Connection/ImapConnectionParametersTest.php b/tests/Unit/Connection/ImapConnectionParametersTest.php index 3a4d59e..304938e 100644 --- a/tests/Unit/Connection/ImapConnectionParametersTest.php +++ b/tests/Unit/Connection/ImapConnectionParametersTest.php @@ -131,7 +131,7 @@ ['expunge', [], 'UID EXPUNGE 1:3,7:*'], ]); -test('search accepts an explicit charset separately from criteria', function () { +test('search accepts an explicit charset separately from criteria', function (ImapIdentifier $identifier, string $command) { $stream = new FakeStream; $stream->feed([ '* OK Ready', @@ -141,12 +141,15 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->search(['SUBJECT', '"été"'], charset: 'UTF-8'); + $connection->search(['SUBJECT', '"été"'], 'UTF-8', $identifier); - $stream->assertWritten('TAG1 UID SEARCH CHARSET "UTF-8" SUBJECT "été"'); -}); + $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 () { +test('sort accepts an explicit charset separately from criteria', function (ImapIdentifier $identifier, string $command) { $stream = new FakeStream; $stream->feed([ '* OK Ready', @@ -156,10 +159,13 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->sort(new ImapSort(new SortCriterion(ImapSortKey::Arrival)), ['ALL'], charset: 'US-ASCII'); + $connection->sort(new ImapSort(new SortCriterion(ImapSortKey::Arrival)), ['ALL'], 'US-ASCII', $identifier); - $stream->assertWritten('TAG1 UID SORT (ARRIVAL) US-ASCII ALL'); -}); + $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; From ab63e17cb71d0434f8e7b52a9ef8e98d9c554de4 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 3 Sep 2026 08:45:10 -0400 Subject: [PATCH 11/16] Rename sequence set parser to fromSequenceSet --- src/StoreResult.php | 2 +- src/Support/Str.php | 2 +- src/Vanished.php | 2 +- tests/Unit/Support/StrTest.php | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/StoreResult.php b/src/StoreResult.php index 73815c5..06a112d 100644 --- a/src/StoreResult.php +++ b/src/StoreResult.php @@ -34,7 +34,7 @@ public static function fromResponses(ResponseCollection $responses, TaggedRespon $code = $response->tokenAt(2); $modified = $code instanceof ResponseCodeData && strtoupper($code->first()?->value ?? '') === 'MODIFIED' - ? Str::parseSequenceSet($code->tokenAt(1)->value) + ? Str::fromSequenceSet($code->tokenAt(1)->value) : []; return new static($response, $messages, $modified, $responses); diff --git a/src/Support/Str.php b/src/Support/Str.php index ebb6c58..c522bd9 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -136,7 +136,7 @@ public static function set(int|string|array $from, int|float|string|null $to = n * * @return int[] */ - public static function parseSequenceSet(string $set): array + public static function fromSequenceSet(string $set): array { $values = []; diff --git a/src/Vanished.php b/src/Vanished.php index 70a8d05..7226308 100644 --- a/src/Vanished.php +++ b/src/Vanished.php @@ -26,7 +26,7 @@ public static function fromResponse(UntaggedResponse $response): static $sequenceSet = $response->tokenAt($earlier ? 3 : 2); return new static( - Str::parseSequenceSet($sequenceSet->value), + Str::fromSequenceSet($sequenceSet->value), $earlier, ); } diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php index be2b0b1..36c0526 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -24,7 +24,7 @@ }); test('parse sequence set expands values and ranges', function () { - expect(Str::parseSequenceSet('1:3,7,10:8'))->toBe([1, 2, 3, 7, 10, 9, 8]); + expect(Str::fromSequenceSet('1:3,7,10:8'))->toBe([1, 2, 3, 7, 10, 9, 8]); }); test('credentials', function () { @@ -188,12 +188,12 @@ }); test('sequence expansion preserves ascending descending and single value ranges', function () { - expect(Str::parseSequenceSet('1:3,9:7,5:5,4294967294:4294967295')) + 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::parseSequenceSet('1:100000'); + $values = Str::fromSequenceSet('1:100000'); expect($values)->toHaveCount(100000); expect($values[0])->toBe(1); From 0844b7be71fccea935076ec8ff2a3b6678cd4646 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 3 Sep 2026 11:44:53 -0400 Subject: [PATCH 12/16] Harden synchronization results and mailbox reconnects --- src/Connection/ImapConnection.php | 40 +++++- src/FetchResult.php | 5 +- src/Mailbox.php | 55 +++++--- src/MailboxInterface.php | 14 ++- src/MessageQuery.php | 6 + src/MessageQueryInterface.php | 2 + src/StoreResult.php | 11 +- src/Testing/FakeMailbox.php | 28 ++++- .../ImapConnectionAuthenticationTest.php | 4 +- .../ImapConnectionFilteringTest.php | 78 ++++++++++++ tests/Unit/Connection/ImapConnectionTest.php | 8 +- tests/Unit/IncrementalSyncTest.php | 102 +++++++++++++++ tests/Unit/MailboxCapabilitiesTest.php | 77 ++++++++++++ tests/Unit/MailboxTest.php | 118 +++++++++++++++++- tests/Unit/Testing/FakeMailboxTest.php | 37 ++++++ 15 files changed, 539 insertions(+), 46 deletions(-) create mode 100644 tests/Unit/Connection/ImapConnectionFilteringTest.php create mode 100644 tests/Unit/MailboxCapabilitiesTest.php diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index ddf4ff0..2f2e344 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -506,7 +506,11 @@ public function store(array|int|string $set, array|string $flags, ?string $mode $this->send($identifier === ImapIdentifier::Uid ? 'UID STORE' : 'STORE', $tokens, $tag); $response = $this->taggedResponse($tag); - $result = StoreResult::fromResponses($this->result->responses(), $response); + $result = StoreResult::fromResponses( + $this->result->responses(), + $response, + fn (FetchedMessageData $data, UntaggedResponse $response) => $this->matchesMessageSet($data, $response, $tokens[0], $identifier), + ); if ($response->status()->is('BAD') || ($response->failed() && empty($result->modified()))) { throw ImapCommandException::make($this->result->command(), $response); @@ -718,8 +722,8 @@ public function fetch(array|int|string $set, array|string $items, ImapIdentifier // >> TAG123 FETCH 123 (UID BODY[TEXT]) // << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!) // << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response - return FetchResult::fromResponses($this->result->responses(), function (FetchedMessageData $data) use ($items, $identifier) { - if ($identifier === ImapIdentifier::Uid && ! $data->has('UID')) { + 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; } @@ -736,6 +740,36 @@ public function fetch(array|int|string $set, array|string $items, ImapIdentifier }); } + /** + * 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. */ diff --git a/src/FetchResult.php b/src/FetchResult.php index 3f27bc3..68715b7 100644 --- a/src/FetchResult.php +++ b/src/FetchResult.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine; use DirectoryTree\ImapEngine\Collections\ResponseCollection; +use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Connection\Tokens\Token; class FetchResult @@ -19,7 +20,7 @@ public function __construct( /** * Create a fetch result from IMAP responses, optionally filtering fetched messages. * - * @param (callable(FetchedMessageData): bool)|null $filter + * @param (callable(FetchedMessageData, UntaggedResponse): bool)|null $filter */ public static function fromResponses(ResponseCollection $responses, ?callable $filter = null): static { @@ -34,7 +35,7 @@ public static function fromResponses(ResponseCollection $responses, ?callable $f ) { $message = FetchedMessageData::fromResponse($response); - if (! $filter || $filter($message)) { + if (! $filter || $filter($message, $response)) { $messages[] = $message; } } diff --git a/src/Mailbox.php b/src/Mailbox.php index d15983e..6891bdb 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -11,6 +11,7 @@ use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use Exception; +use InvalidArgumentException; class Mailbox implements MailboxInterface { @@ -28,7 +29,7 @@ class Mailbox implements MailboxInterface 'password' => '', 'encryption' => 'ssl', 'validate_cert' => true, - 'authentication' => 'plain', + 'authentication' => 'login', 'proxy' => [ 'socket' => null, 'username' => null, @@ -78,6 +79,7 @@ public function __construct(array $config = []) public function __clone(): void { $this->connection = null; + $this->capabilities = null; $this->selected = null; $this->selection = null; $this->enabled = []; @@ -96,10 +98,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); } @@ -126,10 +124,14 @@ public function connected(): bool /** * {@inheritDoc} */ - public function reconnect(): void + public function reconnect(?string $password = null): void { $this->disconnect(); + if ($password !== null) { + $this->config['password'] = $password; + } + $this->connect(); } @@ -167,17 +169,16 @@ class_exists($debug) => new $debug, */ protected function authenticate(): void { - if ($this->config('authentication') === 'oauth') { - $this->connection->authenticate(new Authentication\XOAuth2( - $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' => $this->connection->authenticate( + new Authentication\XOAuth2($username, $password), + ), + default => throw new InvalidArgumentException('Unsupported authentication mechanism.'), + }; } /** @@ -192,6 +193,7 @@ public function disconnect(): void // Do nothing. } finally { $this->connection = null; + $this->capabilities = null; $this->selected = null; $this->selection = null; $this->enabled = []; @@ -230,11 +232,21 @@ public function capabilities(): array ); } + /** + * {@inheritDoc} + */ + public function hasEnabledCapability(string $capability): bool + { + return in_array(strtoupper($capability), $this->enabled, true); + } + /** * {@inheritDoc} */ public function enable(string ...$capabilities): ResponseCollection { + $capabilities = array_map('strtoupper', $capabilities); + foreach ($capabilities as $capability) { if (! $this->hasCapability($capability)) { throw new ImapCapabilityException( @@ -251,7 +263,14 @@ public function enable(string ...$capabilities): ResponseCollection $responses = $this->connection()->enable(...$capabilities); - $this->enabled = array_unique([...$this->enabled, ...$capabilities]); + foreach ($responses as $response) { + if ($response->type()->is('ENABLED')) { + $this->enabled = array_unique([ + ...$this->enabled, + ...array_map(fn (Token $token) => strtoupper($token->value), $response->tokensAfter(2)), + ]); + } + } return $responses; } diff --git a/src/MailboxInterface.php b/src/MailboxInterface.php index 448edc3..e3d36df 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -23,9 +23,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. @@ -57,8 +60,15 @@ public function capabilities(): array; */ public function hasCapability(string $capability): bool; + /** + * Determine if a capability has been enabled for the current connection. + */ + public function hasEnabledCapability(string $capability): bool; + /** * Enable the given mailbox capabilities for the current connection. + * + * Call this before selecting or examining any folder. */ public function enable(string ...$capabilities): ResponseCollection; diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 4ce4fb0..1063470 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -95,6 +95,12 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = ); } + if ($vanished && ! $mailbox->hasEnabledCapability('QRESYNC')) { + throw new ImapCapabilityException( + 'Enable QRESYNC before selecting a folder to request vanished messages.' + ); + } + $items = array_map( fn (FetchItem $item) => $item->toImap(), $this->fetchItems, diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 5436f63..1033399 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -96,6 +96,8 @@ 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; diff --git a/src/StoreResult.php b/src/StoreResult.php index 06a112d..8139921 100644 --- a/src/StoreResult.php +++ b/src/StoreResult.php @@ -6,7 +6,6 @@ use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; -use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Support\Str; class StoreResult @@ -23,14 +22,12 @@ public function __construct( /** * Create a store result from IMAP responses. + * + * @param (callable(FetchedMessageData, UntaggedResponse): bool)|null $filter */ - public static function fromResponses(ResponseCollection $responses, TaggedResponse $response): static + public static function fromResponses(ResponseCollection $responses, TaggedResponse $response, ?callable $filter = null): static { - $messages = $responses->untagged() - ->filter(fn (UntaggedResponse $response) => ($type = $response->tokenAt(2)) instanceof Token && $type->is('FETCH')) - ->map(fn (UntaggedResponse $response) => FetchedMessageData::fromResponse($response)) - ->values() - ->all(); + $messages = FetchResult::fromResponses($responses, $filter)->messages(); $code = $response->tokenAt(2); $modified = $code instanceof ResponseCodeData && strtoupper($code->first()?->value ?? '') === 'MODIFIED' diff --git a/src/Testing/FakeMailbox.php b/src/Testing/FakeMailbox.php index 28325da..977458e 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -21,6 +21,11 @@ class FakeMailbox implements MailboxInterface */ protected ?FolderInterface $selected = null; + /** + * The capabilities enabled for the current connection. + */ + protected array $enabled = []; + /** * Constructor. */ @@ -40,10 +45,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); } @@ -66,9 +67,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->enabled = []; } /** @@ -111,11 +117,21 @@ public function capabilities(): array return $this->capabilities; } + /** + * {@inheritDoc} + */ + public function hasEnabledCapability(string $capability): bool + { + return in_array(strtoupper($capability), $this->enabled, true); + } + /** * {@inheritDoc} */ public function enable(string ...$capabilities): ResponseCollection { + $this->enabled = array_unique([...$this->enabled, ...array_map('strtoupper', $capabilities)]); + return new ResponseCollection; } diff --git a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php index 8aaac8d..614c67b 100644 --- a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php +++ b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php @@ -219,7 +219,7 @@ public function respond(string $challenge): ?string expect($connection->connected())->toBeFalse(); }); -test('mailbox oauth configuration uses challenge based authentication', function () { +test('mailbox xoauth2 configuration uses challenge based authentication', function () { $stream = new FakeStream; $stream->feed([ '* OK Ready', @@ -230,7 +230,7 @@ public function respond(string $challenge): ?string $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'secret', - 'authentication' => 'oauth', + 'authentication' => 'xoauth2', ]); $mailbox->connect(new ImapConnection($stream)); 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/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index 2afd500..f78961f 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -582,9 +582,9 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch(1, 'UID'); + $responses = $connection->fetch(123, 'UID'); - $stream->assertWritten('TAG1 UID FETCH 1 (UID)'); + $stream->assertWritten('TAG1 UID FETCH 123 (UID)'); expect($responses)->toBeInstanceOf(FetchResult::class); expect($responses->messages()[0]->uid())->toBe(123); @@ -893,9 +893,9 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $responses = $connection->fetch(1, 'FLAGS'); + $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); diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php index f6cffa7..8537e29 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -2,6 +2,7 @@ use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; +use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Fetch\ChangedSince; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; @@ -214,6 +215,107 @@ 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->hasEnabledCapability('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->hasEnabledCapability('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([ diff --git a/tests/Unit/MailboxCapabilitiesTest.php b/tests/Unit/MailboxCapabilitiesTest.php new file mode 100644 index 0000000..fa7808f --- /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->hasEnabledCapability('qresync'))->toBeTrue(); + expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); + + $mailbox->disconnect(); + + expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); + 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->hasCapability('QRESYNC'))->toBeFalse(); + expect($mailbox->hasCapability('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($clone->hasEnabledCapability('QRESYNC'))->toBeFalse(); + expect($mailbox->hasEnabledCapability('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->hasCapability('QRESYNC'))->toBeFalse(); + expect($clone->hasCapability('CONDSTORE'))->toBeTrue(); + expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); + $stream->assertWritten('TAG2 CAPABILITY'); +}); diff --git a/tests/Unit/MailboxTest.php b/tests/Unit/MailboxTest.php index ab03302..ef41518 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', diff --git a/tests/Unit/Testing/FakeMailboxTest.php b/tests/Unit/Testing/FakeMailboxTest.php index 126ac87..f4aecfa 100644 --- a/tests/Unit/Testing/FakeMailboxTest.php +++ b/tests/Unit/Testing/FakeMailboxTest.php @@ -37,12 +37,49 @@ ]); }); +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->hasEnabledCapability('QRESYNC'))->toBeFalse(); + + $mailbox->enable('qresync'); + + expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeTrue(); + + $mailbox->reconnect(); + + expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); + expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); +}); + test('it returns folder repository', function () { $mailbox = new FakeMailbox; From b5dda1518aba2d10a6b474159ba4cba2d9475ef4 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 4 Sep 2026 21:09:27 -0400 Subject: [PATCH 13/16] Extract SASL authentication exchange --- src/Authentication.php | 51 +++++++++++++++++++ src/Authentication/XOAuth2.php | 4 +- src/Authenticator.php | 4 +- src/Connection/ConnectionInterface.php | 12 +++-- src/Connection/ImapConnection.php | 27 +++++----- src/Mailbox.php | 19 ++++--- .../ImapConnectionAuthenticationTest.php | 25 +++++---- tests/Unit/Connection/ImapConnectionTest.php | 5 +- 8 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 src/Authentication.php diff --git a/src/Authentication.php b/src/Authentication.php new file mode 100644 index 0000000..90a3d4f --- /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 index ac69ea2..fd52229 100644 --- a/src/Authentication/XOAuth2.php +++ b/src/Authentication/XOAuth2.php @@ -25,7 +25,7 @@ public function mechanism(): string /** * {@inheritDoc} */ - public function initialResponse(): string + public function initial(): string { return "user=$this->user\1auth=Bearer $this->token\1\1"; } @@ -35,6 +35,6 @@ public function initialResponse(): string */ public function respond(string $challenge): string { - return $challenge === '' ? $this->initialResponse() : ''; + return ''; } } diff --git a/src/Authenticator.php b/src/Authenticator.php index 84f7966..745b97b 100644 --- a/src/Authenticator.php +++ b/src/Authenticator.php @@ -10,9 +10,9 @@ interface Authenticator public function mechanism(): string; /** - * Get the unencoded initial response, or null to await a challenge. + * Get the unencoded initial data, or null to await a challenge. */ - public function initialResponse(): ?string; + public function initial(): ?string; /** * Respond to a decoded challenge. Return null to cancel authentication. diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 7466fa3..598a237 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -4,7 +4,6 @@ use DateTimeInterface; use DirectoryTree\ImapEngine\AppendResult; -use DirectoryTree\ImapEngine\Authenticator; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; @@ -58,12 +57,19 @@ public function logout(): void; /** * Send an "AUTHENTICATE" command. * - * Authenticate using a SASL mechanism. Initial responses require SASL-IR support. + * 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(Authenticator $authenticator, bool $initialResponse = false): 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. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 2f2e344..b22d9a0 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -4,7 +4,6 @@ use DateTimeInterface; use DirectoryTree\ImapEngine\AppendResult; -use DirectoryTree\ImapEngine\Authenticator; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; @@ -213,12 +212,12 @@ public function logout(): void /** * {@inheritDoc} */ - public function authenticate(Authenticator $authenticator, bool $initialResponse = false): TaggedResponse + public function authenticate(string $mechanism, ?string $initial = null): Generator { - $tokens = [$authenticator->mechanism()]; + $tokens = [$mechanism]; - if ($initialResponse && ($response = $authenticator->initialResponse()) !== null) { - $tokens[] = $response === '' ? '=' : base64_encode($response); + if ($initial !== null) { + $tokens[] = $initial === '' ? '=' : base64_encode($initial); } $this->send('AUTHENTICATE', $tokens, $tag); @@ -235,20 +234,20 @@ public function authenticate(Authenticator $authenticator, bool $initialResponse return $response; } - try { - $answer = $authenticator->respond(base64_decode(trim(substr((string) $response, 1)))); - } catch (Throwable $e) { - $this->disconnect(); - - throw $e; - } - - $this->write($answer === null ? '*' : base64_encode($answer), sensitive: true); + 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); + } + /** * {@inheritDoc} */ diff --git a/src/Mailbox.php b/src/Mailbox.php index 6891bdb..551baa8 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -45,6 +45,11 @@ class Mailbox implements MailboxInterface */ protected ?array $capabilities = null; + /** + * The capabilities enabled for the current connection. + */ + protected array $enabled = []; + /** * The currently selected folder. */ @@ -55,11 +60,6 @@ class Mailbox implements MailboxInterface */ protected ?SelectionResult $selection = null; - /** - * The capabilities enabled for the current connection. - */ - protected array $enabled = []; - /** * The mailbox connection. */ @@ -128,7 +128,7 @@ public function reconnect(?string $password = null): void { $this->disconnect(); - if ($password !== null) { + if (! is_null($password)) { $this->config['password'] = $password; } @@ -174,10 +174,13 @@ protected function authenticate(): void match ($this->config('authentication')) { 'login' => $this->connection->login($username, $password), - 'xoauth2' => $this->connection->authenticate( + 'xoauth2' => (new Authentication( + $this->connection, new Authentication\XOAuth2($username, $password), + ))->authenticate(), + default => throw new InvalidArgumentException( + 'Unsupported authentication mechanism.' ), - default => throw new InvalidArgumentException('Unsupported authentication mechanism.'), }; } diff --git a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php index 614c67b..aa33b97 100644 --- a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php +++ b/tests/Unit/Connection/ImapConnectionAuthenticationTest.php @@ -1,5 +1,6 @@ connect('imap.example.com'); - $response = $connection->authenticate(new XOAuth2('foo', 'secret'), initialResponse: $initialResponse); + $response = (new Authentication($connection, new XOAuth2('foo', 'secret'))) + ->authenticate(initial: $initialResponse); $credentials = base64_encode("user=foo\1auth=Bearer secret\1\1"); @@ -58,7 +60,8 @@ public function received(string $message): void {} $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(fn () => $connection->authenticate(new XOAuth2('foo', 'secret'), initialResponse: $initialResponse)) + 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"); @@ -93,7 +96,7 @@ public function mechanism(): string return 'LOGIN'; } - public function initialResponse(): ?string + public function initial(): ?string { return null; } @@ -111,7 +114,7 @@ public function respond(string $challenge): string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate($authenticator, initialResponse: true); + (new Authentication($connection, $authenticator))->authenticate(initial: true); $stream->assertWritten("TAG1 AUTHENTICATE LOGIN\r\n"); $stream->assertWritten(base64_encode('foo')."\r\n"); @@ -133,7 +136,7 @@ public function mechanism(): string return 'EXTERNAL'; } - public function initialResponse(): string + public function initial(): string { return ''; } @@ -146,7 +149,7 @@ public function respond(string $challenge): string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate($authenticator, initialResponse: true); + (new Authentication($connection, $authenticator))->authenticate(initial: true); $stream->assertWritten("TAG1 AUTHENTICATE EXTERNAL =\r\n"); }); @@ -167,7 +170,7 @@ public function mechanism(): string return 'X-CUSTOM'; } - public function initialResponse(): ?string + public function initial(): ?string { return null; } @@ -181,7 +184,8 @@ public function respond(string $challenge): ?string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(fn () => $connection->authenticate($authenticator))->toThrow(ImapCommandException::class); + expect(fn () => (new Authentication($connection, $authenticator))->authenticate()) + ->toThrow(ImapCommandException::class); $stream->assertWritten("*\r\n"); expect($connection->noop()->successful())->toBeTrue(); @@ -201,7 +205,7 @@ public function mechanism(): string return 'X-CUSTOM'; } - public function initialResponse(): ?string + public function initial(): ?string { return null; } @@ -215,7 +219,8 @@ public function respond(string $challenge): ?string $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - expect(fn () => $connection->authenticate($authenticator))->toThrow(RuntimeException::class, 'Unable to respond'); + expect(fn () => (new Authentication($connection, $authenticator))->authenticate()) + ->toThrow(RuntimeException::class, 'Unable to respond'); expect($connection->connected())->toBeFalse(); }); diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index f78961f..d4f75e2 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,6 +1,7 @@ connect('imap.example.com'); - $connection->authenticate(new XOAuth2('foo', 'bar')); + (new Authentication($connection, new XOAuth2('foo', 'bar')))->authenticate(); $credentials = Str::credentials('foo', 'bar'); @@ -141,7 +142,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->authenticate(new XOAuth2('foo', 'bar')); + (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 () { From 68e6ab682a0169736de9d2e0d3782957df7ae4a7 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 4 Sep 2026 21:22:57 -0400 Subject: [PATCH 14/16] Add fake logger assertions --- src/Connection/Loggers/FakeLogger.php | 74 +++++++++++++++++++ ...icationTest.php => AuthenticationTest.php} | 16 +--- tests/Unit/Connection/FakeLoggerTest.php | 16 ++++ 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 src/Connection/Loggers/FakeLogger.php rename tests/Unit/{Connection/ImapConnectionAuthenticationTest.php => AuthenticationTest.php} (94%) create mode 100644 tests/Unit/Connection/FakeLoggerTest.php 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/tests/Unit/Connection/ImapConnectionAuthenticationTest.php b/tests/Unit/AuthenticationTest.php similarity index 94% rename from tests/Unit/Connection/ImapConnectionAuthenticationTest.php rename to tests/Unit/AuthenticationTest.php index aa33b97..52271b3 100644 --- a/tests/Unit/Connection/ImapConnectionAuthenticationTest.php +++ b/tests/Unit/AuthenticationTest.php @@ -4,7 +4,7 @@ use DirectoryTree\ImapEngine\Authentication\XOAuth2; use DirectoryTree\ImapEngine\Authenticator; use DirectoryTree\ImapEngine\Connection\ImapConnection; -use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; +use DirectoryTree\ImapEngine\Connection\Loggers\FakeLogger; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Mailbox; @@ -17,17 +17,7 @@ 'TAG1 OK Authenticated', ])); - $logger = new class implements LoggerInterface - { - public array $sent = []; - - public function sent(string $message): void - { - $this->sent[] = $message; - } - - public function received(string $message): void {} - }; + $logger = new FakeLogger; $connection = new ImapConnection($stream, $logger); $connection->connect('imap.example.com'); @@ -44,7 +34,7 @@ public function received(string $message): void {} } expect($response->successful())->toBeTrue(); - expect($logger->sent)->toBe(array_fill(0, $initialResponse ? 1 : 2, '[redacted]')); + $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) { 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'); +}); From 007487b1f366f65d10fe7d8451d73dc2afd8ee81 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 4 Sep 2026 23:03:21 -0400 Subject: [PATCH 15/16] Refine capability and selection APIs --- src/Authentication.php | 2 +- src/Authentication/XOAuth2.php | 4 +- ...ticator.php => AuthenticatorInterface.php} | 2 +- src/Capabilities.php | 77 +++++++++++++ src/Capability.php | 53 +++++++++ src/Connection/ConnectionInterface.php | 16 +-- src/Connection/ImapConnection.php | 22 ++-- src/Fetch/ChangedSince.php | 4 +- .../ModifierInterface.php} | 4 +- src/Folder.php | 8 +- src/FolderData.php | 2 +- ...taItem.php => FolderDataItemInterface.php} | 2 +- src/FolderInterface.php | 5 +- src/FolderRepository.php | 8 +- src/FolderRepositoryInterface.php | 2 +- src/HasCapabilities.php | 24 ----- src/Mailbox.php | 102 +++++++++--------- src/MailboxInterface.php | 18 +--- src/Message.php | 6 +- src/MessageData/Attribute.php | 2 +- src/MessageData/Body.php | 2 +- .../{FetchItem.php => FetchItemInterface.php} | 2 +- src/MessageQuery.php | 14 +-- src/MessageQueryInterface.php | 8 +- src/QueriesMessages.php | 10 +- src/Selection/CondStore.php | 4 +- .../OptionInterface.php} | 4 +- src/Selection/QuickResync.php | 3 +- src/Selection/RequiresEnableInterface.php | 8 ++ .../Result.php} | 5 +- .../ModifierInterface.php} | 4 +- src/Store/UnchangedSince.php | 4 +- src/Testing/FakeFolder.php | 8 +- src/Testing/FakeFolderRepository.php | 6 +- src/Testing/FakeMailbox.php | 49 +++++---- tests/Integration/MailboxTest.php | 4 +- tests/Unit/ArchitectureTest.php | 6 ++ tests/Unit/AuthenticationTest.php | 10 +- tests/Unit/CapabilitiesTest.php | 40 +++++++ .../ImapConnectionOperationsTest.php | 4 +- tests/Unit/Connection/ImapConnectionTest.php | 4 +- tests/Unit/IncrementalSyncTest.php | 47 +++++++- tests/Unit/MailboxCapabilitiesTest.php | 20 ++-- tests/Unit/MailboxTest.php | 14 +-- tests/Unit/MessageDataTest.php | 8 +- tests/Unit/Testing/FakeMailboxTest.php | 24 +++-- 46 files changed, 443 insertions(+), 232 deletions(-) rename src/{Authenticator.php => AuthenticatorInterface.php} (92%) create mode 100644 src/Capabilities.php create mode 100644 src/Capability.php rename src/{FetchModifier.php => Fetch/ModifierInterface.php} (63%) rename src/{FolderDataItem.php => FolderDataItemInterface.php} (91%) delete mode 100644 src/HasCapabilities.php rename src/MessageData/{FetchItem.php => FetchItemInterface.php} (90%) rename src/{SelectionOption.php => Selection/OptionInterface.php} (77%) create mode 100644 src/Selection/RequiresEnableInterface.php rename src/{SelectionResult.php => Selection/Result.php} (97%) rename src/{StoreModifier.php => Store/ModifierInterface.php} (63%) create mode 100644 tests/Unit/ArchitectureTest.php create mode 100644 tests/Unit/CapabilitiesTest.php diff --git a/src/Authentication.php b/src/Authentication.php index 90a3d4f..868d68d 100644 --- a/src/Authentication.php +++ b/src/Authentication.php @@ -13,7 +13,7 @@ class Authentication */ public function __construct( protected ConnectionInterface $connection, - protected Authenticator $authenticator, + protected AuthenticatorInterface $authenticator, ) {} /** diff --git a/src/Authentication/XOAuth2.php b/src/Authentication/XOAuth2.php index fd52229..cbdda8a 100644 --- a/src/Authentication/XOAuth2.php +++ b/src/Authentication/XOAuth2.php @@ -2,9 +2,9 @@ namespace DirectoryTree\ImapEngine\Authentication; -use DirectoryTree\ImapEngine\Authenticator; +use DirectoryTree\ImapEngine\AuthenticatorInterface; -class XOAuth2 implements Authenticator +class XOAuth2 implements AuthenticatorInterface { /** * Constructor. diff --git a/src/Authenticator.php b/src/AuthenticatorInterface.php similarity index 92% rename from src/Authenticator.php rename to src/AuthenticatorInterface.php index 745b97b..7f68aa9 100644 --- a/src/Authenticator.php +++ b/src/AuthenticatorInterface.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine; -interface Authenticator +interface AuthenticatorInterface { /** * Get the SASL mechanism name. diff --git a/src/Capabilities.php b/src/Capabilities.php new file mode 100644 index 0000000..243f440 --- /dev/null +++ b/src/Capabilities.php @@ -0,0 +1,77 @@ + + */ + 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 598a237..d75282f 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -8,12 +8,12 @@ use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; use DirectoryTree\ImapEngine\Enums\ImapIdentifier; -use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\Fetch\ModifierInterface as FetchModifierInterface; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; -use DirectoryTree\ImapEngine\SelectionOption; -use DirectoryTree\ImapEngine\SelectionResult; -use DirectoryTree\ImapEngine\StoreModifier; +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; @@ -165,7 +165,7 @@ public function id(?array $parameters = null): UntaggedResponse; * @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|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult; + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifierInterface ...$modifiers): FetchResult; /** * Send an IMAP command. @@ -179,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 = 'INBOX', SelectionOption ...$options): SelectionResult; + public function select(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult; /** * Send a "EXAMINE" command. @@ -188,7 +188,7 @@ public function select(string $folder = 'INBOX', SelectionOption ...$options): S * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command */ - public function examine(string $folder = 'INBOX', SelectionOption ...$options): SelectionResult; + public function examine(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult; /** * Send a "LIST" command. @@ -219,7 +219,7 @@ public function status(string $folder = 'INBOX', array $items = ['MESSAGES', 'UN * @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|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult; + 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. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index b22d9a0..13b659a 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -22,12 +22,12 @@ use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; use DirectoryTree\ImapEngine\FetchedMessageData; -use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\Fetch\ModifierInterface as FetchModifierInterface; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; -use DirectoryTree\ImapEngine\SelectionOption; -use DirectoryTree\ImapEngine\SelectionResult; -use DirectoryTree\ImapEngine\StoreModifier; +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; @@ -277,7 +277,7 @@ public function enable(string ...$capabilities): ResponseCollection /** * {@inheritDoc} */ - public function select(string $folder = 'INBOX', SelectionOption ...$options): SelectionResult + public function select(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult { return $this->examineOrSelect('SELECT', $folder, $options); } @@ -285,7 +285,7 @@ public function select(string $folder = 'INBOX', SelectionOption ...$options): S /** * {@inheritDoc} */ - public function examine(string $folder = 'INBOX', SelectionOption ...$options): SelectionResult + public function examine(string $folder = 'INBOX', OptionInterface ...$options): SelectionResult { return $this->examineOrSelect('EXAMINE', $folder, $options); } @@ -299,7 +299,7 @@ protected function examineOrSelect(string $command = 'EXAMINE', string $folder = if ($options) { $tokens[] = Str::list(array_map( - fn (SelectionOption $option) => $option->toImap(), + fn (OptionInterface $option) => $option->toImap(), $options, )); } @@ -488,13 +488,13 @@ public function move(array|int|string $set, string $folder, ImapIdentifier $iden /** * {@inheritDoc} */ - public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifier ...$modifiers): StoreResult + public function store(array|int|string $set, array|string $flags, ?string $mode = '+', bool $silent = true, ImapIdentifier $identifier = ImapIdentifier::Uid, StoreModifierInterface ...$modifiers): StoreResult { $tokens = [Str::set($set)]; if ($modifiers) { $tokens[] = Str::list(array_map( - fn (StoreModifier $modifier) => $modifier->toImap(), + fn (StoreModifierInterface $modifier) => $modifier->toImap(), $modifiers, )); } @@ -688,7 +688,7 @@ protected function write(string $data, bool $sensitive = false): void /** * {@inheritDoc} */ - public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifier ...$modifiers): FetchResult + public function fetch(array|int|string $set, array|string $items, ImapIdentifier $identifier = ImapIdentifier::Uid, FetchModifierInterface ...$modifiers): FetchResult { $prefix = ($identifier === ImapIdentifier::Uid) ? 'UID' : ''; @@ -706,7 +706,7 @@ public function fetch(array|int|string $set, array|string $items, ImapIdentifier if ($modifiers) { $tokens[] = Str::list(array_map( - fn (FetchModifier $modifier) => $modifier->toImap(), + fn (FetchModifierInterface $modifier) => $modifier->toImap(), $modifiers, )); } diff --git a/src/Fetch/ChangedSince.php b/src/Fetch/ChangedSince.php index 37532d2..0fe4aa6 100644 --- a/src/Fetch/ChangedSince.php +++ b/src/Fetch/ChangedSince.php @@ -2,8 +2,6 @@ namespace DirectoryTree\ImapEngine\Fetch; -use DirectoryTree\ImapEngine\FetchModifier; - /** * Fetch messages changed after a modification sequence. * @@ -12,7 +10,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc7162#section-3.1.4 * @see https://datatracker.ietf.org/doc/html/rfc7162#section-3.2.6 */ -class ChangedSince implements FetchModifier +class ChangedSince implements ModifierInterface { /** * Constructor. diff --git a/src/FetchModifier.php b/src/Fetch/ModifierInterface.php similarity index 63% rename from src/FetchModifier.php rename to src/Fetch/ModifierInterface.php index f964b47..2be0d53 100644 --- a/src/FetchModifier.php +++ b/src/Fetch/ModifierInterface.php @@ -1,8 +1,8 @@ mailbox->hasCapability('IDLE')) { + if (! $this->mailbox->capabilities()->supports('IDLE')) { throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.'); } @@ -173,7 +175,7 @@ public function move(string $newPath): void /** * {@inheritDoc} */ - public function select(bool $force = false, SelectionOption ...$options): SelectionResult + public function select(bool $force = false, OptionInterface ...$options): Result { return $this->mailbox->select($this, $force, ...$options); } @@ -183,7 +185,7 @@ public function select(bool $force = false, SelectionOption ...$options): Select */ 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.' ); 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 658b644..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, SelectionOption ...$options): SelectionResult; + 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 1b869be..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." ); 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 551baa8..66f4ab1 100644 --- a/src/Mailbox.php +++ b/src/Mailbox.php @@ -10,13 +10,14 @@ 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. */ @@ -43,22 +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 capabilities enabled for the current connection. + * The currently selected or examined folder. */ - protected array $enabled = []; - - /** - * The currently selected folder. - */ - protected ?FolderInterface $selected = null; + protected ?FolderInterface $folder = null; /** * The result from the currently selected folder. */ - protected ?SelectionResult $selection = null; + protected ?Result $selection = null; /** * The mailbox connection. @@ -80,9 +76,8 @@ public function __clone(): void { $this->connection = null; $this->capabilities = null; - $this->selected = null; + $this->folder = null; $this->selection = null; - $this->enabled = []; } /** @@ -197,9 +192,8 @@ public function disconnect(): void } finally { $this->connection = null; $this->capabilities = null; - $this->selected = null; + $this->folder = null; $this->selection = null; - $this->enabled = []; } } @@ -227,51 +221,55 @@ 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 hasEnabledCapability(string $capability): bool - { - return in_array(strtoupper($capability), $this->enabled, true); - } - /** * {@inheritDoc} */ public function enable(string ...$capabilities): ResponseCollection { - $capabilities = array_map('strtoupper', $capabilities); + $current = $this->capabilities(); - foreach ($capabilities as $capability) { - if (! $this->hasCapability($capability)) { + $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." ); } } - $capabilities = array_values(array_diff($capabilities, $this->enabled)); + $requested = array_values(array_filter( + $requested->all(), + fn (string $capability) => ! $current->enabled($capability), + )); - if (empty($capabilities)) { + if (empty($requested)) { return new ResponseCollection; } - $responses = $this->connection()->enable(...$capabilities); + 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->enabled = array_unique([ - ...$this->enabled, - ...array_map(fn (Token $token) => strtoupper($token->value), $response->tokensAfter(2)), - ]); + $this->capabilities->enable( + ...array_map(fn (Token $token) => $token->value, $response->tokensAfter(2)) + ); } } @@ -281,40 +279,46 @@ public function enable(string ...$capabilities): ResponseCollection /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result { foreach ($options as $option) { - if (! $this->hasCapability($option->capability())) { + if (! $this->capabilities()->supports($option->capability())) { throw new ImapCapabilityException( "Unable to select folder with [{$option->capability()}]. IMAP server does not support it." ); } - if ($option->capability() === 'QRESYNC') { - $this->enable('QRESYNC'); + if ($option instanceof RequiresEnableInterface) { + $this->enable($option->capability()); } } if (! $this->selected($folder) || $force || $options) { - $this->selection = $this->connection()->select($folder->path(), ...$options); - } + $this->selection = null; + + $selection = $this->connection()->select($folder->path(), ...$options); - $this->selected = $folder; + $this->folder = $folder; + $this->selection = $selection; + } - return $this->selection ?? new SelectionResult; + return $this->selection; } /** * {@inheritDoc} */ - public function examine(FolderInterface $folder): SelectionResult + 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->selected = null; $this->selection = null; - return $this->connection()->examine($folder->path()); + $selection = $this->connection()->examine($folder->path()); + + $this->folder = $folder; + + return $selection; } /** @@ -322,6 +326,6 @@ public function examine(FolderInterface $folder): SelectionResult */ 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 e3d36df..37e075c 100644 --- a/src/MailboxInterface.php +++ b/src/MailboxInterface.php @@ -4,6 +4,8 @@ use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; +use DirectoryTree\ImapEngine\Selection\OptionInterface; +use DirectoryTree\ImapEngine\Selection\Result; interface MailboxInterface { @@ -53,17 +55,7 @@ public function folders(): FolderRepositoryInterface; /** * Get the mailbox's capabilities. */ - public function capabilities(): array; - - /** - * Determine if the mailbox supports the given capability. - */ - public function hasCapability(string $capability): bool; - - /** - * Determine if a capability has been enabled for the current connection. - */ - public function hasEnabledCapability(string $capability): bool; + public function capabilities(): Capabilities; /** * Enable the given mailbox capabilities for the current connection. @@ -75,12 +67,12 @@ public function enable(string ...$capabilities): ResponseCollection; /** * Select the given folder. */ - public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult; + 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): SelectionResult; + public function examine(FolderInterface $folder): Result; /** * Determine if the given folder is selected. diff --git a/src/Message.php b/src/Message.php index b626909..93b6643 100644 --- a/src/Message.php +++ b/src/Message.php @@ -202,7 +202,7 @@ 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' ); @@ -223,12 +223,12 @@ public function move(string $folder, bool $expunge = false): ?int $mailbox = $this->folder->mailbox(); switch (true) { - case $mailbox->hasCapability('MOVE'): + 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); diff --git a/src/MessageData/Attribute.php b/src/MessageData/Attribute.php index bcf3b01..c5aeca5 100644 --- a/src/MessageData/Attribute.php +++ b/src/MessageData/Attribute.php @@ -2,7 +2,7 @@ namespace DirectoryTree\ImapEngine\MessageData; -enum Attribute: string implements FetchItem +enum Attribute: string implements FetchItemInterface { case Flags = 'FLAGS'; case Size = 'RFC822.SIZE'; 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/MessageQuery.php b/src/MessageQuery.php index 1063470..d7b0e33 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -14,7 +14,7 @@ use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Exceptions\ImapCommandException; use DirectoryTree\ImapEngine\Fetch\ChangedSince; -use DirectoryTree\ImapEngine\MessageData\FetchItem; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Support\Collection; @@ -86,8 +86,8 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = $mailbox = $this->folder->mailbox(); - $supported = $mailbox->hasCapability($capability) - || ($capability === 'CONDSTORE' && $mailbox->hasCapability('QRESYNC')); + $supported = $mailbox->capabilities()->supports($capability) + || ($capability === 'CONDSTORE' && $mailbox->capabilities()->supports('QRESYNC')); if (! $supported) { throw new ImapCapabilityException( @@ -95,14 +95,14 @@ public function changesSince(int $modSequence, array|int $uids, bool $vanished = ); } - if ($vanished && ! $mailbox->hasEnabledCapability('QRESYNC')) { + if ($vanished && ! $mailbox->capabilities()->enabled('QRESYNC')) { throw new ImapCapabilityException( 'Enable QRESYNC before selecting a folder to request vanished messages.' ); } $items = array_map( - fn (FetchItem $item) => $item->toImap(), + fn (FetchItemInterface $item) => $item->toImap(), $this->fetchItems, ); @@ -376,7 +376,7 @@ 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, ); @@ -432,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.' ); diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 1033399..d6b2239 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -9,7 +9,7 @@ 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. 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 index 179e681..4ff75e6 100644 --- a/src/Selection/CondStore.php +++ b/src/Selection/CondStore.php @@ -2,9 +2,7 @@ namespace DirectoryTree\ImapEngine\Selection; -use DirectoryTree\ImapEngine\SelectionOption; - -class CondStore implements SelectionOption +class CondStore implements OptionInterface { /** * {@inheritDoc} diff --git a/src/SelectionOption.php b/src/Selection/OptionInterface.php similarity index 77% rename from src/SelectionOption.php rename to src/Selection/OptionInterface.php index d2ab71f..61ac819 100644 --- a/src/SelectionOption.php +++ b/src/Selection/OptionInterface.php @@ -1,8 +1,8 @@ mailbox?->select($this, $force, ...$options) ?? new SelectionResult; + return $this->mailbox?->select($this, $force, ...$options) ?? new Result; } /** 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 977458e..09d81dc 100644 --- a/src/Testing/FakeMailbox.php +++ b/src/Testing/FakeMailbox.php @@ -2,29 +2,28 @@ 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\SelectionOption; -use DirectoryTree\ImapEngine\SelectionResult; +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 capabilities enabled for the current connection. + * The mailbox capabilities. */ - protected array $enabled = []; + protected Capabilities $capabilities; /** * Constructor. @@ -33,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); } @@ -74,7 +75,7 @@ public function reconnect(?string $password = null): void } $this->selected = null; - $this->enabled = []; + $this->capabilities = Capabilities::from($this->capabilities->all()); } /** @@ -112,25 +113,27 @@ public function folders(): FolderRepositoryInterface /** * {@inheritDoc} */ - public function capabilities(): array + public function capabilities(): Capabilities { return $this->capabilities; } - /** - * {@inheritDoc} - */ - public function hasEnabledCapability(string $capability): bool - { - return in_array(strtoupper($capability), $this->enabled, true); - } - /** * {@inheritDoc} */ public function enable(string ...$capabilities): ResponseCollection { - $this->enabled = array_unique([...$this->enabled, ...array_map('strtoupper', $capabilities)]); + $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; } @@ -138,21 +141,21 @@ public function enable(string ...$capabilities): ResponseCollection /** * {@inheritDoc} */ - public function select(FolderInterface $folder, bool $force = false, SelectionOption ...$options): SelectionResult + public function select(FolderInterface $folder, bool $force = false, OptionInterface ...$options): Result { $this->selected = $folder; - return new SelectionResult; + return new Result; } /** * {@inheritDoc} */ - public function examine(FolderInterface $folder): SelectionResult + public function examine(FolderInterface $folder): Result { $this->selected = null; - return new SelectionResult; + return new Result; } /** 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 index 52271b3..46b8471 100644 --- a/tests/Unit/AuthenticationTest.php +++ b/tests/Unit/AuthenticationTest.php @@ -2,7 +2,7 @@ use DirectoryTree\ImapEngine\Authentication; use DirectoryTree\ImapEngine\Authentication\XOAuth2; -use DirectoryTree\ImapEngine\Authenticator; +use DirectoryTree\ImapEngine\AuthenticatorInterface; use DirectoryTree\ImapEngine\Connection\ImapConnection; use DirectoryTree\ImapEngine\Connection\Loggers\FakeLogger; use DirectoryTree\ImapEngine\Connection\Streams\FakeStream; @@ -77,7 +77,7 @@ 'TAG1 OK Authenticated', ]); - $authenticator = new class implements Authenticator + $authenticator = new class implements AuthenticatorInterface { public array $challenges = []; @@ -119,7 +119,7 @@ public function respond(string $challenge): string 'TAG1 OK Authenticated', ]); - $authenticator = new class implements Authenticator + $authenticator = new class implements AuthenticatorInterface { public function mechanism(): string { @@ -153,7 +153,7 @@ public function respond(string $challenge): string 'TAG2 OK NOOP completed', ]); - $authenticator = new class implements Authenticator + $authenticator = new class implements AuthenticatorInterface { public function mechanism(): string { @@ -188,7 +188,7 @@ public function respond(string $challenge): ?string '+ '.base64_encode('challenge'), ]); - $authenticator = new class implements Authenticator + $authenticator = new class implements AuthenticatorInterface { public function mechanism(): string { 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/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php index 5030dcc..4b44988 100644 --- a/tests/Unit/Connection/ImapConnectionOperationsTest.php +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -9,7 +9,7 @@ use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\SortCriterion; use DirectoryTree\ImapEngine\Store\UnchangedSince; -use DirectoryTree\ImapEngine\StoreModifier; +use DirectoryTree\ImapEngine\Store\ModifierInterface; use DirectoryTree\ImapEngine\StoreResult; test('store supports adding removing and replacing flags', function (?string $mode, bool $silent, string $item) { @@ -126,7 +126,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $custom = new class implements StoreModifier + $custom = new class implements ModifierInterface { public function toImap(): string { diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index d4f75e2..b0bd63b 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -10,7 +10,7 @@ use DirectoryTree\ImapEngine\Exceptions\ImapConnectionException; use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException; use DirectoryTree\ImapEngine\Fetch\ChangedSince; -use DirectoryTree\ImapEngine\FetchModifier; +use DirectoryTree\ImapEngine\Fetch\ModifierInterface; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\StoreResult; use DirectoryTree\ImapEngine\Support\Str; @@ -1003,7 +1003,7 @@ $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $custom = new class implements FetchModifier + $custom = new class implements ModifierInterface { public function toImap(): string { diff --git a/tests/Unit/IncrementalSyncTest.php b/tests/Unit/IncrementalSyncTest.php index 8537e29..501d22d 100644 --- a/tests/Unit/IncrementalSyncTest.php +++ b/tests/Unit/IncrementalSyncTest.php @@ -183,6 +183,7 @@ $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))'); @@ -190,6 +191,48 @@ 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(); @@ -260,7 +303,7 @@ $query->changesSince(42, [7], vanished: true); expect($result->vanishedUids())->toBe([7]); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeTrue(); + 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)'); @@ -283,7 +326,7 @@ $mailbox->enable('QRESYNC'); $query = (new Folder($mailbox, 'INBOX'))->messages(); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); expect(fn () => $query->changesSince(42, [7], vanished: true))->toThrow(ImapCapabilityException::class); $stream->assertNotWritten('UID FETCH'); }); diff --git a/tests/Unit/MailboxCapabilitiesTest.php b/tests/Unit/MailboxCapabilitiesTest.php index fa7808f..9f9b1f4 100644 --- a/tests/Unit/MailboxCapabilitiesTest.php +++ b/tests/Unit/MailboxCapabilitiesTest.php @@ -22,12 +22,11 @@ $folder = new Folder($mailbox, 'INBOX'); $folder->select(); - expect($mailbox->hasEnabledCapability('qresync'))->toBeTrue(); - expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); + expect($mailbox->capabilities()->enabled('qresync'))->toBeTrue(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeTrue(); $mailbox->disconnect(); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); expect($mailbox->selected($folder))->toBeFalse(); $stream = new FakeStream; @@ -39,8 +38,9 @@ ]); $mailbox->connect(new ImapConnection($stream)); - expect($mailbox->hasCapability('QRESYNC'))->toBeFalse(); - expect($mailbox->hasCapability('CONDSTORE'))->toBeTrue(); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->supports('CONDSTORE'))->toBeTrue(); $stream->assertWritten('TAG2 CAPABILITY'); }); @@ -58,8 +58,7 @@ $clone = clone $mailbox; - expect($clone->hasEnabledCapability('QRESYNC'))->toBeFalse(); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeTrue(); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); $stream = new FakeStream; $stream->feed([ @@ -70,8 +69,9 @@ ]); $clone->connect(new ImapConnection($stream)); - expect($clone->hasCapability('QRESYNC'))->toBeFalse(); - expect($clone->hasCapability('CONDSTORE'))->toBeTrue(); - expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); + 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 ef41518..4244e27 100644 --- a/tests/Unit/MailboxTest.php +++ b/tests/Unit/MailboxTest.php @@ -261,15 +261,15 @@ public function connect(?ConnectionInterface $connection = null): void '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 20e002b..dbe6643 100644 --- a/tests/Unit/MessageDataTest.php +++ b/tests/Unit/MessageDataTest.php @@ -1,9 +1,9 @@ key())->toBe($command) ->and($item->toImap())->toBe($command); })->with([ @@ -13,7 +13,7 @@ [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]'], @@ -21,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/Testing/FakeMailboxTest.php b/tests/Unit/Testing/FakeMailboxTest.php index f4aecfa..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 () { @@ -68,16 +69,25 @@ test('it tracks enabled capabilities until reconnection', function () { $mailbox = new FakeMailbox(capabilities: ['QRESYNC']); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeFalse(); $mailbox->enable('qresync'); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeTrue(); + expect($mailbox->capabilities()->enabled('QRESYNC'))->toBeTrue(); $mailbox->reconnect(); - expect($mailbox->hasEnabledCapability('QRESYNC'))->toBeFalse(); - expect($mailbox->hasCapability('QRESYNC'))->toBeTrue(); + 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 () { From cea1083476236cf1554bc6278d29ec1406004c27 Mon Sep 17 00:00:00 2001 From: stevebauman <6421846+stevebauman@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:03:54 +0000 Subject: [PATCH 16/16] Fix code style --- src/Connection/ImapConnection.php | 2 +- tests/Unit/Connection/ImapConnectionOperationsTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 13b659a..c36cd8d 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -21,8 +21,8 @@ use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; use DirectoryTree\ImapEngine\Exceptions\ImapResponseException; use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; -use DirectoryTree\ImapEngine\FetchedMessageData; use DirectoryTree\ImapEngine\Fetch\ModifierInterface as FetchModifierInterface; +use DirectoryTree\ImapEngine\FetchedMessageData; use DirectoryTree\ImapEngine\FetchResult; use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\Selection\OptionInterface; diff --git a/tests/Unit/Connection/ImapConnectionOperationsTest.php b/tests/Unit/Connection/ImapConnectionOperationsTest.php index 4b44988..52fc5cd 100644 --- a/tests/Unit/Connection/ImapConnectionOperationsTest.php +++ b/tests/Unit/Connection/ImapConnectionOperationsTest.php @@ -8,8 +8,8 @@ use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException; use DirectoryTree\ImapEngine\ImapSort; use DirectoryTree\ImapEngine\SortCriterion; -use DirectoryTree\ImapEngine\Store\UnchangedSince; use DirectoryTree\ImapEngine\Store\ModifierInterface; +use DirectoryTree\ImapEngine\Store\UnchangedSince; use DirectoryTree\ImapEngine\StoreResult; test('store supports adding removing and replacing flags', function (?string $mode, bool $silent, string $item) {