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:
102
src/Framework/Mail/Attachment.php
Normal file
102
src/Framework/Mail/Attachment.php
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/Framework/Mail/Commands/SendEmailBatchCommand.php
Normal file
43
src/Framework/Mail/Commands/SendEmailBatchCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
107
src/Framework/Mail/Commands/SendEmailBatchCommandHandler.php
Normal file
107
src/Framework/Mail/Commands/SendEmailBatchCommandHandler.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/Framework/Mail/Commands/SendEmailCommand.php
Normal file
29
src/Framework/Mail/Commands/SendEmailCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
92
src/Framework/Mail/Commands/SendEmailCommandHandler.php
Normal file
92
src/Framework/Mail/Commands/SendEmailCommandHandler.php
Normal 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,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/Framework/Mail/EmailList.php
Normal file
127
src/Framework/Mail/EmailList.php
Normal 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);
|
||||
}
|
||||
}
|
||||
164
src/Framework/Mail/Examples/MailerUsageExample.php
Normal file
164
src/Framework/Mail/Examples/MailerUsageExample.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Framework/Mail/Exceptions/SmtpException.php
Normal file
136
src/Framework/Mail/Exceptions/SmtpException.php
Normal 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,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/Framework/Mail/Mailer.php
Normal file
98
src/Framework/Mail/Mailer.php
Normal 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();
|
||||
}*/
|
||||
}
|
||||
23
src/Framework/Mail/MailerInterface.php
Normal file
23
src/Framework/Mail/MailerInterface.php
Normal 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;
|
||||
}
|
||||
173
src/Framework/Mail/Message.php
Normal file
173
src/Framework/Mail/Message.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/Framework/Mail/PooledSmtpTransport.php
Normal file
89
src/Framework/Mail/PooledSmtpTransport.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
src/Framework/Mail/Priority.php
Normal file
32
src/Framework/Mail/Priority.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
82
src/Framework/Mail/SmtpConfig.php
Normal file
82
src/Framework/Mail/SmtpConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/Framework/Mail/SmtpEncryption.php
Normal file
12
src/Framework/Mail/SmtpEncryption.php
Normal 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';
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
149
src/Framework/Mail/Testing/MockTransport.php
Normal file
149
src/Framework/Mail/Testing/MockTransport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
src/Framework/Mail/TransportInterface.php
Normal file
23
src/Framework/Mail/TransportInterface.php
Normal 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;
|
||||
}
|
||||
67
src/Framework/Mail/TransportResult.php
Normal file
67
src/Framework/Mail/TransportResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user