diff --git a/src/AppendResult.php b/src/AppendResult.php new file mode 100644 index 0000000..8b85b68 --- /dev/null +++ b/src/AppendResult.php @@ -0,0 +1,60 @@ +tokenAt(2); + + if (! $data instanceof ResponseCodeData) { + return new static; + } + + $code = $data->first(); + + if (! $code instanceof Token || ! $code->is('APPENDUID')) { + return new static; + } + + return new static( + uidValidity: (int) $data->tokenAt(1)->value, + uid: (int) $data->tokenAt(2)->value, + ); + } + + /** + * Get the mailbox UID validity value. + */ + public function uidValidity(): ?int + { + return $this->uidValidity; + } + + /** + * Get the appended message UID. + */ + public function uid(): ?int + { + return $this->uid; + } +} diff --git a/src/Connection/ConnectionInterface.php b/src/Connection/ConnectionInterface.php index f04ce09..2b64114 100644 --- a/src/Connection/ConnectionInterface.php +++ b/src/Connection/ConnectionInterface.php @@ -2,6 +2,8 @@ namespace DirectoryTree\ImapEngine\Connection; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; @@ -92,7 +94,7 @@ public function noop(): TaggedResponse; * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command */ - public function expunge(): ResponseCollection; + public function expunge(array|int|null $uids = null): ResponseCollection; /** * Send a "CAPABILITY" command. @@ -259,7 +261,7 @@ public function store(array|string $flags, array|int $from, ?int $to = null, ?st * * @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command */ - public function append(string $folder, string $message, ?array $flags = null): TaggedResponse; + public function append(string $folder, string $message, ?array $flags = null, ?DateTimeInterface $date = null): AppendResult; /** * Send a "UID COPY" command. diff --git a/src/Connection/ImapConnection.php b/src/Connection/ImapConnection.php index fed3fd3..381398c 100644 --- a/src/Connection/ImapConnection.php +++ b/src/Connection/ImapConnection.php @@ -2,6 +2,8 @@ namespace DirectoryTree\ImapEngine\Connection; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; @@ -363,7 +365,7 @@ public function list(string $reference = '', string $folder = '*'): ResponseColl /** * {@inheritDoc} */ - public function append(string $folder, string $message, ?array $flags = null): TaggedResponse + public function append(string $folder, string $message, ?array $flags = null, ?DateTimeInterface $date = null): AppendResult { $tokens = []; @@ -373,11 +375,17 @@ public function append(string $folder, string $message, ?array $flags = null): T $tokens[] = Str::list($flags); } + if ($date) { + $tokens[] = Str::literal($date->format('d-M-Y H:i:s O')); + } + $tokens[] = Str::literal($message); $this->send('APPEND', $tokens, tag: $tag); - return $this->assertTaggedResponse($tag); + return AppendResult::fromResponse( + $this->assertTaggedResponse($tag) + ); } /** @@ -557,9 +565,13 @@ public function id(?array $ids = null): UntaggedResponse /** * {@inheritDoc} */ - public function expunge(): ResponseCollection + public function expunge(array|int|null $uids = null): ResponseCollection { - $this->send('EXPUNGE', tag: $tag); + $this->send( + $uids === null ? 'EXPUNGE' : 'UID EXPUNGE', + $uids === null ? [] : [Str::set($uids)], + $tag, + ); $this->assertTaggedResponse($tag); diff --git a/src/Folder.php b/src/Folder.php index b167679..b73aecf 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -241,9 +241,9 @@ public function examine(): array /** * {@inheritDoc} */ - public function expunge(): array + public function expunge(array|int|null $uids = null): array { - return $this->mailbox->connection()->expunge()->map( + return $this->mailbox->connection()->expunge($uids)->map( fn (UntaggedResponse $response) => $response->tokenAt(1)->value )->all(); } diff --git a/src/FolderInterface.php b/src/FolderInterface.php index 152de63..9958dd2 100644 --- a/src/FolderInterface.php +++ b/src/FolderInterface.php @@ -79,7 +79,7 @@ public function examine(): array; /** * Expunge the mailbox and return the expunged message sequence numbers. */ - public function expunge(): array; + public function expunge(array|int|null $uids = null): array; /** * Delete the current folder. diff --git a/src/Message.php b/src/Message.php index 9938f9f..16647c4 100644 --- a/src/Message.php +++ b/src/Message.php @@ -171,7 +171,7 @@ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = ->store($flag, $this->uid, mode: $operation); if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($this->uid); } $this->flags = match ($operation) { @@ -215,10 +215,6 @@ public function move(string $folder, bool $expunge = false): ?int case in_array('MOVE', $capabilities): $response = $mailbox->connection()->move($folder, $this->uid); - if ($expunge) { - $this->folder->expunge(); - } - return MessageResponseParser::getUidFromCopy($response); case in_array('UIDPLUS', $capabilities): diff --git a/src/MessageQuery.php b/src/MessageQuery.php index 41939b8..18eed49 100644 --- a/src/MessageQuery.php +++ b/src/MessageQuery.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine; use BackedEnum; +use DateTimeInterface; use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Collections\ResponseCollection; use DirectoryTree\ImapEngine\Connection\ConnectionInterface; @@ -74,16 +75,11 @@ public function get(): MessageCollection /** * Append a new message to the folder. */ - public function append(string $message, mixed $flags = null): int + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult { - $response = $this->connection()->append( - $this->folder->path(), $message, (array) Str::enums($flags), + return $this->connection()->append( + $this->folder->path(), $message, (array) Str::enums($flags), $date, ); - - return (int) $response // TAG4 OK [APPENDUID ] APPEND completed. - ->tokenAt(2) // [APPENDUID ] - ->tokenAt(2) // - ->value; } /** @@ -209,7 +205,7 @@ public function destroy(array|int $uids, bool $expunge = false): void ->store([ImapFlag::Deleted->value], $uids, mode: '+'); if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($uids); } } @@ -231,7 +227,7 @@ public function flag(BackedEnum|string $flag, string $operation, bool $expunge = ); if ($expunge) { - $this->folder->expunge(); + $this->folder->expunge($uids); } return count($uids); @@ -290,10 +286,6 @@ public function move(string $folder, bool $expunge = false): int $this->connection()->move($folder, $uids); - if ($expunge) { - $this->folder->expunge(); - } - return count($uids); } diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 0fd04bf..63f0df9 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine; use BackedEnum; +use DateTimeInterface; use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; @@ -207,7 +208,7 @@ public function get(): MessageCollection; /** * Append a new message to the folder. */ - public function append(string $message, mixed $flags = null): int; + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult; /** * Execute a callback over each message via a chunked query. diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php index 6521d48..45d14d1 100644 --- a/src/Testing/FakeFolder.php +++ b/src/Testing/FakeFolder.php @@ -141,7 +141,7 @@ public function examine(): array /** * {@inheritDoc} */ - public function expunge(): array + public function expunge(array|int|null $uids = null): array { return []; } diff --git a/src/Testing/FakeMessageQuery.php b/src/Testing/FakeMessageQuery.php index bcabbee..34e8cc4 100644 --- a/src/Testing/FakeMessageQuery.php +++ b/src/Testing/FakeMessageQuery.php @@ -3,6 +3,8 @@ namespace DirectoryTree\ImapEngine\Testing; use BackedEnum; +use DateTimeInterface; +use DirectoryTree\ImapEngine\AppendResult; use DirectoryTree\ImapEngine\Collections\MessageCollection; use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; @@ -62,7 +64,7 @@ public function firstOrFail(): MessageInterface /** * {@inheritDoc} */ - public function append(string $message, mixed $flags = null): int + public function append(string $message, mixed $flags = null, ?DateTimeInterface $date = null): AppendResult { $uid = 1; @@ -74,7 +76,7 @@ public function append(string $message, mixed $flags = null): int new FakeMessage($uid, $flags === null ? [] : $flags, $message) ); - return $uid; + return new AppendResult(uid: $uid); } /** diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 54f251c..15fe82e 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -51,7 +51,7 @@ function folder(): Folder $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), - ); + )->uid(); expect($folder->messages()->first()->uid())->toBe($uid); }); @@ -61,7 +61,7 @@ function folder(): Folder $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), - ); + )->uid(); $message = $folder->messages()->firstOrFail(); @@ -82,7 +82,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $message = $folder->messages()->find($uid); @@ -97,7 +97,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $message = $folder->messages()->findOrFail($uid); @@ -118,7 +118,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $messages = $folder->messages()->get(); @@ -134,7 +134,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $messages = $callback($folder->messages())->get(); @@ -157,7 +157,7 @@ function folder(): Folder subject: 'Test Subject', text: 'hello world', ), - ); + )->uid(); // Fetch without size - should be null $messagesWithoutSize = $folder->messages()->get(); @@ -185,8 +185,8 @@ function folder(): Folder text: str_repeat('This is a longer message with more content. ', 100), ); - $uid1 = $folder->messages()->append($shortMessage); - $uid2 = $folder->messages()->append($longMessage); + $uid1 = $folder->messages()->append($shortMessage)->uid(); + $uid2 = $folder->messages()->append($longMessage)->uid(); $messages = $folder->messages()->withSize()->get(); @@ -216,7 +216,7 @@ function folder(): Folder date: $datetime = Carbon::now()->subYear(), ), ['\\Seen'], - ); + )->uid(); $message = $messages ->withHeaders() @@ -246,7 +246,7 @@ function folder(): Folder from: 'foo@email.com', text: 'flag test' ) - ); + )->uid(); // Initially, message should not be marked as seen. $message = $messages->withFlags()->find($uid); @@ -273,7 +273,7 @@ function folder(): Folder from: 'foo@email.com', text: 'copy test' ) - ); + )->uid(); $message = $messages->withHeaders()->withBody()->find($uid); @@ -305,7 +305,7 @@ function folder(): Folder from: 'foo@email.com', text: 'move test' ) - ); + )->uid(); $message = $messages->withHeaders()->withBody()->find($uid); @@ -338,7 +338,7 @@ function folder(): Folder from: 'foo@email.com', text: 'delete test' ) - ); + )->uid(); $message = $messages->find($uid); @@ -355,14 +355,14 @@ function folder(): Folder from: 'foo@email.com', text: $firstUuid = uniqid(), ), - ); + )->uid(); $secondUid = $folder->messages()->append( new DraftMessage( from: 'foo@email.com', text: $secondUuid = uniqid(), ), - ); + )->uid(); $results = $folder->messages() ->where(fn (ImapQueryBuilder $q) => $q->body($firstUuid)) @@ -383,7 +383,7 @@ function folder(): Folder text: 'hello world', ), [$flag], - ); + )->uid(); expect( $folder->messages() @@ -415,7 +415,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $folder->messages() ->markAsRead() @@ -435,7 +435,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); $folder->messages() ->leaveUnread() @@ -455,7 +455,7 @@ function folder(): Folder from: 'foo@email.com', text: 'hello world', ), - ); + )->uid(); expect($folder->messages()->unseen()->count())->toBe(1); @@ -473,7 +473,7 @@ function folder(): Folder subject: 'AAA First alphabetically', text: 'hello world', ), - ); + )->uid(); $uid2 = $folder->messages()->append( new DraftMessage( @@ -481,7 +481,7 @@ function folder(): Folder subject: 'ZZZ Last alphabetically', text: 'hello world', ), - ); + )->uid(); // Ascending order: AAA should come before ZZZ $messagesAsc = $folder->messages()->sortBy('subject', 'asc')->get(); diff --git a/tests/Unit/AppendResultTest.php b/tests/Unit/AppendResultTest.php new file mode 100644 index 0000000..0c16511 --- /dev/null +++ b/tests/Unit/AppendResultTest.php @@ -0,0 +1,37 @@ +uidValidity())->toBe(1234567890) + ->and($result->uid())->toBe(42); +}); + +test('it creates a result without identifiers when APPENDUID is unavailable', function () { + $response = new TaggedResponse([ + new Atom('TAG1'), + new Atom('OK'), + new Atom('APPEND completed'), + ]); + + $result = AppendResult::fromResponse($response); + + expect($result->uidValidity())->toBeNull() + ->and($result->uid())->toBeNull(); +}); diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index d99f718..d8bf6d9 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -1,5 +1,6 @@ feed([ '* OK Welcome to IMAP', - 'TAG1 OK APPEND completed', + 'TAG1 OK [APPENDUID 1234567890 42] APPEND completed', ]); $connection = new ImapConnection($stream); $connection->connect('imap.example.com'); - $connection->append('INBOX', 'Test message', ['\\Seen']); + $result = $connection->append('INBOX', 'Test message', ['\\Seen']); $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "Test message"'); + + expect($result)->toBeInstanceOf(AppendResult::class) + ->and($result->uidValidity())->toBe(1234567890) + ->and($result->uid())->toBe(42); +}); + +test('append message with internal date', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK APPEND completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $result = $connection->append( + 'INBOX', + 'Test message', + ['\\Seen'], + new DateTimeImmutable('2026-09-01 12:34:56 -04:00'), + ); + + $stream->assertWritten('TAG1 APPEND "INBOX" (\Seen) "01-Sep-2026 12:34:56 -0400" "Test message"'); + + expect($result->uidValidity())->toBeNull() + ->and($result->uid())->toBeNull(); }); test('append sends literal data after receiving a continuation response', function () { @@ -751,6 +781,26 @@ expect($responses->count())->toBeGreaterThan(0); }); +test('expunge messages by uid', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + '* 1 EXPUNGE', + 'TAG1 OK UID EXPUNGE completed', + ]); + + $connection = new ImapConnection($stream); + $connection->connect('imap.example.com'); + + $responses = $connection->expunge([1, 2, 3]); + + $stream->assertWritten('TAG1 UID EXPUNGE 1:3'); + + expect($responses->count())->toBeGreaterThan(0); +}); + test('noop', function () { $stream = new FakeStream; $stream->open(); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index d9876b3..eb048c7 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -78,6 +78,27 @@ function query(?Mailbox $mailbox = null): MessageQuery $stream->assertWritten('TAG2 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); }); +test('destroy with expunge only expunges the given messages', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK UID STORE completed', + 'TAG3 OK UID EXPUNGE completed', + ]); + + $mailbox = Mailbox::make(); + + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox)->destroy([1, 2, 3], expunge: true); + + $stream->assertWritten('TAG2 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG3 UID EXPUNGE 1:3'); +}); + test('oldest sets fetch order to asc', function () { $query = query(); @@ -208,9 +229,10 @@ function query(?Mailbox $mailbox = null): MessageQuery $folder = new Folder($mailbox, 'INBOX'); $query = new MessageQuery($folder, new ImapQueryBuilder); - $uid = $query->append('Hello world', $flag); + $result = $query->append('Hello world', $flag); - expect($uid)->toBe(1); + expect($result->uidValidity())->toBe(1234567890) + ->and($result->uid())->toBe(1); $stream->assertWritten('TAG2 APPEND "INBOX" (\\Seen) "Hello world"'); })->with([ImapFlag::Seen, '\\Seen']); @@ -410,7 +432,7 @@ function query(?Mailbox $mailbox = null): MessageQuery '* SEARCH 1 2', 'TAG2 OK SEARCH completed', 'TAG3 OK UID STORE completed', - 'TAG4 OK EXPUNGE completed', + 'TAG4 OK UID EXPUNGE completed', ]); $mailbox = Mailbox::make(); @@ -423,7 +445,7 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($count)->toBe(2); $stream->assertWritten('TAG3 UID STORE 1:2 +FLAGS.SILENT (\Deleted)'); - $stream->assertWritten('TAG4 EXPUNGE'); + $stream->assertWritten('TAG4 UID EXPUNGE 1:2'); }); test('move moves all matching messages to folder', function () { diff --git a/tests/Unit/MessageTest.php b/tests/Unit/MessageTest.php index 2850b14..0491cb0 100644 --- a/tests/Unit/MessageTest.php +++ b/tests/Unit/MessageTest.php @@ -1,6 +1,7 @@ toBe(123); }); +test('it only expunges the deleted message', function () { + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'bar', + ]); + + $stream = new FakeStream; + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + 'TAG2 OK STORE completed', + 'TAG3 OK UID EXPUNGE completed', + ]); + + $connection = new ImapConnection($stream); + + $mailbox->connect($connection); + + $folder = new Folder($mailbox, 'INBOX', [], '/'); + + $message = new Message($folder, 42, [], 'header', 'body'); + + $message->delete(expunge: true); + + $stream->assertWritten('TAG2 UID STORE 42 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG3 UID EXPUNGE 42'); +}); + test('it throws exception when server does not support MOVE or UIDPLUS capabilities', function () { $mailbox = Mailbox::make([ 'username' => 'foo', diff --git a/tests/Unit/Testing/FakeMessageQueryTest.php b/tests/Unit/Testing/FakeMessageQueryTest.php index b0e4009..697173d 100644 --- a/tests/Unit/Testing/FakeMessageQueryTest.php +++ b/tests/Unit/Testing/FakeMessageQueryTest.php @@ -74,14 +74,14 @@ $folder = new FakeFolder('INBOX'); $query = new FakeMessageQuery($folder); - $uid1 = $query->append('First message'); - expect($uid1)->toBe(1); + $result1 = $query->append('First message'); + expect($result1->uid())->toBe(1); - $uid2 = $query->append('Second message'); - expect($uid2)->toBe(2); + $result2 = $query->append('Second message'); + expect($result2->uid())->toBe(2); - $uid3 = $query->append('Third message'); - expect($uid3)->toBe(3); + $result3 = $query->append('Third message'); + expect($result3->uid())->toBe(3); expect($query->count())->toBe(3); }); @@ -93,8 +93,8 @@ $query = new FakeMessageQuery($folder); - $uid = $query->append('New message'); - expect($uid)->toBe(6); + $result = $query->append('New message'); + expect($result->uid())->toBe(6); }); test('it can find message by uid', function () {