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,102 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Filesystem\Storage;
use App\Framework\Http\MimeType;
final readonly class Attachment
{
private function __construct(
public string $content,
public string $name,
public MimeType $mimeType,
public bool $isInline = false,
public ?string $contentId = null,
) {
}
public static function fromFile(Storage $storage, string $filePath, ?string $name = null, ?MimeType $mimeType = null): self
{
if (! $storage->exists($filePath)) {
throw new \InvalidArgumentException("File not found: {$filePath}");
}
if (! $storage->isReadable($filePath)) {
throw new \InvalidArgumentException("File not readable: {$filePath}");
}
$content = $storage->get($filePath);
$name ??= basename($filePath);
// Try to determine mime type from file extension if not provided
if ($mimeType === null) {
$detectedMimeType = MimeType::fromFilePath($filePath);
$mimeType = $detectedMimeType ?? MimeType::APPLICATION_OCTET_STREAM;
}
return new self(
content: $content,
name: $name,
mimeType: $mimeType,
);
}
public static function fromData(string $data, string $name, MimeType $mimeType = MimeType::APPLICATION_OCTET_STREAM): self
{
return new self(
content: $data,
name: $name,
mimeType: $mimeType,
);
}
public static function inline(string $content, string $contentId, MimeType $mimeType): self
{
return new self(
content: $content,
name: $contentId,
mimeType: $mimeType,
isInline: true,
contentId: $contentId,
);
}
public function getBase64Content(): string
{
return base64_encode($this->content);
}
public function getSize(): Byte
{
return Byte::fromBytes(strlen($this->content));
}
public function getSizeFormatted(): string
{
return $this->getSize()->toHumanReadable();
}
public function isImage(): bool
{
return $this->mimeType->isImage();
}
public function isDocument(): bool
{
return $this->mimeType->isDocument();
}
public function isText(): bool
{
return $this->mimeType->isText();
}
public function isBinary(): bool
{
return $this->mimeType->isBinary();
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Commands;
use App\Framework\CommandBus\ShouldQueue;
use App\Framework\Mail\Message;
#[ShouldQueue]
final readonly class SendEmailBatchCommand
{
/**
* @param Message[] $messages
*/
public function __construct(
public array $messages,
public int $maxRetries = 3,
public int $delaySeconds = 0,
) {
// Validate that all items are Message instances
foreach ($messages as $message) {
if (! $message instanceof Message) {
throw new \InvalidArgumentException('All items must be Message instances');
}
}
}
public function getMaxRetries(): int
{
return $this->maxRetries;
}
public function getDelaySeconds(): int
{
return $this->delaySeconds;
}
public function getMessageCount(): int
{
return count($this->messages);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Commands;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Logging\Logger;
use App\Framework\Mail\Exceptions\SmtpException;
use App\Framework\Mail\TransportInterface;
final readonly class SendEmailBatchCommandHandler
{
public function __construct(
private TransportInterface $transport,
private Logger $logger,
) {
}
#[CommandHandler]
public function handle(SendEmailBatchCommand $command): void
{
$messageCount = $command->getMessageCount();
$successCount = 0;
$failureCount = 0;
$errors = [];
$this->logger->info('Starting batch email sending', [
'message_count' => $messageCount,
'transport' => $this->transport->getName(),
]);
foreach ($command->messages as $index => $message) {
try {
$result = $this->transport->send($message);
if ($result->isSuccess()) {
$successCount++;
$this->logger->debug('Batch email sent successfully', [
'batch_index' => $index,
'message_id' => $result->getMessageId(),
'to' => $message->to->toString(),
'subject' => $message->subject,
]);
} else {
$failureCount++;
$errorMessage = "Email {$index} failed: " . $result->getError();
$errors[] = $errorMessage;
$this->logger->warning('Batch email failed', [
'batch_index' => $index,
'error' => $result->getError(),
'metadata' => $result->getMetadata(),
'to' => $message->to->toString(),
'subject' => $message->subject,
]);
}
} catch (\Exception $e) {
$failureCount++;
$errorMessage = "Email {$index} exception: " . $e->getMessage();
$errors[] = $errorMessage;
$this->logger->error('Batch email exception', [
'batch_index' => $index,
'exception_class' => get_class($e),
'exception_message' => $e->getMessage(),
'to' => $message->to->toString(),
'subject' => $message->subject,
]);
}
}
$this->logger->info('Batch email sending completed', [
'total_messages' => $messageCount,
'successful' => $successCount,
'failed' => $failureCount,
'success_rate' => $messageCount > 0 ? round(($successCount / $messageCount) * 100, 2) : 0,
]);
// If all emails failed, throw an exception to trigger retry
if ($failureCount === $messageCount && $messageCount > 0) {
throw new SmtpException(
message: "All {$messageCount} emails in batch failed to send",
context: ExceptionContext::forOperation('send_email_batch', 'mail')
->withData([
'total_messages' => $messageCount,
'failed_messages' => $failureCount,
'errors' => $errors,
'transport' => $this->transport->getName(),
])
);
}
// If more than 50% failed, log a warning but don't throw (partial success)
if ($failureCount > ($messageCount / 2)) {
$this->logger->warning('High failure rate in batch email sending', [
'total_messages' => $messageCount,
'failed_messages' => $failureCount,
'failure_rate' => round(($failureCount / $messageCount) * 100, 2),
'errors' => $errors,
]);
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Commands;
use App\Framework\CommandBus\ShouldQueue;
use App\Framework\Mail\Message;
#[ShouldQueue]
final readonly class SendEmailCommand
{
public function __construct(
public Message $message,
public int $maxRetries = 3,
public int $delaySeconds = 0,
) {
}
public function getMaxRetries(): int
{
return $this->maxRetries;
}
public function getDelaySeconds(): int
{
return $this->delaySeconds;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Commands;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Logging\Logger;
use App\Framework\Mail\Exceptions\SmtpException;
use App\Framework\Mail\TransportInterface;
final readonly class SendEmailCommandHandler
{
public function __construct(
private TransportInterface $transport,
private Logger $logger,
) {
}
#[CommandHandler]
public function handle(SendEmailCommand $command): void
{
try {
$this->logger->info('Sending email via queue', [
'to' => $command->message->to->toString(),
'subject' => $command->message->subject,
'transport' => $this->transport->getName(),
]);
$result = $this->transport->send($command->message);
if ($result->isFailure()) {
$this->logger->error('Email sending failed', [
'error' => $result->getError(),
'metadata' => $result->getMetadata(),
'to' => $command->message->to->toString(),
'subject' => $command->message->subject,
]);
throw new SmtpException(
message: 'Failed to send email: ' . $result->getError(),
context: ExceptionContext::forOperation('send_email_command', 'mail')
->withData([
'transport_error' => $result->getError(),
'transport_metadata' => $result->getMetadata(),
'message_to' => $command->message->to->toString(),
'message_subject' => $command->message->subject,
])
);
}
$this->logger->info('Email sent successfully', [
'message_id' => $result->getMessageId(),
'to' => $command->message->to->toString(),
'subject' => $command->message->subject,
'transport' => $this->transport->getName(),
]);
} catch (SmtpException $e) {
$this->logger->error('SMTP exception during email sending', [
'exception' => $e->toArray(),
'to' => $command->message->to->toString(),
'subject' => $command->message->subject,
]);
// Re-throw to trigger queue retry logic
throw $e;
} catch (\Exception $e) {
$this->logger->error('Unexpected exception during email sending', [
'exception_class' => get_class($e),
'exception_message' => $e->getMessage(),
'to' => $command->message->to->toString(),
'subject' => $command->message->subject,
]);
// Wrap in SmtpException for consistent error handling
throw new SmtpException(
message: 'Unexpected error while sending email: ' . $e->getMessage(),
previous: $e,
context: ExceptionContext::forOperation('send_email_command', 'mail')
->withData([
'original_exception' => get_class($e),
'original_message' => $e->getMessage(),
'message_to' => $command->message->to->toString(),
'message_subject' => $command->message->subject,
])
);
}
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
use App\Domain\Common\ValueObject\Email;
use ArrayIterator;
final readonly class EmailList implements \Countable, \IteratorAggregate
{
/**
* @var Email[]
*/
private array $emails;
public function __construct(Email|string ...$emails)
{
$this->emails = array_values(array_map(
fn ($email) => $email instanceof Email ? $email : new Email($email),
$emails
));
}
public static function empty(): self
{
return new self();
}
public static function fromArray(array $emails): self
{
return new self(...$emails);
}
public function add(Email|string ...$emails): self
{
return new self(...$this->emails, ...$emails);
}
public function merge(self $other): self
{
return new self(...$this->emails, ...$other->emails);
}
public function unique(): self
{
$unique = [];
foreach ($this->emails as $email) {
$key = $email->value;
if (! isset($unique[$key])) {
$unique[$key] = $email;
}
}
return new self(...array_values($unique));
}
public function filter(callable $callback): self
{
return new self(...array_filter($this->emails, $callback));
}
public function map(callable $callback): array
{
return array_map($callback, $this->emails);
}
public function contains(Email|string $email): bool
{
$searchEmail = $email instanceof Email ? $email : new Email($email);
foreach ($this->emails as $existing) {
if ($existing->equals($searchEmail)) {
return true;
}
}
return false;
}
public function isEmpty(): bool
{
return empty($this->emails);
}
public function isNotEmpty(): bool
{
return ! $this->isEmpty();
}
public function first(): ?Email
{
return $this->emails[0] ?? null;
}
public function last(): ?Email
{
$count = count($this->emails);
return $count > 0 ? $this->emails[$count - 1] : null;
}
public function toArray(): array
{
return $this->emails;
}
public function toStringArray(): array
{
return array_map(fn (Email $email) => $email->value, $this->emails);
}
public function toString(string $separator = ', '): string
{
return implode($separator, $this->toStringArray());
}
public function count(): int
{
return count($this->emails);
}
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->emails);
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Examples;
use App\Domain\Common\ValueObject\Email;
use App\Framework\Mail\Attachment;
use App\Framework\Mail\EmailList;
use App\Framework\Mail\Mailer;
use App\Framework\Mail\Message;
use App\Framework\Mail\Priority;
/**
* Example usage of the Mailer system with queue integration
*
* This class demonstrates how to use the mailer for different scenarios:
* - Single email sending (sync)
* - Queue-based sending (async)
* - Batch operations
* - Different retry strategies
*/
final readonly class MailerUsageExample
{
public function __construct(
private Mailer $mailer,
) {
}
/**
* Send a simple transactional email immediately
*/
public function sendWelcomeEmail(Email $userEmail, string $userName): bool
{
$message = new Message(
from: new Email('noreply@example.com'),
subject: 'Welcome to our service!',
body: "Hello {$userName},\n\nWelcome to our service!",
htmlBody: "<h1>Hello {$userName}</h1><p>Welcome to our service!</p>",
to: new EmailList([$userEmail]),
);
// Send immediately (synchronous)
return $this->mailer->send($message);
}
/**
* Queue a password reset email with custom retry settings
*/
public function queuePasswordResetEmail(Email $userEmail, string $resetToken): bool
{
$message = new Message(
from: new Email('security@example.com'),
subject: 'Password Reset Request',
body: "Please use this token to reset your password: {$resetToken}",
htmlBody: "<p>Please use this token to reset your password:</p><code>{$resetToken}</code>",
to: new EmailList([$userEmail]),
priority: Priority::HIGH,
);
// Queue with higher retry count for important emails
return $this->mailer->queue($message, maxRetries: 5, delaySeconds: 30);
}
/**
* Send newsletter to multiple recipients using batch queue
*/
public function sendNewsletter(array $subscribers, string $subject, string $content): array
{
$messages = [];
foreach ($subscribers as $subscriber) {
$messages[] = new Message(
from: new Email('newsletter@example.com'),
subject: $subject,
body: strip_tags($content),
htmlBody: $content,
to: new EmailList([new Email($subscriber['email'])]),
priority: Priority::LOW,
);
}
// Use batch queue for better performance
return $this->mailer->queueBatch($messages, maxRetries: 2, delaySeconds: 60);
}
/**
* Send email with attachment
*/
public function sendInvoiceEmail(Email $customerEmail, string $invoicePath): bool
{
$attachment = Attachment::fromFile(
storage: app()->get('storage'), // Assuming storage is available via DI
filePath: $invoicePath,
name: 'invoice.pdf'
);
$message = new Message(
from: new Email('billing@example.com'),
subject: 'Your Invoice',
body: 'Please find your invoice attached.',
htmlBody: '<p>Please find your invoice attached.</p>',
to: new EmailList([$customerEmail]),
attachments: [$attachment],
priority: Priority::NORMAL,
);
// Send with moderate retry settings
return $this->mailer->queue($message, maxRetries: 3, delaySeconds: 0);
}
/**
* Send notification email with CC and BCC
*/
public function sendNotificationEmail(
Email $primaryRecipient,
array $ccEmails = [],
array $bccEmails = [],
string $subject = 'System Notification',
string $message = 'This is a system notification.'
): bool {
$email = new Message(
from: new Email('system@example.com'),
subject: $subject,
body: $message,
htmlBody: "<p>{$message}</p>",
to: new EmailList([$primaryRecipient]),
cc: new EmailList(array_map(fn ($email) => new Email($email), $ccEmails)),
bcc: new EmailList(array_map(fn ($email) => new Email($email), $bccEmails)),
replyTo: new Email('support@example.com'),
);
return $this->mailer->send($email);
}
/**
* Example of error handling when sending fails
*/
public function sendWithErrorHandling(Email $recipient, string $subject, string $content): string
{
$message = new Message(
from: new Email('noreply@example.com'),
subject: $subject,
body: $content,
to: new EmailList([$recipient]),
);
try {
$success = $this->mailer->send($message);
if ($success) {
return "Email sent successfully to {$recipient->value}";
} else {
return "Failed to send email to {$recipient->value}";
}
} catch (\Exception $e) {
// Log the error (assuming logger is available)
error_log("Email sending failed: " . $e->getMessage());
return "Email sending failed with error: " . $e->getMessage();
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final class SmtpException extends FrameworkException
{
public static function connectionFailed(string $host, int $port, string $error): self
{
return new self(
message: "Failed to connect to SMTP server {$host}:{$port}: {$error}",
context: ExceptionContext::forOperation('smtp_connect', 'mail')
->withData([
'host' => $host,
'port' => $port,
'error' => $error,
])
);
}
public static function authenticationFailed(string $response): self
{
return new self(
message: "SMTP authentication failed",
context: ExceptionContext::forOperation('smtp_auth', 'mail')
->withData([
'smtp_response' => $response,
])
);
}
public static function commandFailed(string $command, string $response): self
{
return new self(
message: "SMTP command failed: {$command}",
context: ExceptionContext::forOperation('smtp_command', 'mail')
->withData([
'command' => $command,
'smtp_response' => $response,
])
);
}
public static function tlsFailed(): self
{
return new self(
message: "Failed to enable TLS encryption",
context: ExceptionContext::forOperation('smtp_tls', 'mail')
);
}
public static function messageBuildFailed(string $reason): self
{
return new self(
message: "Failed to build email message: {$reason}",
context: ExceptionContext::forOperation('message_build', 'mail')
->withData([
'reason' => $reason,
])
);
}
public static function messageValidationFailed(array $errors): self
{
return new self(
message: "Message validation failed: " . implode(', ', $errors),
context: ExceptionContext::forOperation('message_validation', 'mail')
->withData([
'validation_errors' => $errors,
])
);
}
public static function mailFromFailed(string $sender, string $response): self
{
return new self(
message: "MAIL FROM command failed for sender: {$sender}",
context: ExceptionContext::forOperation('smtp_mail_from', 'mail')
->withData([
'sender' => $sender,
'smtp_response' => $response,
])
);
}
public static function recipientFailed(string $recipient, string $response): self
{
return new self(
message: "RCPT TO command failed for recipient: {$recipient}",
context: ExceptionContext::forOperation('smtp_rcpt_to', 'mail')
->withData([
'recipient' => $recipient,
'smtp_response' => $response,
])
);
}
public static function dataCommandFailed(string $response): self
{
return new self(
message: "DATA command failed",
context: ExceptionContext::forOperation('smtp_data', 'mail')
->withData([
'smtp_response' => $response,
])
);
}
public static function messageSendFailed(string $response): self
{
return new self(
message: "Message sending failed",
context: ExceptionContext::forOperation('smtp_message_send', 'mail')
->withData([
'smtp_response' => $response,
])
);
}
public static function socketError(string $host, int $port, string $operation): self
{
return new self(
message: "Socket error during {$operation}",
context: ExceptionContext::forOperation('smtp_socket', 'mail')
->withData([
'host' => $host,
'port' => $port,
'operation' => $operation,
])
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Mail\Commands\SendEmailBatchCommand;
use App\Framework\Mail\Commands\SendEmailCommand;
use App\Framework\Queue\Queue;
final readonly class Mailer implements MailerInterface
{
public function __construct(
public TransportInterface $transport,
private Queue $queue,
private CommandBus $commandBus,
) {
}
public function send(Message $message): bool
{
$result = $this->transport->send($message);
return $result->isSuccess();
}
public function queue(Message $message, int $maxRetries = 3, int $delaySeconds = 0): bool
{
try {
$command = new SendEmailCommand($message, $maxRetries, $delaySeconds);
$this->commandBus->dispatch($command);
return true;
} catch (\Exception) {
return false;
}
}
public function sendBatch(array $messages): array
{
$results = [];
foreach ($messages as $index => $message) {
if (! $message instanceof Message) {
$results[$index] = false;
continue;
}
$results[$index] = $this->send($message);
}
return $results;
}
public function queueBatch(array $messages, int $maxRetries = 3, int $delaySeconds = 0): array
{
// Validate messages first
$validMessages = [];
$results = [];
foreach ($messages as $index => $message) {
if (! $message instanceof Message) {
$results[$index] = false;
continue;
}
$validMessages[] = $message;
$results[$index] = true; // Assume success, will be updated if batch fails
}
if (empty($validMessages)) {
return $results; // All invalid, return early
}
// Use batch command for better performance
try {
$batchCommand = new SendEmailBatchCommand($validMessages, $maxRetries, $delaySeconds);
$this->commandBus->dispatch($batchCommand);
} catch (\Exception) {
// If batch command fails, mark all valid messages as failed
foreach ($results as $index => $result) {
if ($result === true) {
$results[$index] = false;
}
}
}
return $results;
}
/*public function isAvailable(): bool
{
return $this->transport->isAvailable();
}*/
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
interface MailerInterface
{
/**
* Send an email message.
*/
public function send(Message $message): bool;
/**
* Queue an email message for later sending.
*/
public function queue(Message $message, int $maxRetries = 3, int $delaySeconds = 0): bool;
/**
* Send multiple email messages.
*/
public function sendBatch(array $messages): array;
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
use App\Domain\Common\ValueObject\Email;
use InvalidArgumentException;
final readonly class Message
{
public function __construct(
public Email $from,
public string $subject,
public string $body = '',
public string $htmlBody = '',
public Priority $priority = Priority::NORMAL,
public EmailList $to = new EmailList(),
public EmailList $cc = new EmailList(),
public EmailList $bcc = new EmailList(),
/** @var Attachment[] */
public array $attachments = [],
public array $headers = [],
public ?Email $replyTo = null,
) {
if (empty($body) && empty($htmlBody)) {
throw new InvalidArgumentException('Either body or HTML body is required');
}
if ($to->isEmpty()) {
throw new InvalidArgumentException('At least one recipient is required');
}
}
public function hasHtmlBody(): bool
{
return ! empty($this->htmlBody);
}
public function hasAttachments(): bool
{
return ! empty($this->attachments);
}
public function hasReplyTo(): bool
{
return $this->replyTo !== null;
}
public function getAllRecipients(): EmailList
{
return $this->to->merge($this->cc)->merge($this->bcc)->unique();
}
public function withTo(Email|string ...$recipients): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: new EmailList(...$recipients),
cc: $this->cc,
bcc: $this->bcc,
attachments: $this->attachments,
headers: $this->headers,
replyTo: $this->replyTo,
);
}
public function withCc(Email|string ...$recipients): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: new EmailList(...$recipients),
bcc: $this->bcc,
attachments: $this->attachments,
headers: $this->headers,
replyTo: $this->replyTo,
);
}
public function withBcc(Email|string ...$recipients): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: $this->cc,
bcc: new EmailList(...$recipients),
attachments: $this->attachments,
headers: $this->headers,
replyTo: $this->replyTo,
);
}
public function withAttachment(Attachment $attachment): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: $this->cc,
bcc: $this->bcc,
attachments: [...$this->attachments, $attachment],
headers: $this->headers,
replyTo: $this->replyTo,
);
}
public function withHeader(string $name, string $value): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: $this->cc,
bcc: $this->bcc,
attachments: $this->attachments,
headers: [...$this->headers, $name => $value],
replyTo: $this->replyTo,
);
}
public function withSubject(string $subject): self
{
return new self(
from: $this->from,
subject: $subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: $this->cc,
bcc: $this->bcc,
attachments: $this->attachments,
headers: $this->headers,
replyTo: $this->replyTo,
);
}
public function withReplyTo(Email|string $replyTo): self
{
return new self(
from: $this->from,
subject: $this->subject,
body: $this->body,
htmlBody: $this->htmlBody,
priority: $this->priority,
to: $this->to,
cc: $this->cc,
bcc: $this->bcc,
attachments: $this->attachments,
headers: $this->headers,
replyTo: $replyTo instanceof Email ? $replyTo : new Email($replyTo),
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
final class PooledSmtpTransport implements TransportInterface
{
private array $connections = [];
private int $currentConnection = 0;
public function __construct(
private readonly SmtpConfig $config,
private readonly int $maxConnections = 3,
) {
}
public function send(Message $message): TransportResult
{
$transport = $this->getConnection();
return $transport->send($message);
}
public function isAvailable(): bool
{
// Test with a fresh connection
$transport = new SmtpTransport($this->config);
return $transport->isAvailable();
}
public function getName(): string
{
return 'Pooled SMTP Transport (' . $this->config->host . ':' . $this->config->port . ', pool: ' . $this->maxConnections . ')';
}
public function sendBatch(array $messages): array
{
$results = [];
foreach ($messages as $index => $message) {
if (! $message instanceof Message) {
$results[$index] = TransportResult::failure('Invalid message at index ' . $index);
continue;
}
$results[$index] = $this->send($message);
}
return $results;
}
private function getConnection(): SmtpTransport
{
// Round-robin connection selection
$connectionId = $this->currentConnection % $this->maxConnections;
$this->currentConnection++;
if (! isset($this->connections[$connectionId])) {
$this->connections[$connectionId] = new SmtpTransport($this->config);
}
return $this->connections[$connectionId];
}
public function closeAllConnections(): void
{
foreach ($this->connections as $transport) {
// Connections will be closed when transport is destroyed
unset($transport);
}
$this->connections = [];
$this->currentConnection = 0;
}
public function getActiveConnections(): int
{
return count($this->connections);
}
public function __destruct()
{
$this->closeAllConnections();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
enum Priority: int
{
case HIGHEST = 1;
case HIGH = 2;
case NORMAL = 3;
case LOW = 4;
case LOWEST = 5;
public function toHeaderValue(): string
{
return match ($this) {
self::HIGHEST, self::HIGH => 'high',
self::NORMAL => 'normal',
self::LOW, self::LOWEST => 'low',
};
}
public function toImportanceValue(): string
{
return match ($this) {
self::HIGHEST, self::HIGH => 'high',
self::NORMAL => 'normal',
self::LOW, self::LOWEST => 'low',
};
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
use InvalidArgumentException;
final readonly class SmtpConfig
{
public function __construct(
public string $host,
public int $port = 587,
public string $username = '',
public string $password = '',
public SmtpEncryption $encryption = SmtpEncryption::None,
public int $timeout = 30,
public bool $authentication = true,
public bool $verifyPeer = true,
) {
if ($port < 1 || $port > 65535) {
throw new InvalidArgumentException('Port must be between 1 and 65535');
}
if ($timeout < 1) {
throw new InvalidArgumentException('Timeout must be positive');
}
}
public static function createTls(string $host, int $port, string $username, string $password): self
{
return new self(
host: $host,
port: $port,
username: $username,
password: $password,
encryption: SmtpEncryption::TLS,
);
}
public static function createSsl(string $host, int $port, string $username, string $password): self
{
return new self(
host: $host,
port: $port,
username: $username,
password: $password,
encryption: SmtpEncryption::SSL,
);
}
public static function createPlain(string $host, int $port, string $username = '', string $password = ''): self
{
return new self(
host: $host,
port: $port,
username: $username,
password: $password,
encryption: SmtpEncryption::None,
authentication: ! empty($username),
);
}
public function requiresAuthentication(): bool
{
return $this->authentication && ! empty($this->username);
}
public function hasEncryption(): bool
{
return $this->encryption !== SmtpEncryption::None;
}
public function getConnectionString(): string
{
if ($this->hasEncryption()) {
return $this->encryption->value . '://' . $this->host . ':' . $this->port;
}
return $this->host . ':' . $this->port;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
enum SmtpEncryption: string
{
case None = '';
case SSL = 'ssl';
case TLS = 'tls';
}

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

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail\Testing;
use App\Framework\Mail\Message;
use App\Framework\Mail\TransportInterface;
use App\Framework\Mail\TransportResult;
final class MockTransport implements TransportInterface
{
private array $sentMessages = [];
private bool $shouldFail = false;
private ?string $failureMessage = null;
private bool $isAvailable = true;
public function send(Message $message): TransportResult
{
if ($this->shouldFail) {
return TransportResult::failure(
error: $this->failureMessage ?? 'Mock transport failure',
metadata: ['mock' => true]
);
}
$messageId = 'mock_' . uniqid();
$this->sentMessages[] = [
'message' => $message,
'message_id' => $messageId,
'sent_at' => new \DateTimeImmutable(),
];
return TransportResult::success($messageId);
}
public function isAvailable(): bool
{
return $this->isAvailable;
}
public function getName(): string
{
return 'Mock Transport';
}
public function sendBatch(array $messages): array
{
$results = [];
foreach ($messages as $index => $message) {
if (! $message instanceof Message) {
$results[$index] = TransportResult::failure('Invalid message at index ' . $index);
continue;
}
$results[$index] = $this->send($message);
}
return $results;
}
// Test helper methods
public function setShouldFail(bool $shouldFail, ?string $failureMessage = null): void
{
$this->shouldFail = $shouldFail;
$this->failureMessage = $failureMessage;
}
public function setIsAvailable(bool $isAvailable): void
{
$this->isAvailable = $isAvailable;
}
public function getSentMessages(): array
{
return $this->sentMessages;
}
public function getLastSentMessage(): ?array
{
return end($this->sentMessages) ?: null;
}
public function getSentMessageCount(): int
{
return count($this->sentMessages);
}
public function clearSentMessages(): void
{
$this->sentMessages = [];
}
public function wasSentTo(string $email): bool
{
foreach ($this->sentMessages as $sentMessage) {
/** @var Message $message */
$message = $sentMessage['message'];
foreach ($message->to as $recipient) {
if ($recipient->value === $email) {
return true;
}
}
}
return false;
}
public function getSentMessagesTo(string $email): array
{
$messages = [];
foreach ($this->sentMessages as $sentMessage) {
/** @var Message $message */
$message = $sentMessage['message'];
foreach ($message->to as $recipient) {
if ($recipient->value === $email) {
$messages[] = $sentMessage;
break;
}
}
}
return $messages;
}
public function getSentMessageWithSubject(string $subject): ?array
{
foreach ($this->sentMessages as $sentMessage) {
/** @var Message $message */
$message = $sentMessage['message'];
if ($message->subject === $subject) {
return $sentMessage;
}
}
return null;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
interface TransportInterface
{
/**
* Send a message using this transport.
*/
public function send(Message $message): TransportResult;
/**
* Check if the transport is available and configured properly.
*/
public function isAvailable(): bool;
/**
* Get transport name for debugging/logging.
*/
public function getName(): string;
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mail;
final readonly class TransportResult
{
public function __construct(
public bool $success,
public ?string $messageId = null,
public ?string $error = null,
public array $metadata = [],
) {
}
public static function success(?string $messageId = null, array $metadata = []): self
{
return new self(
success: true,
messageId: $messageId,
metadata: $metadata,
);
}
public static function failure(string $error, array $metadata = []): self
{
return new self(
success: false,
error: $error,
metadata: $metadata,
);
}
public function isSuccess(): bool
{
return $this->success;
}
public function isFailure(): bool
{
return ! $this->success;
}
public function getError(): string
{
if ($this->success) {
throw new \RuntimeException('Cannot get error from successful result');
}
return $this->error ?? 'Unknown error';
}
public function getMessageId(): string
{
if (! $this->success) {
throw new \RuntimeException('Cannot get message ID from failed result');
}
return $this->messageId ?? '';
}
public function getMetadata(): array
{
return $this->metadata;
}
}