generated from Nodarx/template
feat: smtp sending
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
<?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\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<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 (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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user