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