From 1b68c995217acfc189175430e06beb85f9ea7ed8 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 25 Sep 2026 12:09:45 -0400 Subject: [PATCH] feat: Add CommandFailedException for COPY/MOVE and APPEND, exposing command name, status, server text, and parsed completion code. Signed-off-by: Sebastian Krupinski --- lib/Client/CommandFailedException.php | 48 ++++++++++ lib/Client/Protocol/Command/AppendCommand.php | 8 +- .../Command/MessageTransferCommand.php | 18 ++-- tests/php/Unit/CommandFailedExceptionTest.php | 91 +++++++++++++++++++ 4 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 lib/Client/CommandFailedException.php create mode 100644 tests/php/Unit/CommandFailedExceptionTest.php diff --git a/lib/Client/CommandFailedException.php b/lib/Client/CommandFailedException.php new file mode 100644 index 0000000..be104b4 --- /dev/null +++ b/lib/Client/CommandFailedException.php @@ -0,0 +1,48 @@ +, text:string} $responseCode + */ + public function __construct( + private readonly string $command, + private readonly string $status, + private readonly string $text, + private readonly ?array $responseCode = null, + ) { + parent::__construct($command . ' failed: ' . $text); + } + + public function command(): string + { + return $this->command; + } + + public function status(): string + { + return $this->status; + } + + public function text(): string + { + return $this->text; + } + + /** + * The code from the tagged completion, if present. + * + * @return ?array{name:string, arguments:list, text:string} + */ + public function responseCode(): ?array + { + return $this->responseCode; + } +} diff --git a/lib/Client/Protocol/Command/AppendCommand.php b/lib/Client/Protocol/Command/AppendCommand.php index 8a870ba..e0d5430 100644 --- a/lib/Client/Protocol/Command/AppendCommand.php +++ b/lib/Client/Protocol/Command/AppendCommand.php @@ -7,6 +7,7 @@ namespace KTXM\ProviderImap\Client\Protocol\Command; use KTXM\ProviderImap\Client\Protocol\Parser\ResponseCodeParser; use KTXM\ProviderImap\Client\Protocol\StringEncoder; use KTXM\ProviderImap\Client\ImapException; +use KTXM\ProviderImap\Client\CommandFailedException; use KTXM\ProviderImap\Client\Protocol\RequestFrame; use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse; use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse; @@ -81,7 +82,12 @@ final class AppendCommand implements CommandInterface if ($response instanceof TaggedResponse) { if (!$response->isOk()) { - throw new ImapException('APPEND failed: ' . $response->text()); + throw new CommandFailedException( + 'APPEND', + $response->status(), + $response->text(), + $this->responseCodeParser->parse($response->text()), + ); } return $this->parseAppendUid($response->text()); diff --git a/lib/Client/Protocol/Command/MessageTransferCommand.php b/lib/Client/Protocol/Command/MessageTransferCommand.php index 3235045..eeb6735 100644 --- a/lib/Client/Protocol/Command/MessageTransferCommand.php +++ b/lib/Client/Protocol/Command/MessageTransferCommand.php @@ -10,6 +10,7 @@ use KTXM\ProviderImap\Client\Result\MessageTransferResult; use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget; use KTXM\ProviderImap\Client\Protocol\IdentifierMode; use KTXM\ProviderImap\Client\ImapException; +use KTXM\ProviderImap\Client\CommandFailedException; use KTXM\ProviderImap\Client\Protocol\RequestFrame; use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse; use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse; @@ -113,7 +114,16 @@ final class MessageTransferCommand implements CommandInterface $highestModSeq, ); - $result = new MessageTransferResult( + if (!$response->isOk()) { + throw new CommandFailedException( + $this->operation, + $response->status(), + $response->text(), + $this->responseCodeParser->parse($response->text()), + ); + } + + return new MessageTransferResult( $response->status(), $response->text(), $responseCodes, @@ -123,12 +133,6 @@ final class MessageTransferCommand implements CommandInterface $expunged, $vanished, ); - - if (!$response->isOk()) { - throw new ImapException($this->operation . ' failed: ' . $response->text()); - } - - return $result; } } diff --git a/tests/php/Unit/CommandFailedExceptionTest.php b/tests/php/Unit/CommandFailedExceptionTest.php new file mode 100644 index 0000000..423920f --- /dev/null +++ b/tests/php/Unit/CommandFailedExceptionTest.php @@ -0,0 +1,91 @@ +createStub(ConnectionInterface::class)); + $context->setSelectedMailbox('INBOX'); + return $context; + } + + private function command(string $name): CommandInterface + { + return match ($name) { + 'COPY' => new CopyCommand(MessageTarget::uid(42), 'Archive'), + 'MOVE' => new MoveCommand(MessageTarget::uid(42), 'Archive'), + 'APPEND' => new AppendCommand('Archive', 'body'), + }; + } + + public static function failures(): iterable + { + foreach (['COPY', 'MOVE', 'APPEND'] as $command) { + foreach (['NO', 'BAD'] as $status) { + foreach ([ + ['[trycreate] Create destination first', ['name' => 'TRYCREATE', 'arguments' => [], 'text' => 'Create destination first']], + ['[LIMIT 10] Too many messages', ['name' => 'LIMIT', 'arguments' => ['10'], 'text' => 'Too many messages']], + ['Denied', null], + ['[Malformed', null], + ] as [$text, $code]) { + yield [$command, $status, $text, $code]; + } + } + } + } + + #[DataProvider('failures')] + public function testServerFailurePreservesStructuredDetails(string $command, string $status, string $text, ?array $code): void + { + $responses = new ResponseStream(static function () use ($status, $text): \Generator { + yield new TaggedResponse('A1', $status, $text, "A1 $status $text"); + }); + + try { + $this->command($command)->handle($responses, $this->context()); + $this->fail('Expected server failure'); + } catch (ImapException $exception) { + $this->assertInstanceOf(CommandFailedException::class, $exception); + $this->assertSame($command . ' failed: ' . $text, $exception->getMessage()); + $this->assertSame($command, $exception->command()); + $this->assertSame($status, $exception->status()); + $this->assertSame($text, $exception->text()); + $this->assertSame($code, $exception->responseCode()); + } + } + + public function testMissingCompletionRemainsAProtocolError(): void + { + foreach (['COPY', 'MOVE', 'APPEND'] as $command) { + $responses = new ResponseStream(static function (): \Generator { yield from []; }); + try { + $this->command($command)->handle($responses, $this->context()); + $this->fail('Expected missing completion failure'); + } catch (ImapException $exception) { + $this->assertNotInstanceOf(CommandFailedException::class, $exception); + $this->assertSame($command . ' did not receive a tagged completion response.', $exception->getMessage()); + } + } + } + + public function testSuccessfulAppendStillReturnsAssignedUid(): void + { + $responses = new ResponseStream(static function (): \Generator { + yield new TaggedResponse('A1', 'OK', '[APPENDUID 7 42] Done', 'A1 OK [APPENDUID 7 42] Done'); + }); + $this->assertSame(42, $this->command('APPEND')->handle($responses, $this->context())); + } +}