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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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;
}
}