generated from Nodarx/template
67a0dbbee7
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
265 lines
8.8 KiB
PHP
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.');
|
|
}
|
|
}
|