diff --git a/lib/Smtp/Auth/AuthMechanism.php b/lib/Smtp/Auth/AuthMechanism.php new file mode 100644 index 0000000..58c914e --- /dev/null +++ b/lib/Smtp/Auth/AuthMechanism.php @@ -0,0 +1,44 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Auth; + +/** + * Supported SASL authentication mechanisms, in preference order. + */ +enum AuthMechanism: string +{ + case Plain = 'PLAIN'; + case Login = 'LOGIN'; + + /** + * Select the most preferred mechanism the server advertises. + * + * @param list $advertised mechanisms offered by the server (uppercased) + */ + public static function select(array $advertised): ?self + { + foreach ([self::Plain, self::Login] as $mechanism) { + if (in_array($mechanism->value, $advertised, true)) { + return $mechanism; + } + } + + return null; + } + + /** + * The initial AUTH response for the PLAIN mechanism: + * base64( authzid NUL authcid NUL passwd ). + */ + public static function plainToken(string $username, string $password): string + { + return base64_encode("\0" . $username . "\0" . $password); + } +} diff --git a/lib/Smtp/Client.php b/lib/Smtp/Client.php new file mode 100644 index 0000000..b546201 --- /dev/null +++ b/lib/Smtp/Client.php @@ -0,0 +1,264 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp; + +use KTXM\ProviderImap\Smtp\Auth\AuthMechanism; +use KTXM\ProviderImap\Smtp\Protocol\CommandWriter; +use KTXM\ProviderImap\Smtp\Protocol\Reply; +use KTXM\ProviderImap\Smtp\Protocol\ReplyReader; +use KTXM\ProviderImap\Smtp\Transport\ConnectionFactoryInterface; +use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface; +use KTXM\ProviderImap\Smtp\Transport\SocketConnectionFactory; +use Psr\Log\LoggerInterface; + +/** + * Minimal SMTP submission client. + * + * Implements the ESMTP submission handshake (RFC 5321 / RFC 4954): + * greeting → EHLO → [STARTTLS → EHLO] → [AUTH] → MAIL FROM → RCPT TO → DATA. + * + * The client carries opaque RFC822 bytes; message construction lives in the + * {@see \KTXM\ProviderImap\Mime\MessageBuilder}. + */ +final class Client +{ + private ?ConnectionInterface $connection = null; + private ?ReplyReader $reader = null; + private ?CommandWriter $writer = null; + private ?ConnectionConfig $config = null; + private ?SmtpCapabilities $capabilities = null; + + public function __construct( + private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(), + private readonly ?LoggerInterface $logger = null, + ) {} + + public function connect(ConnectionConfig $config): void + { + $connection = $this->connectionFactory->create($this->logger); + $connection->connect($config); + + $this->connection = $connection; + $this->config = $config; + $this->reader = new ReplyReader($connection, $this->logger); + $this->writer = new CommandWriter($connection, $this->logger); + + // Server greeting. + $greeting = $this->reader()->read(); + if (!$greeting->isPositiveCompletion()) { + throw new SmtpException('SMTP server rejected the connection: ' . $greeting->text()); + } + + $this->capabilities = $this->ehlo(); + + if ($config->security() === ConnectionSecurity::StartTls) { + $this->startTls(); + $this->capabilities = $this->ehlo(); + } + + if ($config->hasCredentials()) { + $this->authenticate(); + } + } + + public function capabilities(): SmtpCapabilities + { + return $this->caps(); + } + + public function isConnected(): bool + { + return $this->connection !== null && $this->connection->isConnected(); + } + + /** + * Submit a message envelope and its raw RFC822 body. + * + * @param string $from envelope sender (bare address) + * @param list $recipients envelope recipients (bare addresses, incl. Bcc) + * @param string $rawMessage complete RFC822 message bytes + * + * @return string the queue/transport id reported by the server, when available + */ + public function send(string $from, array $recipients, string $rawMessage): string + { + if ($recipients === []) { + throw new SmtpException('Cannot send a message without recipients.'); + } + + $mailFrom = sprintf('MAIL FROM:<%s>', $from); + if (($max = $this->caps()->maxSize()) !== null) { + $size = strlen($rawMessage); + if ($size > $max) { + throw new SmtpException(sprintf('Message size %d exceeds server limit %d.', $size, $max)); + } + $mailFrom .= ' SIZE=' . $size; + } + $this->command($mailFrom, static fn (Reply $r): bool => $r->isPositiveCompletion(), 'MAIL FROM'); + + foreach ($recipients as $recipient) { + $this->command( + sprintf('RCPT TO:<%s>', $recipient), + static fn (Reply $r): bool => $r->isPositiveCompletion(), + 'RCPT TO', + ); + } + + $this->command('DATA', static fn (Reply $r): bool => $r->isPositiveIntermediate(), 'DATA'); + + $this->writer()->writeRaw($this->dotStuff($rawMessage) . "\r\n.\r\n"); + $reply = $this->reader()->read(); + if (!$reply->isPositiveCompletion()) { + throw new SmtpException('SMTP DATA delivery failed: ' . $reply->text()); + } + + return $reply->text(); + } + + public function quit(): void + { + if ($this->connection === null) { + return; + } + + try { + $this->writer?->writeLine('QUIT'); + $this->reader?->read(); + } catch (SmtpException) { + // Best-effort: the peer may already have closed the stream. + } finally { + $this->connection->disconnect(); + $this->connection = null; + $this->reader = null; + $this->writer = null; + $this->capabilities = null; + $this->config = null; + } + } + + private function ehlo(): SmtpCapabilities + { + $this->writer()->writeLine('EHLO ' . $this->config()->ehloDomain()); + $reply = $this->reader()->read(); + + if (!$reply->isPositiveCompletion()) { + throw new SmtpException('EHLO was rejected: ' . $reply->text()); + } + + return SmtpCapabilities::fromEhlo($reply); + } + + private function startTls(): void + { + if (!$this->caps()->supportsStartTls()) { + throw new SmtpException('SMTP server does not advertise STARTTLS.'); + } + + $this->command('STARTTLS', static fn (Reply $r): bool => $r->isPositiveCompletion(), 'STARTTLS'); + $this->connection()->upgradeToTls(); + } + + private function authenticate(): void + { + $username = $this->config()->username() ?? ''; + $password = $this->config()->password() ?? ''; + + $mechanism = AuthMechanism::select($this->caps()->authMechanisms()); + if ($mechanism === null) { + throw new SmtpException('No supported SMTP AUTH mechanism is available.'); + } + + match ($mechanism) { + AuthMechanism::Plain => $this->authPlain($username, $password), + AuthMechanism::Login => $this->authLogin($username, $password), + }; + } + + private function authPlain(string $username, string $password): void + { + $this->command( + 'AUTH PLAIN ' . AuthMechanism::plainToken($username, $password), + static fn (Reply $r): bool => $r->isPositiveCompletion(), + 'AUTH PLAIN', + true, + ); + } + + private function authLogin(string $username, string $password): void + { + $this->command('AUTH LOGIN', static fn (Reply $r): bool => $r->isPositiveIntermediate(), 'AUTH LOGIN'); + + $this->writer()->writeLine(base64_encode($username), true); + $userReply = $this->reader()->read(); + if (!$userReply->isPositiveIntermediate()) { + throw new SmtpException('SMTP AUTH LOGIN username rejected: ' . $userReply->text()); + } + + $this->writer()->writeLine(base64_encode($password), true); + $passReply = $this->reader()->read(); + if (!$passReply->isPositiveCompletion()) { + throw new SmtpException('SMTP authentication failed: ' . $passReply->text()); + } + } + + /** + * Issue a command line and assert the reply satisfies $accept. + * + * @param callable(Reply):bool $accept + */ + private function command(string $line, callable $accept, string $label, bool $sensitive = false): Reply + { + $this->writer()->writeLine($line, $sensitive); + $reply = $this->reader()->read(); + + if (!$accept($reply)) { + throw new SmtpException(sprintf('SMTP %s failed (%d): %s', $label, $reply->code(), $reply->text())); + } + + return $reply; + } + + /** + * Dot-stuffing per RFC 5321 §4.5.2: any line beginning with a period gets + * an extra leading period so it is not mistaken for the end-of-data marker. + * Line endings are normalised to CRLF. + */ + private function dotStuff(string $message): string + { + $normalized = preg_replace('/\r\n|\r|\n/', "\r\n", $message) ?? $message; + + return preg_replace('/^\./m', '..', $normalized) ?? $normalized; + } + + private function connection(): ConnectionInterface + { + return $this->connection ?? throw new SmtpException('SMTP client is not connected.'); + } + + private function reader(): ReplyReader + { + return $this->reader ?? throw new SmtpException('SMTP client is not connected.'); + } + + private function writer(): CommandWriter + { + return $this->writer ?? throw new SmtpException('SMTP client is not connected.'); + } + + private function config(): ConnectionConfig + { + return $this->config ?? throw new SmtpException('SMTP client is not connected.'); + } + + private function caps(): SmtpCapabilities + { + return $this->capabilities ?? throw new SmtpException('SMTP client is not connected.'); + } +} diff --git a/lib/Smtp/ConnectionConfig.php b/lib/Smtp/ConnectionConfig.php new file mode 100644 index 0000000..b280b08 --- /dev/null +++ b/lib/Smtp/ConnectionConfig.php @@ -0,0 +1,113 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp; + +final class ConnectionConfig +{ + public function __construct( + private readonly string $host, + private readonly int $port = 587, + private readonly ConnectionSecurity $security = ConnectionSecurity::StartTls, + private readonly ?string $username = null, + private readonly ?string $password = null, + private readonly float $timeout = 30.0, + private readonly bool $verifyPeer = true, + private readonly bool $verifyPeerName = true, + private readonly bool $allowSelfSigned = false, + private readonly ?string $ehloDomain = null, + ) {} + + public function host(): string + { + return $this->host; + } + + public function port(): int + { + return $this->port; + } + + public function security(): ConnectionSecurity + { + return $this->security; + } + + public function username(): ?string + { + return $this->username; + } + + public function password(): ?string + { + return $this->password; + } + + public function hasCredentials(): bool + { + return $this->username !== null && $this->username !== '' + && $this->password !== null && $this->password !== ''; + } + + public function timeout(): float + { + return $this->timeout; + } + + public function verifyPeer(): bool + { + return $this->verifyPeer; + } + + public function verifyPeerName(): bool + { + return $this->verifyPeerName; + } + + public function allowSelfSigned(): bool + { + return $this->allowSelfSigned; + } + + /** + * The domain announced in the EHLO command. Falls back to the local + * hostname, then to a literal so a value is always sent. + */ + public function ehloDomain(): string + { + if ($this->ehloDomain !== null && $this->ehloDomain !== '') { + return $this->ehloDomain; + } + + $hostname = gethostname(); + + return $hostname !== false && $hostname !== '' ? $hostname : 'localhost'; + } + + public function endpoint(): string + { + return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port); + } + + /** + * @return array> + */ + public function streamContextOptions(): array + { + return [ + 'ssl' => [ + 'verify_peer' => $this->verifyPeer, + 'verify_peer_name' => $this->verifyPeerName, + 'allow_self_signed' => $this->allowSelfSigned, + 'SNI_enabled' => true, + 'peer_name' => $this->host, + ], + ]; + } +} diff --git a/lib/Smtp/ConnectionSecurity.php b/lib/Smtp/ConnectionSecurity.php new file mode 100644 index 0000000..0e96f98 --- /dev/null +++ b/lib/Smtp/ConnectionSecurity.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp; + +enum ConnectionSecurity: string +{ + case Plain = 'plain'; + case Tls = 'tls'; // implicit TLS (SMTPS, port 465) + case StartTls = 'starttls'; + + public function transport(): string + { + return $this === self::Tls ? 'ssl' : 'tcp'; + } +} diff --git a/lib/Smtp/Protocol/CommandWriter.php b/lib/Smtp/Protocol/CommandWriter.php new file mode 100644 index 0000000..440bc1a --- /dev/null +++ b/lib/Smtp/Protocol/CommandWriter.php @@ -0,0 +1,53 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Protocol; + +use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface; +use Psr\Log\LoggerInterface; + +final class CommandWriter +{ + public function __construct( + private readonly ConnectionInterface $connection, + private readonly ?LoggerInterface $logger = null, + ) {} + + /** + * Write a single command line, appending the CRLF terminator. + * + * @param bool $sensitive when true the payload is redacted from logs + * (used for AUTH credential exchanges) + */ + public function writeLine(string $line, bool $sensitive = false): void + { + $this->logger?->debug('SMTP command sent: {command}', [ + 'command' => $sensitive ? $this->redact($line) : $line, + ]); + + $this->connection->write($line . "\r\n"); + } + + /** + * Write raw bytes verbatim (e.g. the DATA payload). Never logged. + */ + public function writeRaw(string $payload): void + { + $this->connection->write($payload); + } + + private function redact(string $line): string + { + if (preg_match('/^(AUTH\s+\S+)(\s+.*)?$/i', $line, $matches) === 1) { + return $matches[1] . (isset($matches[2]) ? ' [REDACTED]' : ''); + } + + return '[REDACTED]'; + } +} diff --git a/lib/Smtp/Protocol/Reply.php b/lib/Smtp/Protocol/Reply.php new file mode 100644 index 0000000..03f3305 --- /dev/null +++ b/lib/Smtp/Protocol/Reply.php @@ -0,0 +1,74 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Protocol; + +/** + * A parsed SMTP reply: a 3-digit status code plus one or more text lines. + */ +final class Reply +{ + /** + * @param int $code 3-digit SMTP status code + * @param list $lines text portion of each reply line (code/separator stripped) + * @param string $raw the raw wire text (for diagnostics) + */ + public function __construct( + private readonly int $code, + private readonly array $lines, + private readonly string $raw, + ) {} + + public function code(): int + { + return $this->code; + } + + /** + * @return list + */ + public function lines(): array + { + return $this->lines; + } + + public function text(): string + { + return implode(' ', $this->lines); + } + + public function raw(): string + { + return $this->raw; + } + + /** 2xx — requested action completed. */ + public function isPositiveCompletion(): bool + { + return $this->code >= 200 && $this->code < 300; + } + + /** 3xx — more input required (e.g. DATA, AUTH challenge). */ + public function isPositiveIntermediate(): bool + { + return $this->code >= 300 && $this->code < 400; + } + + /** 4xx — transient negative completion (try again later). */ + public function isTransientError(): bool + { + return $this->code >= 400 && $this->code < 500; + } + + /** 5xx — permanent negative completion. */ + public function isPermanentError(): bool + { + return $this->code >= 500; + } +} diff --git a/lib/Smtp/Protocol/ReplyReader.php b/lib/Smtp/Protocol/ReplyReader.php new file mode 100644 index 0000000..db9f37c --- /dev/null +++ b/lib/Smtp/Protocol/ReplyReader.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Protocol; + +use KTXM\ProviderImap\Smtp\SmtpException; +use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface; +use Psr\Log\LoggerInterface; + +/** + * Reads SMTP replies, assembling multi-line responses. + * + * A multi-line reply repeats the status code on every line, using a hyphen + * ("250-text") to indicate continuation and a space ("250 text") on the final + * line. See RFC 5321 §4.2. + */ +final class ReplyReader +{ + public function __construct( + private readonly ConnectionInterface $connection, + private readonly ?LoggerInterface $logger = null, + ) {} + + public function read(): Reply + { + $lines = []; + $raw = ''; + $code = null; + + while (true) { + $rawLine = $this->connection->readLine(); + $raw .= $rawLine; + $trimmed = rtrim($rawLine, "\r\n"); + + if (!preg_match('/^(\d{3})([ -]?)(.*)$/', $trimmed, $matches)) { + throw new SmtpException(sprintf('Malformed SMTP reply line: %s', $trimmed)); + } + + $lineCode = (int) $matches[1]; + $separator = $matches[2]; + $lines[] = $matches[3]; + + if ($code === null) { + $code = $lineCode; + } elseif ($lineCode !== $code) { + throw new SmtpException(sprintf('Inconsistent SMTP reply code: %s', $trimmed)); + } + + // A space separator (or no separator) marks the final line. + if ($separator !== '-') { + break; + } + } + + $this->logger?->debug('SMTP reply received: {raw}', [ + 'code' => $code, + 'raw' => rtrim($raw, "\r\n"), + ]); + + return new Reply($code ?? 0, $lines, rtrim($raw, "\r\n")); + } +} diff --git a/lib/Smtp/SmtpCapabilities.php b/lib/Smtp/SmtpCapabilities.php new file mode 100644 index 0000000..a28e535 --- /dev/null +++ b/lib/Smtp/SmtpCapabilities.php @@ -0,0 +1,72 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp; + +use KTXM\ProviderImap\Smtp\Protocol\Reply; + +/** + * Parsed EHLO capabilities. + * + * The first line of an EHLO reply is the server greeting/domain; every + * subsequent line is a capability keyword optionally followed by parameters + * (e.g. "AUTH PLAIN LOGIN", "SIZE 35882577"). + */ +final class SmtpCapabilities +{ + /** + * @param array> $keywords capability keyword => parameters + */ + private function __construct( + private readonly array $keywords, + ) {} + + public static function fromEhlo(Reply $reply): self + { + $keywords = []; + $lines = $reply->lines(); + + // Skip the first line (greeting/domain); the rest are capabilities. + foreach (array_slice($lines, 1) as $line) { + $parts = preg_split('/\s+/', trim($line)) ?: []; + $keyword = strtoupper((string) array_shift($parts)); + if ($keyword === '') { + continue; + } + $keywords[$keyword] = array_map('strtoupper', $parts); + } + + return new self($keywords); + } + + public function has(string $keyword): bool + { + return isset($this->keywords[strtoupper($keyword)]); + } + + public function supportsStartTls(): bool + { + return $this->has('STARTTLS'); + } + + /** + * @return list Advertised AUTH mechanisms (uppercased). + */ + public function authMechanisms(): array + { + return $this->keywords['AUTH'] ?? []; + } + + public function maxSize(): ?int + { + $size = $this->keywords['SIZE'][0] ?? null; + + return $size !== null && ctype_digit($size) ? (int) $size : null; + } +} diff --git a/lib/Smtp/SmtpException.php b/lib/Smtp/SmtpException.php new file mode 100644 index 0000000..deba425 --- /dev/null +++ b/lib/Smtp/SmtpException.php @@ -0,0 +1,19 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp; + +use RuntimeException; + +/** + * SMTP client exception. + */ +final class SmtpException extends RuntimeException +{ +} diff --git a/lib/Smtp/Transport/ConnectionFactoryInterface.php b/lib/Smtp/Transport/ConnectionFactoryInterface.php new file mode 100644 index 0000000..a2aff4c --- /dev/null +++ b/lib/Smtp/Transport/ConnectionFactoryInterface.php @@ -0,0 +1,17 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Transport; + +use Psr\Log\LoggerInterface; + +interface ConnectionFactoryInterface +{ + public function create(?LoggerInterface $logger = null): ConnectionInterface; +} diff --git a/lib/Smtp/Transport/ConnectionInterface.php b/lib/Smtp/Transport/ConnectionInterface.php new file mode 100644 index 0000000..ca5a08f --- /dev/null +++ b/lib/Smtp/Transport/ConnectionInterface.php @@ -0,0 +1,27 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Transport; + +use KTXM\ProviderImap\Smtp\ConnectionConfig; + +interface ConnectionInterface +{ + public function connect(ConnectionConfig $config): void; + + public function disconnect(): void; + + public function isConnected(): bool; + + public function write(string $payload): void; + + public function readLine(): string; + + public function upgradeToTls(): void; +} diff --git a/lib/Smtp/Transport/SocketConnection.php b/lib/Smtp/Transport/SocketConnection.php new file mode 100644 index 0000000..a371b7d --- /dev/null +++ b/lib/Smtp/Transport/SocketConnection.php @@ -0,0 +1,136 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Transport; + +use KTXM\ProviderImap\Smtp\ConnectionConfig; +use KTXM\ProviderImap\Smtp\SmtpException; +use Psr\Log\LoggerInterface; + +final class SocketConnection implements ConnectionInterface +{ + /** @var resource|null */ + private $stream = null; + + public function __construct( + private readonly ?LoggerInterface $logger = null, + ) {} + + public function connect(ConnectionConfig $config): void + { + if ($this->isConnected()) { + return; + } + + $context = stream_context_create($config->streamContextOptions()); + $errorCode = 0; + $errorMessage = ''; + + $stream = @stream_socket_client( + $config->endpoint(), + $errorCode, + $errorMessage, + $config->timeout(), + STREAM_CLIENT_CONNECT, + $context, + ); + + if (!is_resource($stream)) { + $this->logger?->error('SMTP socket connection failed', [ + 'endpoint' => $config->endpoint(), + 'code' => $errorCode, + 'message' => $errorMessage ?: 'unknown error', + ]); + + throw new SmtpException(sprintf( + 'Unable to connect to %s (%d: %s)', + $config->endpoint(), + $errorCode, + $errorMessage ?: 'unknown error', + )); + } + + stream_set_timeout($stream, (int) $config->timeout()); + $this->stream = $stream; + + $this->logger?->info('SMTP socket connected to {endpoint} (timeout={timeout})', [ + 'endpoint' => $config->endpoint(), + 'timeout' => $config->timeout(), + ]); + } + + public function disconnect(): void + { + if (!is_resource($this->stream)) { + return; + } + + fclose($this->stream); + $this->stream = null; + + $this->logger?->info('SMTP socket disconnected'); + } + + public function isConnected(): bool + { + return is_resource($this->stream); + } + + public function write(string $payload): void + { + $stream = $this->stream(); + $written = fwrite($stream, $payload); + + if ($written === false || $written !== strlen($payload)) { + $this->logger?->error('SMTP socket write failed (bytes={bytes})', [ + 'bytes' => strlen($payload), + ]); + + throw new SmtpException('Failed to write complete payload to SMTP socket.'); + } + } + + public function readLine(): string + { + $stream = $this->stream(); + $line = fgets($stream); + + if ($line === false) { + $this->logger?->error('SMTP socket read failed'); + throw new SmtpException('Failed to read line from SMTP socket.'); + } + + return $line; + } + + public function upgradeToTls(): void + { + $stream = $this->stream(); + $result = stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($result !== true) { + $this->logger?->error('SMTP TLS upgrade failed'); + throw new SmtpException('Failed to enable TLS on SMTP socket.'); + } + + $this->logger?->info('SMTP socket upgraded to TLS'); + } + + /** + * @return resource + */ + private function stream() + { + if (!is_resource($this->stream)) { + throw new SmtpException('SMTP socket is not connected.'); + } + + return $this->stream; + } +} diff --git a/lib/Smtp/Transport/SocketConnectionFactory.php b/lib/Smtp/Transport/SocketConnectionFactory.php new file mode 100644 index 0000000..a60417b --- /dev/null +++ b/lib/Smtp/Transport/SocketConnectionFactory.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Smtp\Transport; + +use Psr\Log\LoggerInterface; + +final class SocketConnectionFactory implements ConnectionFactoryInterface +{ + public function create(?LoggerInterface $logger = null): ConnectionInterface + { + return new SocketConnection($logger); + } +}