440 lines
14 KiB
PHP
440 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Mail;
|
|
|
|
use App\Framework\Mail\Exceptions\SmtpException;
|
|
|
|
final class SmtpTransport implements TransportInterface
|
|
{
|
|
private $socket = null;
|
|
|
|
private bool $connected = false;
|
|
|
|
public function __construct(
|
|
private readonly SmtpConfig $config,
|
|
) {
|
|
}
|
|
|
|
public function send(Message $message): TransportResult
|
|
{
|
|
try {
|
|
$this->connect();
|
|
$this->authenticate();
|
|
|
|
$messageId = $this->sendMessage($message);
|
|
|
|
return TransportResult::success($messageId);
|
|
|
|
} catch (SmtpException $e) {
|
|
return TransportResult::failure(
|
|
error: $e->getMessage(),
|
|
metadata: $e->getContext()->toArray()
|
|
);
|
|
} catch (\Exception $e) {
|
|
return TransportResult::failure($e->getMessage());
|
|
} finally {
|
|
$this->disconnect();
|
|
}
|
|
}
|
|
|
|
public function isAvailable(): bool
|
|
{
|
|
try {
|
|
$this->connect();
|
|
$this->disconnect();
|
|
|
|
return true;
|
|
} catch (\Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getName(): string
|
|
{
|
|
return 'SMTP Transport (' . $this->config->host . ':' . $this->config->port . ')';
|
|
}
|
|
|
|
private function connect(): void
|
|
{
|
|
if ($this->connected) {
|
|
return;
|
|
}
|
|
|
|
$context = stream_context_create([
|
|
'ssl' => [
|
|
'verify_peer' => $this->config->verifyPeer,
|
|
'verify_peer_name' => $this->config->verifyPeer,
|
|
],
|
|
]);
|
|
|
|
$connectionString = $this->config->getConnectionString();
|
|
|
|
$this->socket = stream_socket_client(
|
|
$connectionString,
|
|
$errno,
|
|
$errstr,
|
|
$this->config->timeout,
|
|
STREAM_CLIENT_CONNECT,
|
|
$context
|
|
);
|
|
|
|
if (! $this->socket) {
|
|
throw SmtpException::connectionFailed($this->config->host, $this->config->port, "$errstr ($errno)");
|
|
}
|
|
|
|
stream_set_timeout($this->socket, $this->config->timeout);
|
|
|
|
// Read greeting
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '220')) {
|
|
throw SmtpException::commandFailed('GREETING', $response);
|
|
}
|
|
|
|
// Send EHLO
|
|
$this->sendCommand('EHLO ' . gethostname());
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '250')) {
|
|
throw SmtpException::commandFailed('EHLO', $response);
|
|
}
|
|
|
|
// Start TLS if needed
|
|
if ($this->config->encryption === 'tls') {
|
|
$this->sendCommand('STARTTLS');
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '220')) {
|
|
throw SmtpException::commandFailed('STARTTLS', $response);
|
|
}
|
|
|
|
if (! stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
throw SmtpException::tlsFailed();
|
|
}
|
|
|
|
// Send EHLO again after TLS
|
|
$this->sendCommand('EHLO ' . gethostname());
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '250')) {
|
|
throw SmtpException::commandFailed('EHLO_AFTER_TLS', $response);
|
|
}
|
|
}
|
|
|
|
$this->connected = true;
|
|
}
|
|
|
|
private function authenticate(): void
|
|
{
|
|
if (! $this->config->requiresAuthentication()) {
|
|
return;
|
|
}
|
|
|
|
$this->sendCommand('AUTH LOGIN');
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '334')) {
|
|
throw SmtpException::authenticationFailed($response);
|
|
}
|
|
|
|
$this->sendCommand(base64_encode($this->config->username));
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '334')) {
|
|
throw SmtpException::authenticationFailed($response);
|
|
}
|
|
|
|
$this->sendCommand(base64_encode($this->config->password));
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '235')) {
|
|
throw SmtpException::authenticationFailed($response);
|
|
}
|
|
}
|
|
|
|
private function sendMessage(Message $message): string
|
|
{
|
|
// MAIL FROM
|
|
$this->sendCommand('MAIL FROM:<' . $message->from->value . '>');
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '250')) {
|
|
throw SmtpException::mailFromFailed($message->from->value, $response);
|
|
}
|
|
|
|
// RCPT TO
|
|
foreach ($message->getAllRecipients() as $recipient) {
|
|
$this->sendCommand('RCPT TO:<' . $recipient->value . '>');
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '250')) {
|
|
throw SmtpException::recipientFailed($recipient->value, $response);
|
|
}
|
|
}
|
|
|
|
// DATA
|
|
$this->sendCommand('DATA');
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '354')) {
|
|
throw SmtpException::dataCommandFailed($response);
|
|
}
|
|
|
|
// Send message content
|
|
$messageContent = $this->buildMessage($message);
|
|
$this->sendCommand($messageContent);
|
|
$this->sendCommand('.');
|
|
|
|
$response = $this->readResponse();
|
|
if (! str_starts_with($response, '250')) {
|
|
throw SmtpException::messageSendFailed($response);
|
|
}
|
|
|
|
// Extract message ID from response
|
|
$messageId = $this->extractMessageId($response);
|
|
|
|
return $messageId;
|
|
}
|
|
|
|
private function buildMessage(Message $message): string
|
|
{
|
|
$lines = [];
|
|
|
|
// Headers
|
|
$lines[] = 'From: ' . $this->encodeHeader($message->from->value);
|
|
$lines[] = 'To: ' . $this->encodeHeader($message->to->toString());
|
|
|
|
if ($message->cc->isNotEmpty()) {
|
|
$lines[] = 'Cc: ' . $this->encodeHeader($message->cc->toString());
|
|
}
|
|
|
|
if ($message->replyTo !== null) {
|
|
$lines[] = 'Reply-To: ' . $this->encodeHeader($message->replyTo->value);
|
|
}
|
|
|
|
$lines[] = 'Subject: ' . $this->encodeHeader($message->subject);
|
|
$lines[] = 'Date: ' . date('r');
|
|
$lines[] = 'Message-ID: <' . $this->generateMessageId() . '>';
|
|
$lines[] = 'X-Mailer: Custom PHP SMTP Transport';
|
|
|
|
// Priority
|
|
if ($message->priority !== Priority::NORMAL) {
|
|
$lines[] = 'X-Priority: ' . $message->priority->value;
|
|
$lines[] = 'Importance: ' . $message->priority->toImportanceValue();
|
|
}
|
|
|
|
// Custom headers
|
|
foreach ($message->headers as $name => $value) {
|
|
$lines[] = $this->sanitizeHeaderName($name) . ': ' . $this->encodeHeader($value);
|
|
}
|
|
|
|
// MIME structure
|
|
if ($message->hasHtmlBody() && ! empty($message->body)) {
|
|
// Both HTML and text - use multipart/alternative
|
|
return $this->buildMultipartAlternativeMessage($message, $lines);
|
|
} elseif ($message->hasAttachments()) {
|
|
// Has attachments - use multipart/mixed
|
|
return $this->buildMultipartMixedMessage($message, $lines);
|
|
} elseif ($message->hasHtmlBody()) {
|
|
// HTML only
|
|
return $this->buildHtmlMessage($message, $lines);
|
|
} else {
|
|
// Plain text only
|
|
return $this->buildTextMessage($message, $lines);
|
|
}
|
|
}
|
|
|
|
private function buildTextMessage(Message $message, array $lines): string
|
|
{
|
|
$lines[] = 'MIME-Version: 1.0';
|
|
$lines[] = 'Content-Type: text/plain; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->body;
|
|
|
|
return implode("\r\n", $lines);
|
|
}
|
|
|
|
private function buildHtmlMessage(Message $message, array $lines): string
|
|
{
|
|
$lines[] = 'MIME-Version: 1.0';
|
|
$lines[] = 'Content-Type: text/html; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->htmlBody;
|
|
|
|
return implode("\r\n", $lines);
|
|
}
|
|
|
|
private function buildMultipartAlternativeMessage(Message $message, array $lines): string
|
|
{
|
|
$generator = new \App\Framework\Id\Ulid\UlidGenerator();
|
|
$boundary = 'alt_' . $generator->generate();
|
|
|
|
$lines[] = 'MIME-Version: 1.0';
|
|
|
|
if ($message->hasAttachments()) {
|
|
// Mixed with alternative inside
|
|
$mixedBoundary = 'mixed_' . $generator->generate();
|
|
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $mixedBoundary . '"';
|
|
$lines[] = '';
|
|
$lines[] = '--' . $mixedBoundary;
|
|
$lines[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
|
|
$lines[] = '';
|
|
|
|
$this->addAlternativeParts($lines, $message, $boundary);
|
|
|
|
$this->addAttachments($lines, $message, $mixedBoundary);
|
|
$lines[] = '--' . $mixedBoundary . '--';
|
|
} else {
|
|
// Just alternative
|
|
$lines[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
|
|
$lines[] = '';
|
|
|
|
$this->addAlternativeParts($lines, $message, $boundary);
|
|
$lines[] = '--' . $boundary . '--';
|
|
}
|
|
|
|
return implode("\r\n", $lines);
|
|
}
|
|
|
|
private function buildMultipartMixedMessage(Message $message, array $lines): string
|
|
{
|
|
$generator = new \App\Framework\Id\Ulid\UlidGenerator();
|
|
$boundary = 'mixed_' . $generator->generate();
|
|
|
|
$lines[] = 'MIME-Version: 1.0';
|
|
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
|
|
$lines[] = '';
|
|
|
|
// Add main content
|
|
$lines[] = '--' . $boundary;
|
|
if ($message->hasHtmlBody()) {
|
|
$lines[] = 'Content-Type: text/html; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->htmlBody;
|
|
} else {
|
|
$lines[] = 'Content-Type: text/plain; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->body;
|
|
}
|
|
|
|
$this->addAttachments($lines, $message, $boundary);
|
|
$lines[] = '--' . $boundary . '--';
|
|
|
|
return implode("\r\n", $lines);
|
|
}
|
|
|
|
private function addAlternativeParts(array &$lines, Message $message, string $boundary): void
|
|
{
|
|
// Text part
|
|
$lines[] = '--' . $boundary;
|
|
$lines[] = 'Content-Type: text/plain; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->body;
|
|
$lines[] = '';
|
|
|
|
// HTML part
|
|
$lines[] = '--' . $boundary;
|
|
$lines[] = 'Content-Type: text/html; charset=UTF-8';
|
|
$lines[] = 'Content-Transfer-Encoding: 8bit';
|
|
$lines[] = '';
|
|
$lines[] = $message->htmlBody;
|
|
$lines[] = '';
|
|
$lines[] = '--' . $boundary . '--';
|
|
}
|
|
|
|
private function addAttachments(array &$lines, Message $message, string $boundary): void
|
|
{
|
|
foreach ($message->attachments as $attachment) {
|
|
$lines[] = '';
|
|
$lines[] = '--' . $boundary;
|
|
$lines[] = 'Content-Type: ' . $attachment->mimeType->value . '; name="' . $this->encodeHeader($attachment->name) . '"';
|
|
$lines[] = 'Content-Transfer-Encoding: base64';
|
|
|
|
if ($attachment->isInline && $attachment->contentId) {
|
|
$lines[] = 'Content-Disposition: inline; filename="' . $this->encodeHeader($attachment->name) . '"';
|
|
$lines[] = 'Content-ID: <' . $attachment->contentId . '>';
|
|
} else {
|
|
$lines[] = 'Content-Disposition: attachment; filename="' . $this->encodeHeader($attachment->name) . '"';
|
|
}
|
|
|
|
$lines[] = '';
|
|
$lines[] = chunk_split($attachment->getBase64Content(), 76, "\r\n");
|
|
}
|
|
}
|
|
|
|
private function encodeHeader(string $value): string
|
|
{
|
|
// RFC 2047 header encoding for non-ASCII characters
|
|
if (mb_check_encoding($value, 'ASCII')) {
|
|
return $value;
|
|
}
|
|
|
|
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
|
}
|
|
|
|
private function sanitizeHeaderName(string $name): string
|
|
{
|
|
// Remove any potentially dangerous characters from header names
|
|
return preg_replace('/[^\w-]/', '', $name);
|
|
}
|
|
|
|
private function generateMessageId(): string
|
|
{
|
|
$generator = new \App\Framework\Id\Ulid\UlidGenerator();
|
|
return $generator->generate() . '.' . time() . '@' . gethostname();
|
|
}
|
|
|
|
private function sendCommand(string $command): void
|
|
{
|
|
if (! $this->socket) {
|
|
throw SmtpException::socketError($this->config->host, $this->config->port, 'sending command');
|
|
}
|
|
|
|
fwrite($this->socket, $command . "\r\n");
|
|
}
|
|
|
|
private function readResponse(): string
|
|
{
|
|
if (! $this->socket) {
|
|
throw SmtpException::socketError($this->config->host, $this->config->port, 'reading response');
|
|
}
|
|
|
|
$response = '';
|
|
while (($line = fgets($this->socket, 512)) !== false) {
|
|
$response .= $line;
|
|
if (substr($line, 3, 1) === ' ') {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return trim($response);
|
|
}
|
|
|
|
private function extractMessageId(string $response): string
|
|
{
|
|
// Try to extract message ID from SMTP response
|
|
if (preg_match('/id=([a-zA-Z0-9\-._]+)/', $response, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
// Fallback to generated ID
|
|
$generator = new \App\Framework\Id\Ulid\UlidGenerator();
|
|
return $generator->generate() . '@' . gethostname();
|
|
}
|
|
|
|
private function disconnect(): void
|
|
{
|
|
if (! $this->connected || ! $this->socket) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->sendCommand('QUIT');
|
|
$this->readResponse();
|
|
} catch (\Exception) {
|
|
// Ignore errors during disconnect
|
|
}
|
|
|
|
fclose($this->socket);
|
|
$this->socket = null;
|
|
$this->connected = false;
|
|
}
|
|
}
|