Files
Sebastian 67a0dbbee7 refactor: smtp client
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-23 16:51:54 -04:00

265 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<string> $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.');
}
}