From d9c8d02ef78b3e44bb217e5c2b256d43e51cc6f3 Mon Sep 17 00:00:00 2001 From: Pieter Hordijk Date: Mon, 14 Jan 2019 20:54:18 +0300 Subject: [PATCH] Refactored entire SMTP session and transaction flow We now have more sanity, maintainability and testability with 100% less smell --- .../Processor/ExtensionNegotiation.php | 167 +----- .../ExtensionNegotiation/ProcessEhlo.php | 118 ++++ .../ProcessExtensions.php | 94 ++++ .../ExtensionNegotiation/ProcessHelo.php | 103 ++++ src/Transaction/Processor/Handshake.php | 109 +--- .../Processor/Handshake/ProcessBanner.php | 89 +++ .../Processor/Handshake/ProcessGreeting.php | 91 ++++ src/Transaction/Processor/LogIn.php | 248 +-------- .../Processor/LogIn/ProcessCramMd5.php | 149 ++++++ .../Processor/LogIn/ProcessLogin.php | 143 +++++ .../Processor/LogIn/ProcessPlain.php | 111 ++++ src/Transaction/Processor/Mail.php | 242 +-------- .../Processor/Mail/ProcessContent.php | 105 ++++ .../Processor/Mail/ProcessData.php | 99 ++++ .../Processor/Mail/ProcessHeaders.php | 47 ++ .../Processor/Mail/ProcessMailFrom.php | 104 ++++ .../Processor/Mail/ProcessRecipients.php | 120 +++++ .../Status/ExtensionNegotiation.php | 10 +- src/Transaction/Status/Handshake.php | 6 +- src/Transaction/Status/Mail.php | 9 +- .../ExtensionNegotiation/ProcessEhloTest.php | 143 +++++ .../ProcessExtensionsTest.php | 131 +++++ .../ExtensionNegotiation/ProcessHeloTest.php | 118 ++++ .../Processor/ExtensionNegotiationTest.php | 298 +---------- .../Processor/Handshake/ProcessBannerTest.php | 96 ++++ .../Handshake/ProcessGreetingTest.php | 77 +++ .../Transaction/Processor/HandshakeTest.php | 74 +-- .../Processor/LogIn/ProcessCramMd5Test.php | 201 +++++++ .../Processor/LogIn/ProcessLoginTest.php | 166 ++++++ .../Processor/LogIn/ProcessPlainTest.php | 119 +++++ .../Unit/Transaction/Processor/LogInTest.php | 505 +----------------- .../Processor/Mail/ProcessContentTest.php | 177 ++++++ .../Processor/Mail/ProcessDataTest.php | 136 +++++ .../Processor/Mail/ProcessHeadersTest.php | 103 ++++ .../Processor/Mail/ProcessMailFromTest.php | 138 +++++ .../Processor/Mail/ProcessRecipientsTest.php | 130 +++++ tests/Unit/Transaction/Processor/MailTest.php | 130 ++++- 37 files changed, 3359 insertions(+), 1547 deletions(-) create mode 100644 src/Transaction/Processor/ExtensionNegotiation/ProcessEhlo.php create mode 100644 src/Transaction/Processor/ExtensionNegotiation/ProcessExtensions.php create mode 100644 src/Transaction/Processor/ExtensionNegotiation/ProcessHelo.php create mode 100644 src/Transaction/Processor/Handshake/ProcessBanner.php create mode 100644 src/Transaction/Processor/Handshake/ProcessGreeting.php create mode 100644 src/Transaction/Processor/LogIn/ProcessCramMd5.php create mode 100644 src/Transaction/Processor/LogIn/ProcessLogin.php create mode 100644 src/Transaction/Processor/LogIn/ProcessPlain.php create mode 100644 src/Transaction/Processor/Mail/ProcessContent.php create mode 100644 src/Transaction/Processor/Mail/ProcessData.php create mode 100644 src/Transaction/Processor/Mail/ProcessHeaders.php create mode 100644 src/Transaction/Processor/Mail/ProcessMailFrom.php create mode 100644 src/Transaction/Processor/Mail/ProcessRecipients.php create mode 100644 tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessEhloTest.php create mode 100644 tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessExtensionsTest.php create mode 100644 tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessHeloTest.php create mode 100644 tests/Unit/Transaction/Processor/Handshake/ProcessBannerTest.php create mode 100644 tests/Unit/Transaction/Processor/Handshake/ProcessGreetingTest.php create mode 100644 tests/Unit/Transaction/Processor/LogIn/ProcessCramMd5Test.php create mode 100644 tests/Unit/Transaction/Processor/LogIn/ProcessLoginTest.php create mode 100644 tests/Unit/Transaction/Processor/LogIn/ProcessPlainTest.php create mode 100644 tests/Unit/Transaction/Processor/Mail/ProcessContentTest.php create mode 100644 tests/Unit/Transaction/Processor/Mail/ProcessDataTest.php create mode 100644 tests/Unit/Transaction/Processor/Mail/ProcessHeadersTest.php create mode 100644 tests/Unit/Transaction/Processor/Mail/ProcessMailFromTest.php create mode 100644 tests/Unit/Transaction/Processor/Mail/ProcessRecipientsTest.php diff --git a/src/Transaction/Processor/ExtensionNegotiation.php b/src/Transaction/Processor/ExtensionNegotiation.php index 4c82199..c19c360 100644 --- a/src/Transaction/Processor/ExtensionNegotiation.php +++ b/src/Transaction/Processor/ExtensionNegotiation.php @@ -5,43 +5,18 @@ use Amp\Promise; use HarmonyIO\SmtpClient\Buffer; use HarmonyIO\SmtpClient\ClientAddress\Address; -use HarmonyIO\SmtpClient\Exception\Smtp\TransmissionChannelClosed; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\Socket; -use HarmonyIO\SmtpClient\Transaction\Command\Ehlo; -use HarmonyIO\SmtpClient\Transaction\Command\Helo; use HarmonyIO\SmtpClient\Transaction\Extension\Collection; +use HarmonyIO\SmtpClient\Transaction\Processor\ExtensionNegotiation\ProcessEhlo; +use HarmonyIO\SmtpClient\Transaction\Processor\ExtensionNegotiation\ProcessExtensions; +use HarmonyIO\SmtpClient\Transaction\Processor\ExtensionNegotiation\ProcessHelo; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; -use HarmonyIO\SmtpClient\Transaction\Reply\PermanentNegativeCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\Reply; -use HarmonyIO\SmtpClient\Transaction\Reply\TransientNegativeCompletion; use HarmonyIO\SmtpClient\Transaction\Status\ExtensionNegotiation as Status; use function Amp\call; final class ExtensionNegotiation implements Processor { - private const AVAILABLE_COMMANDS = [ - Status::SENT_EHLO => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::PROCESSING_EXTENSION_LIST => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENT_HELO => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - ]; - - /** @var Status */ - private $currentStatus; - /** @var Factory */ private $replyFactory; @@ -74,123 +49,27 @@ public function __construct( public function process(Buffer $buffer): Promise { return call(function () use ($buffer) { - $this->sendEhlo(); - - while ($this->currentStatus->getValue() !== Status::COMPLETED) { - $this->processReply(yield $buffer->readLine()); + $status = new Status(Status::START_PROCESS); + + $processors = [ + new ProcessEhlo($this->replyFactory, $this->logger, $this->connection, $this->clientAddress), + new ProcessHelo($this->replyFactory, $this->logger, $this->connection, $this->clientAddress), + new ProcessExtensions($this->replyFactory, $this->logger, $this->extensionCollection), + ]; + + /** @var Processor $processor */ + foreach ($processors as $processor) { + if (get_class($processor) === ProcessHelo::class && $status->getValue() !== Status::SEND_HELO) { + continue; + } + + /** @var Status $status */ + $status = yield $processor->process($buffer); + + if ($status->getValue() === Status::COMPLETED) { + return; + } } }); } - - private function processReply(string $line): void - { - $reply = $this->replyFactory->build($line, self::AVAILABLE_COMMANDS[$this->currentStatus->getValue()]); - - $this->logger->debug('Server reply object: ' . get_class($reply)); - - switch ($this->currentStatus->getValue()) { - case Status::SENT_EHLO: - $this->processSentEhloReply($reply); - return; - - case Status::PROCESSING_EXTENSION_LIST: - $this->processExtensionListReply($reply); - return; - - case Status::SENT_HELO: - $this->processSentHeloReply($reply); - return; - } - } - - private function processSentEhloReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processEhloSupported($reply); - return; - - case TransientNegativeCompletion::class: - $this->processClosingConnection(); - return; - - case PermanentNegativeCompletion::class: - $this->fallbackToHelo(); - return; - } - } - - private function processExtensionListReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->addExtensionIfSupported($reply); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processClosingConnection(); - return; - } - } - - private function processSentHeloReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processHeloSupported(); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processClosingConnection(); - return; - } - } - - private function sendEhlo(): void - { - $this->currentStatus = new Status(Status::SENT_EHLO); - - $this->connection->write((string) new Ehlo($this->clientAddress)); - } - - private function processEhloSupported(Reply $reply): void - { - if ($reply->isLastLine()) { - $this->currentStatus = new Status(Status::COMPLETED); - - return; - } - - $this->currentStatus = new Status(Status::PROCESSING_EXTENSION_LIST); - } - - private function processClosingConnection(): void - { - throw new TransmissionChannelClosed(); - } - - private function fallbackToHelo(): void - { - $this->currentStatus = new Status(Status::SENT_HELO); - - $this->connection->write((string) new Helo($this->clientAddress)); - } - - private function processHeloSupported(): void - { - $this->currentStatus = new Status(Status::COMPLETED); - } - - private function addExtensionIfSupported(Reply $reply): void - { - if ($reply->isLastLine()) { - $this->currentStatus = new Status(Status::COMPLETED); - - return; - } - - $this->extensionCollection->enable($reply); - } } diff --git a/src/Transaction/Processor/ExtensionNegotiation/ProcessEhlo.php b/src/Transaction/Processor/ExtensionNegotiation/ProcessEhlo.php new file mode 100644 index 0000000..de3437f --- /dev/null +++ b/src/Transaction/Processor/ExtensionNegotiation/ProcessEhlo.php @@ -0,0 +1,118 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->clientAddress = $clientAddress; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendEhlo(); + + while ($this->currentStatus->getValue() === Status::SENT_EHLO) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendEhlo(): Promise + { + $this->currentStatus = new Status(Status::SENT_EHLO); + + $this->connection->write((string) new Ehlo($this->clientAddress)); + + return new Success(); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processEhloSupported($reply); + + case TransientNegativeCompletion::class: + return $this->processClosingConnection(); + + case PermanentNegativeCompletion::class: + return $this->fallbackToHelo(); + } + } + + private function processEhloSupported(Reply $reply): Promise + { + if ($reply->isLastLine()) { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + $this->currentStatus = new Status(Status::PROCESSING_EXTENSION_LIST); + + return new Success(); + } + + private function processClosingConnection(): Promise + { + throw new TransmissionChannelClosed(); + } + + private function fallbackToHelo(): Promise + { + $this->currentStatus = new Status(Status::SEND_HELO); + + return new Success(); + } +} diff --git a/src/Transaction/Processor/ExtensionNegotiation/ProcessExtensions.php b/src/Transaction/Processor/ExtensionNegotiation/ProcessExtensions.php new file mode 100644 index 0000000..21b9277 --- /dev/null +++ b/src/Transaction/Processor/ExtensionNegotiation/ProcessExtensions.php @@ -0,0 +1,94 @@ +currentStatus = new Status(Status::PROCESSING_EXTENSION_LIST); + + $this->replyFactory = $replyFactory; + $this->logger = $logger; + $this->extensions = $extensions; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->addExtensionIfSupported($reply); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processClosingConnection(); + } + } + + private function addExtensionIfSupported(Reply $reply): Promise + { + if ($reply->isLastLine()) { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + $this->extensions->enable($reply); + + return new Success(); + } + + private function processClosingConnection(): Promise + { + throw new TransmissionChannelClosed(); + } +} diff --git a/src/Transaction/Processor/ExtensionNegotiation/ProcessHelo.php b/src/Transaction/Processor/ExtensionNegotiation/ProcessHelo.php new file mode 100644 index 0000000..76e1fe1 --- /dev/null +++ b/src/Transaction/Processor/ExtensionNegotiation/ProcessHelo.php @@ -0,0 +1,103 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->clientAddress = $clientAddress; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendHelo(); + + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendHelo(): Promise + { + $this->currentStatus = new Status(Status::SEND_HELO); + + $this->connection->write((string) new Helo($this->clientAddress)); + + return new Success(); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processHeloSupported(); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processClosingConnection(); + } + } + + private function processHeloSupported(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + private function processClosingConnection(): Promise + { + throw new TransmissionChannelClosed(); + } +} diff --git a/src/Transaction/Processor/Handshake.php b/src/Transaction/Processor/Handshake.php index aa3b753..db71cf2 100644 --- a/src/Transaction/Processor/Handshake.php +++ b/src/Transaction/Processor/Handshake.php @@ -4,31 +4,15 @@ use Amp\Promise; use HarmonyIO\SmtpClient\Buffer; -use HarmonyIO\SmtpClient\Exception\TransactionFailed as TransactionFailedException; use HarmonyIO\SmtpClient\Log\Output; +use HarmonyIO\SmtpClient\Transaction\Processor\Handshake\ProcessBanner; +use HarmonyIO\SmtpClient\Transaction\Processor\Handshake\ProcessGreeting; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\Reply; -use HarmonyIO\SmtpClient\Transaction\Reply\TransientNegativeCompletion; use HarmonyIO\SmtpClient\Transaction\Status\Handshake as Status; use function Amp\call; final class Handshake implements Processor { - private const AVAILABLE_COMMANDS = [ - Status::AWAITING_GREETING => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - ], - Status::AWAITING_BANNER => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - ], - ]; - - /** @var Status */ - private $currentStatus; - /** @var Factory */ private $replyFactory; @@ -37,86 +21,27 @@ final class Handshake implements Processor public function __construct(Factory $replyFactory, Output $logger) { - $this->currentStatus = new Status(Status::AWAITING_GREETING); - - $this->replyFactory = $replyFactory; - $this->logger = $logger; + $this->replyFactory = $replyFactory; + $this->logger = $logger; } public function process(Buffer $buffer): Promise { return call(function () use ($buffer) { - while ($this->currentStatus->getValue() !== Status::COMPLETED) { - $this->processReply(yield $buffer->readLine()); + $processors = [ + new ProcessGreeting($this->replyFactory, $this->logger), + new ProcessBanner($this->replyFactory, $this->logger), + ]; + + /** @var Processor $processor */ + foreach ($processors as $processor) { + /** @var Status $status */ + $status = yield $processor->process($buffer); + + if ($status->getValue() === Status::COMPLETED) { + return; + } } }); } - - private function processReply(string $line): void - { - $reply = $this->replyFactory->build($line, self::AVAILABLE_COMMANDS[$this->currentStatus->getValue()]); - - $this->logger->debug('Server reply object: ' . get_class($reply)); - - switch ($this->currentStatus->getValue()) { - case Status::AWAITING_GREETING: - $this->processAwaitingGreetingReply($reply); - return; - - case Status::AWAITING_BANNER: - $this->processAwaitingBannerReply($reply); - return; - } - } - - private function processAwaitingGreetingReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processGreeting($reply); - return; - - case TransientNegativeCompletion::class: - $this->processFailedTransaction($reply); - return; - } - } - - private function processAwaitingBannerReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processBanner($reply); - return; - - case TransientNegativeCompletion::class: - $this->processFailedTransaction($reply); - return; - } - } - - private function processGreeting(Reply $reply): void - { - if ($reply->isLastLine()) { - $this->currentStatus = new Status(Status::COMPLETED); - - return; - } - - $this->currentStatus = new Status(Status::AWAITING_BANNER); - } - - private function processFailedTransaction(Reply $reply): void - { - throw new TransactionFailedException((string) $reply->getText()); - } - - private function processBanner(Reply $reply): void - { - if (!$reply->isLastLine()) { - return; - } - - $this->currentStatus = new Status(Status::COMPLETED); - } } diff --git a/src/Transaction/Processor/Handshake/ProcessBanner.php b/src/Transaction/Processor/Handshake/ProcessBanner.php new file mode 100644 index 0000000..fe1c901 --- /dev/null +++ b/src/Transaction/Processor/Handshake/ProcessBanner.php @@ -0,0 +1,89 @@ +currentStatus = new Status(Status::PROCESS_BANNER); + + $this->replyFactory = $replyFactory; + $this->logger = $logger; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processBanner($reply); + + case TransientNegativeCompletion::class: + return $this->failTransaction($reply); + } + } + + private function processBanner(Reply $reply): Promise + { + if (!$reply->isLastLine()) { + return new Success(); + } + + return $this->completeProcess(); + } + + private function failTransaction(Reply $reply): Promise + { + throw new TransactionFailed((string) $reply->getText()); + } + + private function completeProcess(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } +} diff --git a/src/Transaction/Processor/Handshake/ProcessGreeting.php b/src/Transaction/Processor/Handshake/ProcessGreeting.php new file mode 100644 index 0000000..0e394ff --- /dev/null +++ b/src/Transaction/Processor/Handshake/ProcessGreeting.php @@ -0,0 +1,91 @@ +currentStatus = new Status(Status::PROCESS_GREETING); + + $this->replyFactory = $replyFactory; + $this->logger = $logger; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + while ($this->currentStatus->getValue() === Status::PROCESS_GREETING) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processGreeting($reply); + + case TransientNegativeCompletion::class: + return $this->failTransaction($reply); + } + } + + private function processGreeting(Reply $reply): Promise + { + if ($reply->isLastLine()) { + return $this->completeProcess(); + } + + $this->currentStatus = new Status(Status::PROCESS_BANNER); + + return new Success(); + } + + private function failTransaction(Reply $reply): Promise + { + throw new TransactionFailed((string) $reply->getText()); + } + + private function completeProcess(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } +} diff --git a/src/Transaction/Processor/LogIn.php b/src/Transaction/Processor/LogIn.php index fa672fc..eeb841d 100644 --- a/src/Transaction/Processor/LogIn.php +++ b/src/Transaction/Processor/LogIn.php @@ -5,57 +5,18 @@ use Amp\Promise; use HarmonyIO\SmtpClient\Authentication; use HarmonyIO\SmtpClient\Buffer; -use HarmonyIO\SmtpClient\Exception\Smtp\InvalidCredentials; -use HarmonyIO\SmtpClient\Exception\Smtp\TransmissionChannelClosed; -use HarmonyIO\SmtpClient\Exception\Smtp\UnexpectedReply; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\Socket; -use HarmonyIO\SmtpClient\Transaction\Command\AuthCramMd5Response; -use HarmonyIO\SmtpClient\Transaction\Command\AuthCramMd5Start; -use HarmonyIO\SmtpClient\Transaction\Command\AuthLoginPassword; -use HarmonyIO\SmtpClient\Transaction\Command\AuthLoginStart; -use HarmonyIO\SmtpClient\Transaction\Command\AuthLoginUsername; -use HarmonyIO\SmtpClient\Transaction\Command\AuthPlain; use HarmonyIO\SmtpClient\Transaction\Extension\Auth; use HarmonyIO\SmtpClient\Transaction\Extension\Collection; +use HarmonyIO\SmtpClient\Transaction\Processor\LogIn\ProcessCramMd5; +use HarmonyIO\SmtpClient\Transaction\Processor\LogIn\ProcessLogin; +use HarmonyIO\SmtpClient\Transaction\Processor\LogIn\ProcessPlain; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; -use HarmonyIO\SmtpClient\Transaction\Reply\PermanentNegativeCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveIntermediate; -use HarmonyIO\SmtpClient\Transaction\Reply\Reply; -use HarmonyIO\SmtpClient\Transaction\Reply\TransientNegativeCompletion; -use HarmonyIO\SmtpClient\Transaction\Status\LogIn as Status; use function Amp\call; final class LogIn implements Processor { - private const AVAILABLE_COMMANDS = [ - Status::SENT_PLAIN => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENT_LOGIN => [ - PositiveCompletion::class, - PositiveIntermediate::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::AWAITING_CRAM_MD5_CHALLENGE => [ - PositiveIntermediate::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENT_CRAM_MD5_RESPONSE => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - ]; - - /** @var Status */ - private $currentStatus; - /** @var Factory */ private $replyFactory; @@ -66,7 +27,7 @@ final class LogIn implements Processor private $connection; /** @var Collection */ - private $extensionCollection; + private $extensions; /** @var Authentication|null */ private $authentication; @@ -75,14 +36,14 @@ public function __construct( Factory $replyFactory, Output $logger, Socket $connection, - Collection $extensionCollection, + Collection $extensions, ?Authentication $authentication = null ) { - $this->replyFactory = $replyFactory; - $this->logger = $logger; - $this->connection = $connection; - $this->extensionCollection = $extensionCollection; - $this->authentication = $authentication; + $this->replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->extensions = $extensions; + $this->authentication = $authentication; } public function process(Buffer $buffer): Promise @@ -92,201 +53,28 @@ public function process(Buffer $buffer): Promise return; } - if (!$this->extensionCollection->isExtensionEnabled(Auth::class)) { + if (!$this->extensions->isExtensionEnabled(Auth::class)) { return; } - $this->requestLogIn(); + /** @var Auth $authExtension */ + $authExtension = $this->extensions->getExtension(Auth::class); - while ($this->currentStatus->getValue() !== Status::COMPLETED) { - $this->processReply(yield $buffer->readLine()); - } + yield $this->getAuthenticationProcessor($authExtension)->process($buffer); }); } - private function requestLogIn(): void + private function getAuthenticationProcessor(Auth $authExtension): Processor { - /** @var Auth $authExtension */ - $authExtension = $this->extensionCollection->getExtension(Auth::class); - switch ($authExtension->getPreferredAuthenticationMethod()) { case 'PLAIN': - $this->startPlain(); - return; + return new ProcessPlain($this->replyFactory, $this->logger, $this->connection, $this->authentication); case 'LOGIN': - $this->startLogin(); - return; + return new ProcessLogin($this->replyFactory, $this->logger, $this->connection, $this->authentication); case 'CRAM-MD5': - $this->startCramMd5(); - return; - } - } - - private function processReply(string $line): void - { - $reply = $this->replyFactory->build($line, self::AVAILABLE_COMMANDS[$this->currentStatus->getValue()]); - - $this->logger->debug('Server reply object: ' . get_class($reply)); - - switch ($this->currentStatus->getValue()) { - case Status::SENT_PLAIN: - $this->processSentPlainReply($reply); - return; - - case Status::SENT_LOGIN: - $this->processSentLoginReply($reply); - return; - - case Status::AWAITING_CRAM_MD5_CHALLENGE: - $this->processAwaitingCramMd5Reply($reply); - return; - - case Status::SENT_CRAM_MD5_RESPONSE: - $this->processSentCramMd5ResponseReply($reply); - return; - } - } - - private function processSentPlainReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processCredentialsAccepted(); - return; - - case TransientNegativeCompletion::class: - $this->processUnknownError(); - return; - - case PermanentNegativeCompletion::class: - $this->processInvalidCredentials(); - return; - } - } - - private function processSentLoginReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processCredentialsAccepted(); - return; - - case PositiveIntermediate::class: - $this->processActiveLoginProcess($reply); - return; - - case TransientNegativeCompletion::class: - $this->processUnknownError(); - return; - - case PermanentNegativeCompletion::class: - $this->processInvalidCredentials(); - return; - } - } - - private function processAwaitingCramMd5Reply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveIntermediate::class: - $this->processCramMd5Challenge($reply); - return; - - case TransientNegativeCompletion::class: - $this->processUnknownError(); - return; - - case PermanentNegativeCompletion::class: - $this->processInvalidCredentials(); - return; + return new ProcessCramMd5($this->replyFactory, $this->logger, $this->connection, $this->authentication); } } - - private function processSentCramMd5ResponseReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processCredentialsAccepted(); - return; - - case TransientNegativeCompletion::class: - $this->processUnknownError(); - return; - - case PermanentNegativeCompletion::class: - $this->processInvalidCredentials(); - return; - } - } - - private function startPlain(): void - { - $this->currentStatus = new Status(Status::SENT_PLAIN); - - $this->connection->write((string) new AuthPlain($this->authentication)); - } - - private function startLogin(): void - { - $this->currentStatus = new Status(Status::SENT_LOGIN); - - $this->connection->write((string) new AuthLoginStart()); - } - - private function startCramMd5(): void - { - $this->currentStatus = new Status(Status::AWAITING_CRAM_MD5_CHALLENGE); - - $this->connection->write((string) new AuthCramMd5Start()); - } - - private function processActiveLoginProcess(Reply $reply): void - { - switch (substr(strtolower(base64_decode($reply->getText())), 0, 8)) { - case 'username': - $this->processActiveLoginSendUsername(); - return; - - case 'password': - $this->processActiveLoginSendPassword(); - return; - - default: - throw new UnexpectedReply((string) $reply); - } - } - - private function processActiveLoginSendUsername(): void - { - $this->connection->write((string) new AuthLoginUsername($this->authentication)); - } - - private function processActiveLoginSendPassword(): void - { - $this->connection->write((string) new AuthLoginPassword($this->authentication)); - } - - private function processCramMd5Challenge(Reply $reply): void - { - $this->currentStatus = new Status(Status::SENT_CRAM_MD5_RESPONSE); - - $this->connection->write((string) new AuthCramMd5Response($this->authentication, $reply->getText())); - } - - private function processCredentialsAccepted(): void - { - $this->currentStatus = new Status(Status::COMPLETED); - } - - public function processUnknownError(): void - { - throw new TransmissionChannelClosed(); - } - - private function processInvalidCredentials(): void - { - throw new InvalidCredentials(); - } } diff --git a/src/Transaction/Processor/LogIn/ProcessCramMd5.php b/src/Transaction/Processor/LogIn/ProcessCramMd5.php new file mode 100644 index 0000000..af25297 --- /dev/null +++ b/src/Transaction/Processor/LogIn/ProcessCramMd5.php @@ -0,0 +1,149 @@ + [ + PositiveIntermediate::class, + TransientNegativeCompletion::class, + PermanentNegativeCompletion::class, + ], + Status::SENT_CRAM_MD5_RESPONSE => [ + PositiveCompletion::class, + TransientNegativeCompletion::class, + PermanentNegativeCompletion::class, + ], + ]; + + /** @var Status */ + private $currentStatus; + + /** @var Factory */ + private $replyFactory; + + /** @var Output */ + private $logger; + + /** @var Socket */ + private $connection; + + /** @var Authentication */ + private $authentication; + + public function __construct(Factory $replyFactory, Output $logger, Socket $connection, Authentication $authentication) + { + $this->replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->authentication = $authentication; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->startCramMd5Process(); + + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES[$this->currentStatus->getValue()]); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + switch ($this->currentStatus->getValue()) { + case Status::AWAITING_CRAM_MD5_CHALLENGE: + yield $this->processChallengeReply($reply); + break; + + case Status::SENT_CRAM_MD5_RESPONSE: + yield $this->processResponseReply($reply); + break; + } + } + + return $this->currentStatus; + }); + } + + private function startCramMd5Process(): Promise + { + $this->currentStatus = new Status(Status::AWAITING_CRAM_MD5_CHALLENGE); + + $this->connection->write((string) new AuthCramMd5Start()); + + return new Success(); + } + + private function processChallengeReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveIntermediate::class: + return $this->processCramMd5Challenge($reply); + + case TransientNegativeCompletion::class: + return $this->processUnknownError(); + + case PermanentNegativeCompletion::class: + return $this->processInvalidCredentials(); + } + } + + private function processResponseReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processCredentialsAccepted(); + + case TransientNegativeCompletion::class: + return $this->processUnknownError(); + + case PermanentNegativeCompletion::class: + return $this->processInvalidCredentials(); + } + } + + private function processCramMd5Challenge(Reply $reply): Promise + { + $this->currentStatus = new Status(Status::SENT_CRAM_MD5_RESPONSE); + + return $this->connection->write((string) new AuthCramMd5Response($this->authentication, $reply->getText())); + } + + private function processCredentialsAccepted(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + public function processUnknownError(): Promise + { + throw new TransmissionChannelClosed(); + } + + private function processInvalidCredentials(): Promise + { + throw new InvalidCredentials(); + } +} diff --git a/src/Transaction/Processor/LogIn/ProcessLogin.php b/src/Transaction/Processor/LogIn/ProcessLogin.php new file mode 100644 index 0000000..1ccc8fe --- /dev/null +++ b/src/Transaction/Processor/LogIn/ProcessLogin.php @@ -0,0 +1,143 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->authentication = $authentication; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->startLoginProcess(); + + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function startLoginProcess(): Promise + { + $this->currentStatus = new Status(Status::SENT_LOGIN); + + $this->connection->write((string) new AuthLoginStart()); + + return new Success(); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processCredentialsAccepted(); + + case PositiveIntermediate::class: + return $this->processActiveLoginProcess($reply); + + case TransientNegativeCompletion::class: + return $this->processUnknownError(); + + case PermanentNegativeCompletion::class: + return $this->processInvalidCredentials(); + } + } + + private function processCredentialsAccepted(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + private function processActiveLoginProcess(Reply $reply): Promise + { + switch (substr(strtolower(base64_decode($reply->getText())), 0, 8)) { + case 'username': + return $this->processActiveLoginSendUsername(); + + case 'password': + return $this->processActiveLoginSendPassword(); + + default: + throw new UnexpectedReply((string) $reply); + } + } + + private function processActiveLoginSendUsername(): Promise + { + return $this->connection->write((string) new AuthLoginUsername($this->authentication)); + } + + private function processActiveLoginSendPassword(): Promise + { + return $this->connection->write((string) new AuthLoginPassword($this->authentication)); + } + + public function processUnknownError(): Promise + { + throw new TransmissionChannelClosed(); + } + + private function processInvalidCredentials(): Promise + { + throw new InvalidCredentials(); + } +} diff --git a/src/Transaction/Processor/LogIn/ProcessPlain.php b/src/Transaction/Processor/LogIn/ProcessPlain.php new file mode 100644 index 0000000..d22b8b2 --- /dev/null +++ b/src/Transaction/Processor/LogIn/ProcessPlain.php @@ -0,0 +1,111 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->authentication = $authentication; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendPlain(); + + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendPlain(): Promise + { + $this->currentStatus = new Status(Status::SENT_PLAIN); + + $this->connection->write((string) new AuthPlain($this->authentication)); + + return new Success(); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processCredentialsAccepted(); + + case TransientNegativeCompletion::class: + return $this->processUnknownError(); + + case PermanentNegativeCompletion::class: + return $this->processInvalidCredentials(); + } + } + + private function processCredentialsAccepted(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return new Success(); + } + + public function processUnknownError(): Promise + { + throw new TransmissionChannelClosed(); + } + + private function processInvalidCredentials(): Promise + { + throw new InvalidCredentials(); + } +} diff --git a/src/Transaction/Processor/Mail.php b/src/Transaction/Processor/Mail.php index 3b91784..97ff9fe 100644 --- a/src/Transaction/Processor/Mail.php +++ b/src/Transaction/Processor/Mail.php @@ -5,56 +5,19 @@ use Amp\Promise; use HarmonyIO\SmtpClient\Buffer; use HarmonyIO\SmtpClient\Envelop; -use HarmonyIO\SmtpClient\Envelop\Address; -use HarmonyIO\SmtpClient\Exception\Smtp\DataNotAccepted; -use HarmonyIO\SmtpClient\Exception\Smtp\InvalidMailFromAddress; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\Socket; -use HarmonyIO\SmtpClient\Transaction\Command\BodyLine; -use HarmonyIO\SmtpClient\Transaction\Command\Data; -use HarmonyIO\SmtpClient\Transaction\Command\EndData; -use HarmonyIO\SmtpClient\Transaction\Command\Header as HeaderCommand; -use HarmonyIO\SmtpClient\Transaction\Command\HeadersAndBodySeparator; -use HarmonyIO\SmtpClient\Transaction\Command\MailFrom; -use HarmonyIO\SmtpClient\Transaction\Command\Quit; -use HarmonyIO\SmtpClient\Transaction\Command\RcptTo; +use HarmonyIO\SmtpClient\Transaction\Processor\Mail\ProcessContent; +use HarmonyIO\SmtpClient\Transaction\Processor\Mail\ProcessData; +use HarmonyIO\SmtpClient\Transaction\Processor\Mail\ProcessHeaders; +use HarmonyIO\SmtpClient\Transaction\Processor\Mail\ProcessMailFrom; +use HarmonyIO\SmtpClient\Transaction\Processor\Mail\ProcessRecipients; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; -use HarmonyIO\SmtpClient\Transaction\Reply\PermanentNegativeCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveCompletion; -use HarmonyIO\SmtpClient\Transaction\Reply\PositiveIntermediate; -use HarmonyIO\SmtpClient\Transaction\Reply\Reply; -use HarmonyIO\SmtpClient\Transaction\Reply\TransientNegativeCompletion; use HarmonyIO\SmtpClient\Transaction\Status\Mail as Status; use function Amp\call; final class Mail implements Processor { - private const AVAILABLE_COMMANDS = [ - Status::SENT_MAIL_FROM => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENDING_RECIPIENTS => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENT_DATA => [ - PositiveIntermediate::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - Status::SENT_CONTENT => [ - PositiveCompletion::class, - TransientNegativeCompletion::class, - PermanentNegativeCompletion::class, - ], - ]; - - /** @var Status */ - private $currentStatus; - /** @var Factory */ private $replyFactory; @@ -67,193 +30,38 @@ final class Mail implements Processor /** @var Envelop */ private $envelop; - /** @var Address[] */ - private $recipientsToSend = []; - public function __construct( Factory $replyFactory, Output $logger, Socket $connection, Envelop $envelop ) { - $this->replyFactory = $replyFactory; - $this->logger = $logger; - $this->connection = $connection; - $this->envelop = $envelop; - $this->recipientsToSend = $envelop->getRecipients(); + $this->replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->envelop = $envelop; } public function process(Buffer $buffer): Promise { return call(function () use ($buffer) { - $this->sendMailFrom(); - - while ($this->currentStatus->getValue() !== Status::COMPLETED) { - $this->processReply(yield $buffer->readLine()); + $processors = [ + new ProcessMailFrom($this->replyFactory, $this->logger, $this->connection, $this->envelop->getMailFromAddress()), + new ProcessRecipients($this->replyFactory, $this->logger, $this->connection, ...$this->envelop->getRecipients()), + new ProcessData($this->replyFactory, $this->logger, $this->connection), + new ProcessHeaders($this->connection, ...array_values($this->envelop->getHeaders())), + new ProcessContent($this->replyFactory, $this->logger, $this->connection, $this->envelop->getBody()), + ]; + + /** @var Processor $processor */ + foreach ($processors as $processor) { + /** @var Status $status */ + $status = yield $processor->process($buffer); + + if ($status->getValue() === Status::COMPLETED) { + return; + } } }); } - - private function processReply(string $line): void - { - $reply = $this->replyFactory->build($line, self::AVAILABLE_COMMANDS[$this->currentStatus->getValue()]); - - $this->logger->debug('Server reply object: ' . get_class($reply)); - - switch ($this->currentStatus->getValue()) { - case Status::SENT_MAIL_FROM: - $this->processSentEhloReply($reply); - return; - - case Status::SENDING_RECIPIENTS: - $this->processSendingRecipientsReply($reply); - return; - - case Status::SENT_DATA: - $this->processSentDataReply($reply); - return; - - case Status::SENT_CONTENT: - $this->processSentContentReply($reply); - return; - } - } - - private function processSentEhloReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processMailFromAccepted(); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processMailFromNotAccepted($reply); - return; - } - } - - private function processSendingRecipientsReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processRecipientAccepted(); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processRecipientNotAccepted(); - return; - } - } - - private function processSentDataReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveIntermediate::class: - $this->processDataAccepted(); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processRecipientNotAccepted(); - return; - } - } - - private function processSentContentReply(Reply $reply): void - { - switch (get_class($reply)) { - case PositiveCompletion::class: - $this->processContentAccepted(); - return; - - case TransientNegativeCompletion::class: - case PermanentNegativeCompletion::class: - $this->processContentNotAccepted($reply); - return; - } - } - - private function sendMailFrom(): void - { - $this->currentStatus = new Status(Status::SENT_MAIL_FROM); - - $this->connection->write((string) new MailFrom($this->envelop->getMailFromAddress())); - } - - private function processMailFromNotAccepted(Reply $reply): void - { - $this->connection->write((string) new Quit()); - - throw new InvalidMailFromAddress((string) $reply->getText()); - } - - private function processMailFromAccepted(): void - { - $this->currentStatus = new Status(Status::SENDING_RECIPIENTS); - - $recipient = array_shift($this->recipientsToSend); - - $this->connection->write((string) new RcptTo($recipient)); - } - - private function processRecipientAccepted(): void - { - if (!count($this->recipientsToSend)) { - $this->sendData(); - - return; - } - - $recipient = array_shift($this->recipientsToSend); - - $this->connection->write((string) new RcptTo($recipient)); - } - - private function processRecipientNotAccepted(): void - { - // @todo: log (and mark?) recipient as invalid, but continue - // check whether we have at least one valid recipient before starting DATA - - if (!count($this->recipientsToSend)) { - $this->sendData(); - - return; - } - } - - private function sendData(): void - { - $this->currentStatus = new Status(Status::SENT_DATA); - - $this->connection->write((string) new Data()); - } - - private function processDataAccepted(): void - { - $this->currentStatus = new Status(Status::SENT_CONTENT); - - foreach ($this->envelop->getHeaders() as $header) { - $this->connection->write((string) new HeaderCommand($header)); - } - - $this->connection->write((string) new HeadersAndBodySeparator()); - - $this->connection->write((string) new BodyLine($this->envelop->getBody())); - - $this->connection->write((string) new EndData()); - } - - private function processContentAccepted(): void - { - $this->currentStatus = new Status(Status::COMPLETED); - - $this->connection->write((string) new Quit()); - } - - private function processContentNotAccepted(Reply $reply): void - { - throw new DataNotAccepted($reply->getText()); - } } diff --git a/src/Transaction/Processor/Mail/ProcessContent.php b/src/Transaction/Processor/Mail/ProcessContent.php new file mode 100644 index 0000000..025be44 --- /dev/null +++ b/src/Transaction/Processor/Mail/ProcessContent.php @@ -0,0 +1,105 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->body = $body; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->processContent(); + + while ($this->currentStatus->getValue() !== Status::COMPLETED) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function processContent(): Promise + { + $this->currentStatus = new Status(Status::SENT_CONTENT); + + $this->connection->write((string) new BodyLine($this->body)); + + return $this->connection->write((string) new EndData()); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processContentAccepted(); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processContentNotAccepted($reply); + } + } + + private function processContentAccepted(): Promise + { + $this->currentStatus = new Status(Status::COMPLETED); + + return $this->connection->write((string) new Quit()); + } + + private function processContentNotAccepted(Reply $reply): Promise + { + $this->connection->write((string) new Quit()); + + throw new DataNotAccepted($reply->getText()); + } +} diff --git a/src/Transaction/Processor/Mail/ProcessData.php b/src/Transaction/Processor/Mail/ProcessData.php new file mode 100644 index 0000000..1844af1 --- /dev/null +++ b/src/Transaction/Processor/Mail/ProcessData.php @@ -0,0 +1,99 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendDataCommand(); + + while ($this->currentStatus->getValue() !== Status::SENDING_HEADERS) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendDataCommand(): Promise + { + $this->currentStatus = new Status(Status::SENT_DATA); + + return $this->connection->write((string) new Data()); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveIntermediate::class: + return $this->processDataAccepted(); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processDataCommandNotAccepted($reply); + } + } + + private function processDataAccepted(): Promise + { + $this->currentStatus = new Status(Status::SENDING_HEADERS); + + return new Success(); + } + + private function processDataCommandNotAccepted(Reply $reply): Promise + { + $this->connection->write((string) new Quit()); + + throw new DataNotAccepted($reply->getText()); + } +} diff --git a/src/Transaction/Processor/Mail/ProcessHeaders.php b/src/Transaction/Processor/Mail/ProcessHeaders.php new file mode 100644 index 0000000..f5e80b9 --- /dev/null +++ b/src/Transaction/Processor/Mail/ProcessHeaders.php @@ -0,0 +1,47 @@ +connection = $connection; + $this->headers = $headers; + } + + // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + public function process(Buffer $buffer): Promise + { + return call(function () { + yield $this->processHeaders(); + + return new Status(Status::SENT_HEADERS); + }); + } + + private function processHeaders(): Promise + { + foreach ($this->headers as $header) { + $this->connection->write((string) new HeaderCommand($header)); + } + + return $this->connection->write((string) new HeadersAndBodySeparator()); + } +} diff --git a/src/Transaction/Processor/Mail/ProcessMailFrom.php b/src/Transaction/Processor/Mail/ProcessMailFrom.php new file mode 100644 index 0000000..4c3e83d --- /dev/null +++ b/src/Transaction/Processor/Mail/ProcessMailFrom.php @@ -0,0 +1,104 @@ +replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->fromAddress = $fromAddress; + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendMailFrom(); + + while ($this->currentStatus->getValue() === Status::SENT_MAIL_FROM) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendMailFrom(): Promise + { + $this->currentStatus = new Status(Status::SENT_MAIL_FROM); + + return $this->connection->write((string) new MailFrom($this->fromAddress)); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processMailFromAccepted(); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processMailFromNotAccepted($reply); + } + } + + private function processMailFromAccepted(): Promise + { + $this->currentStatus = new Status(Status::SENDING_RECIPIENTS); + + return new Success(); + } + + private function processMailFromNotAccepted(Reply $reply): Promise + { + $this->connection->write((string) new Quit()); + + throw new InvalidMailFromAddress((string) $reply->getText()); + } +} diff --git a/src/Transaction/Processor/Mail/ProcessRecipients.php b/src/Transaction/Processor/Mail/ProcessRecipients.php new file mode 100644 index 0000000..8b3629e --- /dev/null +++ b/src/Transaction/Processor/Mail/ProcessRecipients.php @@ -0,0 +1,120 @@ +currentStatus = new Status(Status::SENDING_RECIPIENTS); + + $this->replyFactory = $replyFactory; + $this->logger = $logger; + $this->connection = $connection; + $this->recipients = array_merge([$recipient], $recipients); + } + + public function process(Buffer $buffer): Promise + { + return call(function () use ($buffer) { + yield $this->sendRecipient(); + + while ($this->currentStatus->getValue() !== Status::SENT_RECIPIENTS) { + $line = yield $buffer->readLine(); + $reply = $this->replyFactory->build($line, self::ALLOWED_REPLIES); + + $this->logger->debug('Server reply object: ' . get_class($reply)); + + yield $this->processReply($reply); + } + + return $this->currentStatus; + }); + } + + private function sendRecipient(): Promise + { + $recipient = array_shift($this->recipients); + + return $this->connection->write((string) new RcptTo($recipient)); + } + + private function processReply(Reply $reply): Promise + { + switch (get_class($reply)) { + case PositiveCompletion::class: + return $this->processRecipientAccepted(); + + case TransientNegativeCompletion::class: + case PermanentNegativeCompletion::class: + return $this->processRecipientNotAccepted(); + } + } + + private function processRecipientAccepted(): Promise + { + if ($this->recipients) { + return $this->sendRecipient(); + } + + $this->currentStatus = new Status(Status::SENT_RECIPIENTS); + + return new Success(); + } + + private function processRecipientNotAccepted(): Promise + { + // @todo: log (and mark?) recipient as invalid, but continue + // check whether we have at least one valid recipient before starting DATA + + if ($this->recipients) { + return $this->sendRecipient(); + } + + $this->currentStatus = new Status(Status::SENT_RECIPIENTS); + + return new Success(); + } +} diff --git a/src/Transaction/Status/ExtensionNegotiation.php b/src/Transaction/Status/ExtensionNegotiation.php index 5e2aec1..aaf4a00 100644 --- a/src/Transaction/Status/ExtensionNegotiation.php +++ b/src/Transaction/Status/ExtensionNegotiation.php @@ -6,8 +6,10 @@ final class ExtensionNegotiation extends Enum { - public const SENT_EHLO = 1; - public const PROCESSING_EXTENSION_LIST = 2; - public const SENT_HELO = 3; - public const COMPLETED = 4; + public const START_PROCESS = 1; + public const SENT_EHLO = 2; + public const PROCESSING_EXTENSION_LIST = 3; + public const SEND_HELO = 4; + public const SENT_HELO = 5; + public const COMPLETED = 6; } diff --git a/src/Transaction/Status/Handshake.php b/src/Transaction/Status/Handshake.php index d8f327d..b4c5a89 100644 --- a/src/Transaction/Status/Handshake.php +++ b/src/Transaction/Status/Handshake.php @@ -6,7 +6,7 @@ final class Handshake extends Enum { - public const AWAITING_GREETING = 1; - public const AWAITING_BANNER = 2; - public const COMPLETED = 3; + public const PROCESS_GREETING = 1; + public const PROCESS_BANNER = 2; + public const COMPLETED = 3; } diff --git a/src/Transaction/Status/Mail.php b/src/Transaction/Status/Mail.php index 1553f7e..ed1dd03 100644 --- a/src/Transaction/Status/Mail.php +++ b/src/Transaction/Status/Mail.php @@ -8,7 +8,10 @@ final class Mail extends Enum { public const SENT_MAIL_FROM = 1; public const SENDING_RECIPIENTS = 2; - public const SENT_DATA = 3; - public const SENT_CONTENT = 4; - public const COMPLETED = 5; + public const SENT_RECIPIENTS = 3; + public const SENT_DATA = 4; + public const SENDING_HEADERS = 5; + public const SENT_HEADERS = 6; + public const SENT_CONTENT = 7; + public const COMPLETED = 8; } diff --git a/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessEhloTest.php b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessEhloTest.php new file mode 100644 index 0000000..510cfd8 --- /dev/null +++ b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessEhloTest.php @@ -0,0 +1,143 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessEhlo( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Localhost() + ); + } + + public function testProcessThrowsOnErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("EHLO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessFallsBackToHeloWhenEhloIsNotSupported(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("EHLO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SEND_HELO, $status->getValue()); + } + + public function testProcessResultsInCompletedStatusWhenEhloIsSupportedButNoExtensionsAreAvailable(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("EHLO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } + + public function testProcessResultsInProcessingExtensionsStatusWhenEhloIsSupported(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200-error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("EHLO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::PROCESSING_EXTENSION_LIST, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessExtensionsTest.php b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessExtensionsTest.php new file mode 100644 index 0000000..6a595bd --- /dev/null +++ b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessExtensionsTest.php @@ -0,0 +1,131 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->extensionFactory = $this->createMock(Builder::class); + $this->processor = new ProcessExtensions( + new Factory(), + $this->logger, + new Collection($this->extensionFactory) + ); + } + + public function testProcessThrowsOnTransientErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnTransientErrorReplyAfterSuccessfulReply(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200-success\r\n"), + new Success("400 error\r\n") + ) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentErrorReplyAfterSuccessfulReply(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200-success\r\n"), + new Success("500 error\r\n") + ) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessEnablesSingleExtensions(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls(new Success("200 success\r\n")) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } + + public function testProcessEnablesMultipleExtensions(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200-success\r\n"), + new Success("200 success\r\n") + ) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessHeloTest.php b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessHeloTest.php new file mode 100644 index 0000000..a57bb1e --- /dev/null +++ b/tests/Unit/Transaction/Processor/ExtensionNegotiation/ProcessHeloTest.php @@ -0,0 +1,118 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessHelo( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Localhost() + ); + } + + public function testProcessThrowsOnTransientErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("HELO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("HELO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatus(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("HELO [127.0.0.1]\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/ExtensionNegotiationTest.php b/tests/Unit/Transaction/Processor/ExtensionNegotiationTest.php index 46c0693..c64df26 100644 --- a/tests/Unit/Transaction/Processor/ExtensionNegotiationTest.php +++ b/tests/Unit/Transaction/Processor/ExtensionNegotiationTest.php @@ -2,13 +2,11 @@ namespace HarmonyIO\SmtpClientTest\Unit\Transaction\Processor; -use Amp\Promise; use Amp\Socket\Socket as ServerSocket; use Amp\Success; use HarmonyIO\PHPUnitExtension\TestCase; use HarmonyIO\SmtpClient\Buffer; use HarmonyIO\SmtpClient\ClientAddress\Localhost; -use HarmonyIO\SmtpClient\Exception\Smtp\TransmissionChannelClosed; use HarmonyIO\SmtpClient\Log\Level; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\SmtpSocket; @@ -18,22 +16,21 @@ use HarmonyIO\SmtpClient\Transaction\Processor\ExtensionNegotiation; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; use PHPUnit\Framework\MockObject\MockObject; -use function Amp\Promise\wait; class ExtensionNegotiationTest extends TestCase { /** @var Output */ private $logger; + /** @var SmtpSocket|MockObject $smtpSocket */ + private $smtpSocket; + /** @var ServerSocket|MockObject $socket */ private $socket; - /** @var Builder|MockObject $extensionFactory */ + /** @var Builder|MockObject $socket */ private $extensionFactory; - /** @var SmtpSocket|MockObject $smtpSocket */ - private $smtpSocket; - /** @var ExtensionNegotiation */ private $processor; @@ -41,9 +38,9 @@ class ExtensionNegotiationTest extends TestCase public function setUp() { $this->logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); $this->socket = $this->createMock(ServerSocket::class); $this->extensionFactory = $this->createMock(Builder::class); - $this->smtpSocket = $this->createMock(SmtpSocket::class); $this->processor = new ExtensionNegotiation( new Factory(), $this->logger, @@ -53,301 +50,40 @@ public function setUp() ); } - public function testProcessStartsWithSendingEhlo(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $this->expectException(TransmissionChannelClosed::class); - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testProcessEhloResponseThrowsOnTransientNegativeCompletionResponse(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $this->expectException(TransmissionChannelClosed::class); - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testProcessEhloResponseFallsBackToHeloWhenEhloIsNotSupported(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("HELO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testProcessHeloResponseThrowsWhenTransientNegativeCompletionIsReturned(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("HELO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->processor->process($buffer)); - } - - public function testProcessHeloResponseThrowsWhenPermanentNegativeCompletionIsReturned(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("HELO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->processor->process($buffer)); - } - - public function testProcessEhloSupportedWithoutExtensions(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testProcessEhloSupportedWithSingleExtension(): void - { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->method('read') - ->willReturnOnConsecutiveCalls( - new Success("200-success\r\n"), - new Success("200 AUTH LOGIN\r\n") - ) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testProcessEhloSupportedWithMultipleExtensions(): void + public function testProcessProcessesEntireExtensionNegotiationWhenEhloIsNotSupported(): void { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->method('read') ->willReturnOnConsecutiveCalls( - new Success("200-success\r\n"), - new Success("200-AUTH LOGIN\r\n"), - new Success("200 EXTENSION\r\n") + new Success("500 error\r\n"), + new Success("200 error\r\n") ) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); + $this->assertNull($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessExtensionListThrowsWhenTransientNegativeCompletionIsReturned(): void + public function testProcessProcessesEntireExtensionNegotiationWhenNoExtensionsAreAvailable(): void { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->method('read') - ->willReturnOnConsecutiveCalls( - new Success("200-success\r\n"), - new Success("200-AUTH LOGIN\r\n"), - new Success("400 error\r\n") - ) + ->willReturn(new Success("200 error\r\n")) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->processor->process($buffer)); + $this->assertNull($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessExtensionListThrowsWhenPermanentNegativeCompletionIsReturned(): void + public function testProcessProcessesEntireExtensionNegotiationWhenExtensionsAreAvailable(): void { - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function ($data): Promise { - $this->assertSame("EHLO [127.0.0.1]\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->method('read') ->willReturnOnConsecutiveCalls( - new Success("200-success\r\n"), - new Success("200-AUTH LOGIN\r\n"), - new Success("500 error\r\n") + new Success("200-error\r\n"), + new Success("200-error\r\n"), + new Success("200 error\r\n") ) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->processor->process($buffer)); + $this->assertNull($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } } diff --git a/tests/Unit/Transaction/Processor/Handshake/ProcessBannerTest.php b/tests/Unit/Transaction/Processor/Handshake/ProcessBannerTest.php new file mode 100644 index 0000000..c5ad82e --- /dev/null +++ b/tests/Unit/Transaction/Processor/Handshake/ProcessBannerTest.php @@ -0,0 +1,96 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->processor = new ProcessBanner(new Factory(), $this->logger); + } + + public function testProcessThrowsOnErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->expectException(TransactionFailed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsWhenThirdBannerLineResultsInAnErrorReply(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200-success\r\n"), + new Success("200-success\r\n"), + new Success("400 error\r\n") + ) + ; + + $this->expectException(TransactionFailed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatusWhenTheBannerContainsOnlyASingleLine(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 success\r\n")) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } + + public function testProcessResultsInCompletedStatusWhenTheBannerContainsMultipleLines(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200-success\r\n"), + new Success("200-success\r\n"), + new Success("200 success\r\n") + ) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/Handshake/ProcessGreetingTest.php b/tests/Unit/Transaction/Processor/Handshake/ProcessGreetingTest.php new file mode 100644 index 0000000..282e365 --- /dev/null +++ b/tests/Unit/Transaction/Processor/Handshake/ProcessGreetingTest.php @@ -0,0 +1,77 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->processor = new ProcessGreeting(new Factory(), $this->logger); + } + + public function testProcessThrowsOnErrorReply(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->expectException(TransactionFailed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatusWhenTheReplyDoesNotHaveABanner(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 success\r\n")) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } + + public function testProcessResultsInProcessBannerStatusWhenTheReplyContainsABanner(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200-success\r\n")) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::PROCESS_BANNER, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/HandshakeTest.php b/tests/Unit/Transaction/Processor/HandshakeTest.php index eb72268..4148638 100644 --- a/tests/Unit/Transaction/Processor/HandshakeTest.php +++ b/tests/Unit/Transaction/Processor/HandshakeTest.php @@ -5,14 +5,12 @@ use Amp\Success; use HarmonyIO\PHPUnitExtension\TestCase; use HarmonyIO\SmtpClient\Buffer; -use HarmonyIO\SmtpClient\Exception\TransactionFailed; use HarmonyIO\SmtpClient\Log\Level; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\SmtpSocket; use HarmonyIO\SmtpClient\Transaction\Processor\Handshake; use HarmonyIO\SmtpClient\Transaction\Reply\Factory; use PHPUnit\Framework\MockObject\MockObject; -use function Amp\Promise\wait; class HandshakeTest extends TestCase { @@ -33,21 +31,7 @@ public function setUp() $this->processor = new Handshake(new Factory(), $this->logger); } - public function testAwaitingGreetingThrowsOnTransientNegativeCompletion(): void - { - $this->smtpSocket - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $this->expectException(TransactionFailed::class); - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testAwaitingGreetingCompletesTransaction(): void + public function testProcessProcessesEntireHandShakeWithoutBanner(): void { $this->smtpSocket ->expects($this->once()) @@ -57,67 +41,21 @@ public function testAwaitingGreetingCompletesTransaction(): void $buffer = new Buffer($this->smtpSocket, $this->logger); - wait($this->processor->process($buffer)); + $this->assertNull($this->processor->process($buffer)); } - public function testAwaitingGreetingMovesToAwaitingSingleLineBanner(): void - { - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("200-error\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("200 banner line 1\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testAwaitingGreetingMovesToAwaitingMultiLineBanner(): void - { - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("200-success\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("200-banner line 1\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(2)) - ->method('read') - ->willReturn(new Success("200 banner line 2\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->processor->process($buffer)); - } - - public function testAwaitingBannerThrowsOnTransientNegativeCompletion(): void + public function testProcessProcessesEntireHandShakeWithBanner(): void { $this->smtpSocket ->method('read') ->willReturnOnConsecutiveCalls( - new Success("200-success\r\n"), - new Success("400 error\r\n") + new Success("200 success\r\n"), + new Success("200 success\r\n") ) ; - $this->expectException(TransactionFailed::class); - $buffer = new Buffer($this->smtpSocket, $this->logger); - wait($this->processor->process($buffer)); + $this->assertNull($this->processor->process($buffer)); } } diff --git a/tests/Unit/Transaction/Processor/LogIn/ProcessCramMd5Test.php b/tests/Unit/Transaction/Processor/LogIn/ProcessCramMd5Test.php new file mode 100644 index 0000000..666cbb9 --- /dev/null +++ b/tests/Unit/Transaction/Processor/LogIn/ProcessCramMd5Test.php @@ -0,0 +1,201 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessCramMd5( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Authentication('TheUsername', 'ThePassword') + ); + } + + public function testProcessThrowsOnUnknownError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH CRAM-MD5\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnInvalidCredentials(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH CRAM-MD5\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidCredentials::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnUnknownErrorAfterSendingTheResponse(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("300 success\r\n"), + new Success("400 error\r\n") + ) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH CRAM-MD5\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("VGhlVXNlcm5hbWUgYmI5ZDRlNWY5YzVhOTQ4ZDczMmFhYThkYjA1MzkyOTY=\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnInvalidCredentialsAfterSendingTheResponse(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("300 success\r\n"), + new Success("500 error\r\n") + ) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH CRAM-MD5\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("VGhlVXNlcm5hbWUgYmI5ZDRlNWY5YzVhOTQ4ZDczMmFhYThkYjA1MzkyOTY=\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidCredentials::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatusOnValidCredentials(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("300 VXNlcm5hbWU=\r\n"), + new Success("200 success\r\n") + ) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH CRAM-MD5\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("VGhlVXNlcm5hbWUgMjNhMmRmY2NhZDg3ZjhkYjVjODUxOWU3MDI4ZmYxMGU=\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/LogIn/ProcessLoginTest.php b/tests/Unit/Transaction/Processor/LogIn/ProcessLoginTest.php new file mode 100644 index 0000000..5782140 --- /dev/null +++ b/tests/Unit/Transaction/Processor/LogIn/ProcessLoginTest.php @@ -0,0 +1,166 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessLogin( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Authentication('TheUsername', 'ThePassword') + ); + } + + public function testProcessThrowsOnUnknownError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH LOGIN\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnInvalidCredentials(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH LOGIN\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidCredentials::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsWhenPositiveIntermediateDoesNotStartWithUsernameOrPassword(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("300 success\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH LOGIN\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(UnexpectedReply::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatusOnValidCredentials(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("300 VXNlcm5hbWU=\r\n"), + new Success("300 UGFzc3dvcmQ=\r\n"), + new Success("200 success\r\n") + ) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH LOGIN\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("VGhlVXNlcm5hbWU=\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("VGhlUGFzc3dvcmQ=\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/LogIn/ProcessPlainTest.php b/tests/Unit/Transaction/Processor/LogIn/ProcessPlainTest.php new file mode 100644 index 0000000..6471947 --- /dev/null +++ b/tests/Unit/Transaction/Processor/LogIn/ProcessPlainTest.php @@ -0,0 +1,119 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessPlain( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Authentication('TheUsername', 'ThePassword') + ); + } + + public function testProcessThrowsOnUnknownError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH PLAIN AFRoZVVzZXJuYW1lAFRoZVBhc3N3b3Jk\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(TransmissionChannelClosed::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnInvalidCredentials(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH PLAIN AFRoZVVzZXJuYW1lAFRoZVBhc3N3b3Jk\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidCredentials::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessSucceedsOnValidCredentials(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 success\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("AUTH PLAIN AFRoZVVzZXJuYW1lAFRoZVBhc3N3b3Jk\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/LogInTest.php b/tests/Unit/Transaction/Processor/LogInTest.php index ffd4d23..9d2c757 100644 --- a/tests/Unit/Transaction/Processor/LogInTest.php +++ b/tests/Unit/Transaction/Processor/LogInTest.php @@ -8,7 +8,6 @@ use HarmonyIO\SmtpClient\Authentication; use HarmonyIO\SmtpClient\Buffer; use HarmonyIO\SmtpClient\Exception\Smtp\InvalidCredentials; -use HarmonyIO\SmtpClient\Exception\Smtp\TransmissionChannelClosed; use HarmonyIO\SmtpClient\Log\Level; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\SmtpSocket; @@ -27,41 +26,30 @@ class LogInTest extends TestCase /** @var Output */ private $logger; + /** @var SmtpSocket|MockObject $smtpSocket */ + private $smtpSocket; + /** @var ServerSocket|MockObject $socket */ private $socket; - /** @var Builder|MockObject $extensionFactory */ + /** @var Builder|MockObject $socket */ private $extensionFactory; - /** @var SmtpSocket|MockObject $smtpSocket */ - private $smtpSocket; - /** @var Collection */ private $extensions; /** @var LogIn */ - private $unauthenticatedProcessor; - - /** @var LogIn */ - private $authenticatedProcessor; + private $processor; // phpcs:ignore SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint public function setUp() { $this->logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); $this->socket = $this->createMock(ServerSocket::class); $this->extensionFactory = $this->createMock(Builder::class); - $this->smtpSocket = $this->createMock(SmtpSocket::class); $this->extensions = new Collection($this->extensionFactory); - - $this->unauthenticatedProcessor = new LogIn( - new Factory(), - $this->logger, - new Socket($this->logger, $this->socket), - $this->extensions - ); - - $this->authenticatedProcessor = new LogIn( + $this->processor = new LogIn( new Factory(), $this->logger, new Socket($this->logger, $this->socket), @@ -70,112 +58,41 @@ public function setUp() ); } - public function testProcessBailsOutWhenUnauthenticated(): void + public function testProcessBailsOutWhenAuthenticationIsNotSet(): void { $this->smtpSocket ->expects($this->never()) ->method('read') ; - $buffer = new Buffer($this->smtpSocket, $this->logger); + $processor = new LogIn( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Collection($this->extensionFactory) + ); - wait($this->unauthenticatedProcessor->process($buffer)); + $this->assertNull($processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessBailsOutWhenTheAuthExtensionIsNotEnabled(): void + public function testProcessBailsOutWhenServerDoesNotSupportAuthentication(): void { $this->smtpSocket ->expects($this->never()) ->method('read') ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->authenticatedProcessor->process($buffer)); + $this->assertNull($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessSendsPlainLogInRequestAndFailsWithATransientNegativeCompletion(): void + public function testProcessRunsPlainAuthentication(): void { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('PLAIN')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH PLAIN AFRoZVVzZXJuYW1lAFRoZVBhc3N3b3Jk\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsPlainLogInRequestAndFailsWithAPermanentNegativeCompletion(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('PLAIN')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH PLAIN AFRoZVVzZXJuYW1lAFRoZVBhc3N3b3Jk\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->expects($this->once()) ->method('read') ->willReturn(new Success("500 error\r\n")) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(InvalidCredentials::class); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsPlainLogInRequestAndSucceeds(): void - { $this->extensionFactory ->method('build') ->willReturn(new Auth('PLAIN')) @@ -186,7 +103,7 @@ public function testProcessSendsPlainLogInRequestAndSucceeds(): void $reply ->method('getText') - ->willReturn('FOO BAR') + ->willReturn('200 success') ; $this->extensions->enable($reply); @@ -201,99 +118,19 @@ public function testProcessSendsPlainLogInRequestAndSucceeds(): void }) ; - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsLoginLogInRequestAndFailsWithATransientNegativeCompletion(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('LOGIN')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH LOGIN\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); + $this->expectException(InvalidCredentials::class); - wait($this->authenticatedProcessor->process($buffer)); + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessSendsLoginLogInRequestAndFailsWithAPermanentNegativeCompletion(): void + public function testProcessRunsLoginAuthentication(): void { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('LOGIN')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH LOGIN\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->expects($this->once()) ->method('read') ->willReturn(new Success("500 error\r\n")) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(InvalidCredentials::class); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsLoginLogInRequestAndSucceedsWithAPositiveCompletion(): void - { $this->extensionFactory ->method('build') ->willReturn(new Auth('LOGIN')) @@ -304,7 +141,7 @@ public function testProcessSendsLoginLogInRequestAndSucceedsWithAPositiveComplet $reply ->method('getText') - ->willReturn('FOO BAR') + ->willReturn('200 success') ; $this->extensions->enable($reply); @@ -319,169 +156,19 @@ public function testProcessSendsLoginLogInRequestAndSucceedsWithAPositiveComplet }) ; - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsLoginLogInRequestAndSucceedsWithAPositiveIntermediates(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('LOGIN')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH LOGIN\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("VGhlVXNlcm5hbWU=\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(2)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("VGhlUGFzc3dvcmQ=\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("334 VXNlcm5hbWU6\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("334 UGFzc3dvcmQ6\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(2)) - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsCramMd5LogInRequestAndFailsWithATransientNegativeCompletion(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('CRAM-MD5')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH CRAM-MD5\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); + $this->expectException(InvalidCredentials::class); - wait($this->authenticatedProcessor->process($buffer)); + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } - public function testProcessSendsCramMd5LogInRequestAndFailsWithAPermanentNegativeCompletion(): void + public function testProcessRunsCramMd5Authentication(): void { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('CRAM-MD5')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->once()) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH CRAM-MD5\r\n", $data); - - return new Success(); - }) - ; - $this->smtpSocket ->expects($this->once()) ->method('read') ->willReturn(new Success("500 error\r\n")) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(InvalidCredentials::class); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsCramMd5LogInRequestAndFirstSucceedsThenFailsWithATransientNegativeCompletion(): void - { $this->extensionFactory ->method('build') ->willReturn(new Auth('CRAM-MD5')) @@ -492,69 +179,13 @@ public function testProcessSendsCramMd5LogInRequestAndFirstSucceedsThenFailsWith $reply ->method('getText') - ->willReturn('FOO BAR') + ->willReturn('200 success') ; $this->extensions->enable($reply); $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH CRAM-MD5\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("VGhlVXNlcm5hbWUgNTU5YTE1OTc2YjFiZjk0ZmE2NmY4NGUzMWEzOWRmZDI=\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("334 PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("400 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - $this->expectException(TransmissionChannelClosed::class); - - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessSendsCramMd5LogInRequestAndFirstSucceedsThenFailsWithAPermanentNegativeCompletion(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('CRAM-MD5')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->at(0)) + ->expects($this->once()) ->method('write') ->willReturnCallback(function (string $data) { $this->assertSame("AUTH CRAM-MD5\r\n", $data); @@ -563,86 +194,8 @@ public function testProcessSendsCramMd5LogInRequestAndFirstSucceedsThenFailsWith }) ; - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("VGhlVXNlcm5hbWUgNTU5YTE1OTc2YjFiZjk0ZmE2NmY4NGUzMWEzOWRmZDI=\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("334 PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("500 error\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - $this->expectException(InvalidCredentials::class); - wait($this->authenticatedProcessor->process($buffer)); - } - - public function testProcessCramMd5LoginSucceeds(): void - { - $this->extensionFactory - ->method('build') - ->willReturn(new Auth('CRAM-MD5')) - ; - - /** @var Reply|MockObject $reply */ - $reply = $this->createMock(Reply::class); - - $reply - ->method('getText') - ->willReturn('FOO BAR') - ; - - $this->extensions->enable($reply); - - $this->socket - ->expects($this->at(0)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("AUTH CRAM-MD5\r\n", $data); - - return new Success(); - }) - ; - - $this->socket - ->expects($this->at(1)) - ->method('write') - ->willReturnCallback(function (string $data) { - $this->assertSame("VGhlVXNlcm5hbWUgNTU5YTE1OTc2YjFiZjk0ZmE2NmY4NGUzMWEzOWRmZDI=\r\n", $data); - - return new Success(); - }) - ; - - $this->smtpSocket - ->expects($this->at(0)) - ->method('read') - ->willReturn(new Success("334 PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=\r\n")) - ; - - $this->smtpSocket - ->expects($this->at(1)) - ->method('read') - ->willReturn(new Success("200 success\r\n")) - ; - - $buffer = new Buffer($this->smtpSocket, $this->logger); - - wait($this->authenticatedProcessor->process($buffer)); + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); } } diff --git a/tests/Unit/Transaction/Processor/Mail/ProcessContentTest.php b/tests/Unit/Transaction/Processor/Mail/ProcessContentTest.php new file mode 100644 index 0000000..c5b4429 --- /dev/null +++ b/tests/Unit/Transaction/Processor/Mail/ProcessContentTest.php @@ -0,0 +1,177 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessContent( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + 'Test content body' + ); + } + + public function testProcessThrowsOnTransientError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Test content body\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame(".\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(DataNotAccepted::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Test content body\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame(".\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(DataNotAccepted::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInCompletedStatus(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Test content body\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame(".\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::COMPLETED, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/Mail/ProcessDataTest.php b/tests/Unit/Transaction/Processor/Mail/ProcessDataTest.php new file mode 100644 index 0000000..7e2a3d6 --- /dev/null +++ b/tests/Unit/Transaction/Processor/Mail/ProcessDataTest.php @@ -0,0 +1,136 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessData( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket) + ); + } + + public function testProcessThrowsOnTransientError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("DATA\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(DataNotAccepted::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("DATA\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(DataNotAccepted::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsSendingHeadersStatus(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("300 success\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("DATA\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENDING_HEADERS, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/Mail/ProcessHeadersTest.php b/tests/Unit/Transaction/Processor/Mail/ProcessHeadersTest.php new file mode 100644 index 0000000..bf9250e --- /dev/null +++ b/tests/Unit/Transaction/Processor/Mail/ProcessHeadersTest.php @@ -0,0 +1,103 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessHeaders( + new Socket($this->logger, $this->socket), + new Header('Foo', 'Bar'), + new Header('Baz', 'Qux') + ); + } + + public function testProcessSendsAllHeaders(): void + { + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Foo:Bar\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Baz:Qux\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENT_HEADERS, $status->getValue()); + } + + public function testProcessSendsDelimiterEvenWhenThereAreNoHeaders(): void + { + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("\r\n", $data); + + return new Success(); + }) + ; + + $processor = new ProcessHeaders(new Socket($this->logger, $this->socket)); + + /** @var Status $status */ + $status = wait($processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENT_HEADERS, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/Mail/ProcessMailFromTest.php b/tests/Unit/Transaction/Processor/Mail/ProcessMailFromTest.php new file mode 100644 index 0000000..45022a5 --- /dev/null +++ b/tests/Unit/Transaction/Processor/Mail/ProcessMailFromTest.php @@ -0,0 +1,138 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessMailFrom( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Address('from@example.com', 'From Example') + ); + } + + public function testProcessThrowsOnTransientError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("400 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("MAIL FROM: From Example\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidMailFromAddress::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessThrowsOnPermanentError(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("500 error\r\n")) + ; + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("MAIL FROM: From Example\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("QUIT\r\n", $data); + + return new Success(); + }) + ; + + $this->expectException(InvalidMailFromAddress::class); + + wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + } + + public function testProcessResultsInSendingRecipientsStatusWhenMailFromIsValid(): void + { + $this->smtpSocket + ->expects($this->once()) + ->method('read') + ->willReturn(new Success("200 success\r\n")) + ; + + $this->socket + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("MAIL FROM: From Example\r\n", $data); + + return new Success(); + }) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENDING_RECIPIENTS, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/Mail/ProcessRecipientsTest.php b/tests/Unit/Transaction/Processor/Mail/ProcessRecipientsTest.php new file mode 100644 index 0000000..09cf09a --- /dev/null +++ b/tests/Unit/Transaction/Processor/Mail/ProcessRecipientsTest.php @@ -0,0 +1,130 @@ +logger = new Output(new Level(Level::NONE)); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->socket = $this->createMock(ServerSocket::class); + $this->processor = new ProcessRecipients( + new Factory(), + $this->logger, + new Socket($this->logger, $this->socket), + new Address('to1@example.com', 'To One'), + new Address('to2@example.com', 'To Two'), + new Address('to3@example.com', 'To Three') + ); + + $this->socket + ->expects($this->at(0)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("RCPT TO: To One\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(1)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("RCPT TO: To Two\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("RCPT TO: To Three\r\n", $data); + + return new Success(); + }) + ; + } + + public function testProcessKeepsSendingRecipientsAfterTransientError(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200 success\r\n"), + new Success("400 error\r\n"), + new Success("200 success\r\n") + ) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENT_RECIPIENTS, $status->getValue()); + } + + public function testProcessKeepsSendingRecipientsAfterPermanentError(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200 success\r\n"), + new Success("500 error\r\n"), + new Success("200 success\r\n") + ) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENT_RECIPIENTS, $status->getValue()); + } + + public function testProcessSendsAllRecipientsWhenAllAreAccepted(): void + { + $this->smtpSocket + ->method('read') + ->willReturnOnConsecutiveCalls( + new Success("200 success\r\n"), + new Success("200 error\r\n"), + new Success("200 success\r\n") + ) + ; + + /** @var Status $status */ + $status = wait($this->processor->process(new Buffer($this->smtpSocket, $this->logger))); + + $this->assertSame(Status::SENT_RECIPIENTS, $status->getValue()); + } +} diff --git a/tests/Unit/Transaction/Processor/MailTest.php b/tests/Unit/Transaction/Processor/MailTest.php index 2b69d12..7d18a95 100644 --- a/tests/Unit/Transaction/Processor/MailTest.php +++ b/tests/Unit/Transaction/Processor/MailTest.php @@ -9,7 +9,7 @@ use HarmonyIO\SmtpClient\ClientAddress\Localhost; use HarmonyIO\SmtpClient\Envelop; use HarmonyIO\SmtpClient\Envelop\Address; -use HarmonyIO\SmtpClient\Exception\Smtp\InvalidMailFromAddress; +use HarmonyIO\SmtpClient\Envelop\Header; use HarmonyIO\SmtpClient\Log\Level; use HarmonyIO\SmtpClient\Log\Output; use HarmonyIO\SmtpClient\SmtpSocket; @@ -36,24 +36,28 @@ class MailTest extends TestCase // phpcs:ignore SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint public function setUp() { - $this->logger = new Output(new Level(Level::NONE)); - $this->socket = $this->createMock(ServerSocket::class); - $this->smtpSocket = $this->createMock(SmtpSocket::class); + $this->logger = new Output(new Level(Level::NONE)); + $this->socket = $this->createMock(ServerSocket::class); + $this->smtpSocket = $this->createMock(SmtpSocket::class); + + $envelop = (new Envelop( + new Localhost(), + new Envelop\Address('sender@example.com'), + new Address('receiver@example.com') + )) + ->addHeader(new Header('Foo', 'Bar')) + ->body('Example body') + ; $this->processor = new Mail( new Factory(), $this->logger, new Socket($this->logger, $this->socket), - new Envelop( - new Localhost(), - new Address('sender@example.com'), - new Address('receiver1@example.com'), - new Address('receiver2@example.com') - ) + $envelop ); } - public function testProcessSendMailFromFailsWithTransientNegativeCompletion(): void + public function testProcessProcessesEntireContent(): void { $this->socket ->expects($this->at(0)) @@ -69,39 +73,104 @@ public function testProcessSendMailFromFailsWithTransientNegativeCompletion(): v ->expects($this->at(1)) ->method('write') ->willReturnCallback(function (string $data) { - $this->assertSame("QUIT\r\n", $data); + $this->assertSame("RCPT TO:\r\n", $data); return new Success(); }) ; - $this->smtpSocket - ->expects($this->once()) - ->method('read') - ->willReturn(new Success("400 error\r\n")) + $this->socket + ->expects($this->at(2)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("DATA\r\n", $data); + + return new Success(); + }) ; - $buffer = new Buffer($this->smtpSocket, $this->logger); + $this->socket + ->expects($this->at(3)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertStringStartsWith('Message-ID:', $data); - $this->expectException(InvalidMailFromAddress::class); + return new Success(); + }) + ; - wait($this->processor->process($buffer)); - } + $this->socket + ->expects($this->at(4)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertStringStartsWith('Date:', $data); + + return new Success(); + }) + ; - public function testProcessSendMailFromFailsWithPermanentNegativeCompletion(): void - { $this->socket - ->expects($this->at(0)) + ->expects($this->at(5)) ->method('write') ->willReturnCallback(function (string $data) { - $this->assertSame("MAIL FROM:\r\n", $data); + $this->assertSame("From:\r\n", $data); return new Success(); }) ; $this->socket - ->expects($this->at(1)) + ->expects($this->at(6)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("To:\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(7)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Foo:Bar\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(8)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(9)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame("Example body\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(10)) + ->method('write') + ->willReturnCallback(function (string $data) { + $this->assertSame(".\r\n", $data); + + return new Success(); + }) + ; + + $this->socket + ->expects($this->at(11)) ->method('write') ->willReturnCallback(function (string $data) { $this->assertSame("QUIT\r\n", $data); @@ -111,15 +180,18 @@ public function testProcessSendMailFromFailsWithPermanentNegativeCompletion(): v ; $this->smtpSocket - ->expects($this->once()) ->method('read') - ->willReturn(new Success("500 error\r\n")) + ->willReturnOnConsecutiveCalls( + new Success("200 success\r\n"), + new Success("200 success\r\n"), + new Success("300 success\r\n"), + new Success("200 success\r\n"), + new Success("200 success\r\n") + ) ; $buffer = new Buffer($this->smtpSocket, $this->logger); - $this->expectException(InvalidMailFromAddress::class); - wait($this->processor->process($buffer)); } }