feat: smtp sending

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-23 16:29:16 -04:00
parent e031831719
commit ad6b7c5eba
13 changed files with 929 additions and 0 deletions
+53
View File
@@ -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]';
}
}
+74
View File
@@ -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;
}
}
+68
View File
@@ -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"));
}
}