From ad0425079d7d1109ab81af0373f952a8fbdc9de6 Mon Sep 17 00:00:00 2001 From: Tristan Pouliquen Date: Fri, 2 Oct 2026 11:58:22 +0200 Subject: [PATCH] Make the SMTP connect timeout of GenericProviderClient configurable Co-Authored-By: Claude Opus 5.5 --- src/ProviderClient/GenericProviderClient.php | 17 ++- .../GenericProviderClientTest.php | 100 ++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 tests/ProviderClient/GenericProviderClientTest.php diff --git a/src/ProviderClient/GenericProviderClient.php b/src/ProviderClient/GenericProviderClient.php index 1ce9588..c74930d 100644 --- a/src/ProviderClient/GenericProviderClient.php +++ b/src/ProviderClient/GenericProviderClient.php @@ -19,6 +19,11 @@ class GenericProviderClient { + /** + * Seconds to wait for an MX server to accept the TCP connection + */ + public const int DEFAULT_CONNECT_TIMEOUT = 30; + /** * Host domain to use to connect to the MX servers * Warning: some MX servers require the domain to point to an IP with a valid reverse DNS record @@ -30,7 +35,8 @@ public function __construct( private readonly BounceIsCausedByUnknownUserSpecification $bounceIsCausedByUnknownUserSpecification, private readonly BounceIsCausedByInactiveUserSpecification $bounceIsCausedByInactiveUserSpecification, private readonly BounceTypeResolver $bounceTypeResolver, - private readonly string $host + private readonly string $host, + private readonly int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT ) { } @@ -39,11 +45,11 @@ public function __construct( */ public function check(string $email, string $mxServer): ValidationStatusDtoInterface { - $connection = new SMTP(); + $connection = $this->createConnection(); $connection->Debugoutput = $this->logger; try { // Based on https://github.com/PHPMailer/PHPMailer/blob/master/examples/smtp_check.phps - if (!$connection->connect($mxServer)) { + if (!$connection->connect($mxServer, null, $this->connectTimeout)) { throw new SmtpConnectionRuntimeException( sprintf('Failed to connect to server: %s', $mxServer), 0, @@ -144,4 +150,9 @@ public function check(string $email, string $mxServer): ValidationStatusDtoInter throw $exception; } } + + protected function createConnection(): SMTP + { + return new SMTP(); + } } diff --git a/tests/ProviderClient/GenericProviderClientTest.php b/tests/ProviderClient/GenericProviderClientTest.php new file mode 100644 index 0000000..bf5efd2 --- /dev/null +++ b/tests/ProviderClient/GenericProviderClientTest.php @@ -0,0 +1,100 @@ +connectTimeoutUsedBy(null), + ); + } + + public function testConnectionUsesTheConfiguredTimeout(): void + { + self::assertSame(5, $this->connectTimeoutUsedBy(5)); + } + + private function connectTimeoutUsedBy(?int $connectTimeout): ?int + { + $connection = new class () extends SMTP { + private ?int $timeout = null; + + /** + * @param string $host + * @param int|null $port + * @param int $timeout + * @param array $options + */ + public function connect($host, $port = null, $timeout = 30, $options = []): bool + { + $this->timeout = $timeout; + + return false; + } + + public function getConnectTimeout(): ?int + { + return $this->timeout; + } + }; + + $client = new class ($connection, $connectTimeout) extends GenericProviderClient { + public function __construct(private readonly SMTP $connection, ?int $connectTimeout) + { + $logger = new NullLogger(); + $temporaryFailure = new ExceptionComesFromTemporaryFailureSpecification(); + $unknownUser = new BounceIsCausedByUnknownUserSpecification(); + $inactiveUser = new BounceIsCausedByInactiveUserSpecification(); + $bounceTypeResolver = new BounceTypeResolver(); + + if (null === $connectTimeout) { + parent::__construct( + $logger, + $temporaryFailure, + $unknownUser, + $inactiveUser, + $bounceTypeResolver, + 'hello.org', + ); + } else { + parent::__construct( + $logger, + $temporaryFailure, + $unknownUser, + $inactiveUser, + $bounceTypeResolver, + 'hello.org', + $connectTimeout, + ); + } + } + + protected function createConnection(): SMTP + { + return $this->connection; + } + }; + + try { + $client->check('john@example.org', 'mx.example.org'); + } catch (\Exception) { + // A refused connection always ends in an exception; only the timeout passed to connect() matters here + } + + return $connection->getConnectTimeout(); + } +}