generated from Nodarx/template
feat: smtp sending
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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<string> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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<string,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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]';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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<string> $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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?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\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<string,list<string>> $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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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 RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP client exception.
|
||||||
|
*/
|
||||||
|
final class SmtpException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user