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