diff --git a/src/Collections/FolderCollection.php b/src/Collections/FolderCollection.php index 650bd9e..732bff2 100644 --- a/src/Collections/FolderCollection.php +++ b/src/Collections/FolderCollection.php @@ -2,10 +2,28 @@ namespace DirectoryTree\ImapEngine\Collections; +use DirectoryTree\ImapEngine\Enums\ImapSpecialUse; use DirectoryTree\ImapEngine\FolderInterface; use Illuminate\Support\Collection; /** * @template-extends Collection */ -class FolderCollection extends Collection {} +class FolderCollection extends Collection +{ + /** + * Find a folder by its special use. + */ + public function findBySpecialUse(ImapSpecialUse $specialUse): ?FolderInterface + { + return $this->first(function (FolderInterface $folder) use ($specialUse) { + foreach ($folder->attributes() as $attribute) { + if (strcasecmp($attribute, $specialUse->value) === 0) { + return true; + } + } + + return false; + }); + } +} diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index 2cf5dd4..32ec711 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -234,7 +234,7 @@ public function examine(string $folder): ResponseCollection; * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command */ - public function list(string $reference = '', string $folder = '*'): ResponseCollection; + public function list(string $reference = '', string $folder = '*', array $return = []): ResponseCollection; /** * Send a "STATUS" command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index 826dc42..7080f66 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -351,9 +351,16 @@ public function quotaRoot(string $mailbox): ResponseCollection /** * {@inheritDoc} */ - public function list(string $reference = '', string $folder = '*'): ResponseCollection + public function list(string $reference = '', string $folder = '*', array $return = []): ResponseCollection { - $this->send('LIST', Str::literal([$reference, $folder]), $tag); + $tokens = Str::literal([$reference, $folder]); + + if ($return) { + $tokens[] = 'RETURN'; + $tokens[] = Str::list($return); + } + + $this->send('LIST', $tokens, $tag); $this->assertTaggedResponse($tag); diff --git a/src/Enums/ImapSpecialUse.php b/src/Enums/ImapSpecialUse.php new file mode 100644 index 0000000..95177b7 --- /dev/null +++ b/src/Enums/ImapSpecialUse.php @@ -0,0 +1,17 @@ +flags; + return $this->attributes; } /** @@ -263,7 +263,7 @@ public function toArray(): array { return [ 'path' => $this->path, - 'flags' => $this->flags, + 'attributes' => $this->attributes, 'delimiter' => $this->delimiter, ]; } diff --git a/src/FolderData.php b/src/FolderData.php new file mode 100644 index 0000000..eaaedd3 --- /dev/null +++ b/src/FolderData.php @@ -0,0 +1,32 @@ +value; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + return $this->value; + } + + /** + * {@inheritDoc} + */ + public function capability(): string + { + return $this->value; + } +} diff --git a/src/FolderDataItem.php b/src/FolderDataItem.php new file mode 100644 index 0000000..bf4b8f2 --- /dev/null +++ b/src/FolderDataItem.php @@ -0,0 +1,21 @@ + + */ + protected array $dataItems = []; + /** * Constructor. */ @@ -15,6 +23,18 @@ public function __construct( protected Mailbox $mailbox ) {} + /** + * {@inheritDoc} + */ + public function with(FolderDataItem ...$items): static + { + foreach ($items as $item) { + $this->dataItems[$item->key()] = $item; + } + + return $this; + } + /** * {@inheritDoc} */ @@ -56,11 +76,21 @@ public function firstOrCreate(string $path): FolderInterface */ public function get(?string $match = '*', ?string $reference = ''): FolderCollection { - return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map( + $return = array_map(function (FolderDataItem $item) { + if (! $this->mailbox->hasCapability($item->capability())) { + throw new ImapCapabilityException( + "Unable to fetch {$item->key()} folder data. IMAP server does not support {$item->capability()} capability." + ); + } + + return $item->toImap(); + }, $this->dataItems); + + return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match), $return)->map( fn (UntaggedResponse $response) => new Folder( mailbox: $this->mailbox, path: $response->tokenAt(4)->value, - flags: $response->tokenAt(2)->values(), + attributes: $response->tokenAt(2)->values(), delimiter: $response->tokenAt(3)->value, ) )->pipeInto(FolderCollection::class); diff --git a/src/FolderRepositoryInterface.php b/src/FolderRepositoryInterface.php index a3a85c4..25b4ea6 100644 --- a/src/FolderRepositoryInterface.php +++ b/src/FolderRepositoryInterface.php @@ -6,6 +6,11 @@ interface FolderRepositoryInterface { + /** + * Add items to the folder LIST request. + */ + public function with(FolderDataItem ...$items): static; + /** * Find a folder. */ diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index 45d14d1..d1e9d21 100644 --- a/src/Testing/FakeFolder.php +++ b/src/Testing/FakeFolder.php @@ -18,7 +18,7 @@ class FakeFolder implements FolderInterface */ public function __construct( protected string $path = '', - protected array $flags = [], + protected array $attributes = [], /** @var FakeMessage[] */ protected array $messages = [], protected string $delimiter = '/', @@ -44,9 +44,9 @@ public function path(): string /** * {@inheritDoc} */ - public function flags(): array + public function attributes(): array { - return $this->flags; + return $this->attributes; } /** @@ -184,11 +184,11 @@ public function setPath(string $path): FakeFolder } /** - * Set the folder's flags. + * Set the folder's attributes. */ - public function setFlags(array $flags): FakeFolder + public function setAttributes(array $attributes): FakeFolder { - $this->flags = $flags; + $this->attributes = $attributes; return $this; } diff --git a/src/Testing/FakeFolderRepository.php b/src/Testing/FakeFolderRepository.php index 9ca38f8..e673c6c 100644 --- a/src/Testing/FakeFolderRepository.php +++ b/src/Testing/FakeFolderRepository.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine\Testing; use DirectoryTree\ImapEngine\Collections\FolderCollection; +use DirectoryTree\ImapEngine\FolderDataItem; use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\FolderRepositoryInterface; use DirectoryTree\ImapEngine\MailboxInterface; @@ -11,6 +12,13 @@ class FakeFolderRepository implements FolderRepositoryInterface { + /** + * The requested folder data items. + * + * @var array + */ + protected array $dataItems = []; + /** * Constructor. */ @@ -20,6 +28,18 @@ public function __construct( protected array $folders = [] ) {} + /** + * {@inheritDoc} + */ + public function with(FolderDataItem ...$items): static + { + foreach ($items as $item) { + $this->dataItems[$item->key()] = $item; + } + + return $this; + } + /** * {@inheritDoc} */ diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index d8bf6d9..c0aa9b5 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -375,6 +375,26 @@ expect($responses->count())->toBeGreaterThan(0); }); +test('list folders with return options', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + '* LIST (\\Sent) "/" "Sent"', + 'TAG1 OK LIST completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->list('', '*', ['SPECIAL-USE']); + + $stream->assertWritten('TAG1 LIST "" "*" RETURN (SPECIAL-USE)'); + + expect($responses)->toHaveCount(1); +}); + test('append message', function () { $stream = new FakeStream; $stream->open(); diff --git a/tests/Unit/FolderRepositoryTest.php b/tests/Unit/FolderRepositoryTest.php new file mode 100644 index 0000000..d51206c --- /dev/null +++ b/tests/Unit/FolderRepositoryTest.php @@ -0,0 +1,157 @@ +feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SPECIAL-USE', + 'TAG2 OK CAPABILITY completed', + '* LIST (\\sent \\HasNoChildren) "/" "Outgoing"', + 'TAG3 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); + + $stream->assertWritten('TAG3 LIST "" "*" RETURN (SPECIAL-USE)'); +}); + +test('it does not infer special uses from folder names', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\HasNoChildren) "/" "Sent Items"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent))->toBeNull(); + + $stream->assertWritten('TAG2 LIST "" "*"'); + $stream->assertNotWritten('TAG2 LIST "" "*" RETURN (SPECIAL-USE)'); +}); + +test('it resolves special-use attributes from an ordinary list response', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\Sent \\HasNoChildren) "/" "Outgoing"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); + + $stream->assertWritten('TAG2 LIST "" "*"'); +}); + +test('it resolves special-use attributes instead of matching folder names', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1 SPECIAL-USE', + 'TAG2 OK CAPABILITY completed', + '* LIST (\\HasNoChildren) "/" "Sent"', + '* LIST (\\Sent \\HasNoChildren) "/" "Outgoing"', + 'TAG3 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent)?->path())->toBe('Outgoing'); +}); + +test('it returns null when a special-use folder cannot be resolved', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* LIST (\\HasNoChildren) "/" "INBOX"', + 'TAG2 OK LIST completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + $folders = $mailbox->folders()->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Archive))->toBeNull(); +}); + +test('it throws when requested folder data is not supported', function () { + $stream = new FakeStream; + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* CAPABILITY IMAP4rev1', + 'TAG2 OK CAPABILITY completed', + ]); + + $mailbox = Mailbox::make([ + 'host' => 'imap.example.com', + 'username' => 'foo', + 'password' => 'bar', + ]); + + $mailbox->connect(new ImapConnection($stream)); + + expect(fn () => $mailbox->folders() + ->with(FolderData::SpecialUse) + ->get() + )->toThrow(ImapCapabilityException::class); +}); diff --git a/tests/Unit/FolderTest.php b/tests/Unit/FolderTest.php index ebdf8f1..cc1e483 100644 --- a/tests/Unit/FolderTest.php +++ b/tests/Unit/FolderTest.php @@ -12,7 +12,7 @@ $folder = new Folder( mailbox: $mailbox, path: '[Gmail]/&BBoEPgRABDcEOAQ9BDA-', - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); @@ -32,7 +32,7 @@ $folder = new Folder( mailbox: $mailbox, path: '[Gmail]/'.$utf8FolderName, - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); @@ -45,7 +45,7 @@ $mixedFolder = new Folder( mailbox: $mailbox, path: '[Gmail]/'.$mixedUtf8FolderName, - flags: ['\\HasNoChildren'], + attributes: ['\\HasNoChildren'], delimiter: '/' ); diff --git a/tests/Unit/MailboxTest.php b/tests/Unit/MailboxTest.php index 09b6448..ab03302 100644 --- a/tests/Unit/MailboxTest.php +++ b/tests/Unit/MailboxTest.php @@ -113,7 +113,7 @@ expect($folders)->toHaveCount(1); expect($folders[0]->path())->toBe('INBOX'); - expect($folders[0]->flags())->toBe(['\\HasNoChildren']); + expect($folders[0]->attributes())->toBe(['\\HasNoChildren']); }); test('inbox', function () { @@ -131,7 +131,7 @@ expect($folder)->toBeInstanceOf(Folder::class); expect($folder->path())->toBe('INBOX'); - expect($folder->flags())->toBe(['\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\HasNoChildren']); }); test('capabilities', function () { diff --git a/tests/Unit/Testing/FakeFolderRepositoryTest.php b/tests/Unit/Testing/FakeFolderRepositoryTest.php index db915a1..a1a6b71 100644 --- a/tests/Unit/Testing/FakeFolderRepositoryTest.php +++ b/tests/Unit/Testing/FakeFolderRepositoryTest.php @@ -1,6 +1,8 @@ get('nonexistent*'); expect($noMatches)->toBeEmpty(); }); + +test('it resolves special-use folders from the collection', function () { + $mailbox = new FakeMailbox; + $sentByName = new FakeFolder('Sent'); + $sentByAttribute = new FakeFolder('Outgoing', [ImapSpecialUse::Sent->value]); + + $repository = new FakeFolderRepository($mailbox, [ + $sentByName, + $sentByAttribute, + new FakeFolder('Drafts', [ImapSpecialUse::Drafts->value]), + new FakeFolder('Starred', [ImapSpecialUse::Flagged->value]), + new FakeFolder('Junk Email', [ImapSpecialUse::Junk->value]), + new FakeFolder('Deleted Items', [ImapSpecialUse::Trash->value]), + new FakeFolder('Archive', [ImapSpecialUse::Archive->value]), + new FakeFolder('[Gmail]/All Mail', [ImapSpecialUse::All->value]), + ]); + + $folders = $repository + ->with(FolderData::SpecialUse) + ->get(); + + expect($folders->findBySpecialUse(ImapSpecialUse::Sent))->toBe($sentByAttribute); + expect($folders->findBySpecialUse(ImapSpecialUse::Drafts)?->name())->toBe('Drafts'); + expect($folders->findBySpecialUse(ImapSpecialUse::Flagged)?->name())->toBe('Starred'); + expect($folders->findBySpecialUse(ImapSpecialUse::Junk)?->name())->toBe('Junk Email'); + expect($folders->findBySpecialUse(ImapSpecialUse::Trash)?->name())->toBe('Deleted Items'); + expect($folders->findBySpecialUse(ImapSpecialUse::Archive)?->name())->toBe('Archive'); + expect($folders->findBySpecialUse(ImapSpecialUse::All)?->name())->toBe('All Mail'); +}); diff --git a/tests/Unit/Testing/FakeFolderTest.php b/tests/Unit/Testing/FakeFolderTest.php index 0a1d1e9..9a3a022 100644 --- a/tests/Unit/Testing/FakeFolderTest.php +++ b/tests/Unit/Testing/FakeFolderTest.php @@ -16,7 +16,7 @@ expect($folder)->toBeInstanceOf(FakeFolder::class); expect($folder->path())->toBe('INBOX'); - expect($folder->flags())->toBe(['\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\HasNoChildren']); expect($folder->delimiter())->toBe('/'); }); @@ -61,12 +61,12 @@ expect($folder->path())->toBe('Sent'); }); -test('it can set flags', function () { +test('it can set attributes', function () { $folder = new FakeFolder('INBOX'); - $folder->setFlags(['\\Seen', '\\HasNoChildren']); + $folder->setAttributes(['\\Seen', '\\HasNoChildren']); - expect($folder->flags())->toBe(['\\Seen', '\\HasNoChildren']); + expect($folder->attributes())->toBe(['\\Seen', '\\HasNoChildren']); }); test('it can set mailbox', function () {