Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
435
src/Framework/Mail/SmtpTransport.php
Normal file
435
src/Framework/Mail/SmtpTransport.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?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
|
||||
{
|
||||
$boundary = 'alt_' . uniqid();
|
||||
|
||||
$lines[] = 'MIME-Version: 1.0';
|
||||
|
||||
if ($message->hasAttachments()) {
|
||||
// Mixed with alternative inside
|
||||
$mixedBoundary = 'mixed_' . uniqid();
|
||||
$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
|
||||
{
|
||||
$boundary = 'mixed_' . uniqid();
|
||||
|
||||
$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
|
||||
{
|
||||
return uniqid() . '.' . 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
|
||||
return uniqid() . '@' . 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user