* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Smtp; use KTXM\ProviderImap\Smtp\Auth\AuthMechanism; use KTXM\ProviderImap\Smtp\Protocol\ProtocolWriter; use KTXM\ProviderImap\Smtp\Protocol\Response\Response; use KTXM\ProviderImap\Smtp\Protocol\ProtocolReader; 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 ?ProtocolReader $reader = null; private ?ProtocolWriter $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 ProtocolReader($connection, $this->logger); $this->writer = new ProtocolWriter($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 (Response $r): bool => $r->isPositiveCompletion(), 'MAIL FROM'); foreach ($recipients as $recipient) { $this->command( sprintf('RCPT TO:<%s>', $recipient), static fn (Response $r): bool => $r->isPositiveCompletion(), 'RCPT TO', ); } $this->command('DATA', static fn (Response $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 (Response $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 (Response $r): bool => $r->isPositiveCompletion(), 'AUTH PLAIN', true, ); } private function authLogin(string $username, string $password): void { $this->command('AUTH LOGIN', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'AUTH LOGIN'); $this->writer()->writeLine(base64_encode($username), true); $userResponse = $this->reader()->read(); if (!$userResponse->isPositiveIntermediate()) { throw new SmtpException('SMTP AUTH LOGIN username rejected: ' . $userResponse->text()); } $this->writer()->writeLine(base64_encode($password), true); $passResponse = $this->reader()->read(); if (!$passResponse->isPositiveCompletion()) { throw new SmtpException('SMTP authentication failed: ' . $passResponse->text()); } } /** * Issue a command line and assert the reply satisfies $accept. * * @param callable(Response):bool $accept */ private function command(string $line, callable $accept, string $label, bool $sensitive = false): Response { $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(): ProtocolReader { return $this->reader ?? throw new SmtpException('SMTP client is not connected.'); } private function writer(): ProtocolWriter { 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.'); } }