├── CHANGELOG.md ├── Command └── MailerTestCommand.php ├── DataCollector └── MessageDataCollector.php ├── DelayedEnvelope.php ├── Envelope.php ├── Event ├── FailedMessageEvent.php ├── MessageEvent.php ├── MessageEvents.php └── SentMessageEvent.php ├── EventListener ├── DkimSignedMessageListener.php ├── EnvelopeListener.php ├── MessageListener.php ├── MessageLoggerListener.php ├── MessengerTransportListener.php ├── SmimeCertificateRepositoryInterface.php ├── SmimeEncryptedMessageListener.php └── SmimeSignedMessageListener.php ├── Exception ├── ExceptionInterface.php ├── HttpTransportException.php ├── IncompleteDsnException.php ├── InvalidArgumentException.php ├── LogicException.php ├── RuntimeException.php ├── TransportException.php ├── TransportExceptionInterface.php ├── UnexpectedResponseException.php └── UnsupportedSchemeException.php ├── Header ├── MetadataHeader.php └── TagHeader.php ├── LICENSE ├── Mailer.php ├── MailerInterface.php ├── Messenger ├── MessageHandler.php └── SendEmailMessage.php ├── README.md ├── SentMessage.php ├── Test ├── AbstractTransportFactoryTestCase.php ├── Constraint │ ├── EmailCount.php │ └── EmailIsQueued.php ├── IncompleteDsnTestTrait.php └── TransportFactoryTestCase.php ├── Transport.php ├── Transport ├── AbstractApiTransport.php ├── AbstractHttpTransport.php ├── AbstractTransport.php ├── AbstractTransportFactory.php ├── Dsn.php ├── FailoverTransport.php ├── NativeTransportFactory.php ├── NullTransport.php ├── NullTransportFactory.php ├── RoundRobinTransport.php ├── SendmailTransport.php ├── SendmailTransportFactory.php ├── Smtp │ ├── Auth │ │ ├── AuthenticatorInterface.php │ │ ├── CramMd5Authenticator.php │ │ ├── LoginAuthenticator.php │ │ ├── PlainAuthenticator.php │ │ └── XOAuth2Authenticator.php │ ├── EsmtpTransport.php │ ├── EsmtpTransportFactory.php │ ├── SmtpTransport.php │ └── Stream │ │ ├── AbstractStream.php │ │ ├── ProcessStream.php │ │ └── SocketStream.php ├── TransportFactoryInterface.php ├── TransportInterface.php └── Transports.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add DSN param `retry_period` to override default email transport retry period 8 | * Add `Dsn::getBooleanOption()` 9 | * Add DSN param `source_ip` to allow binding to a (specific) IPv4 or IPv6 address. 10 | * Add DSN param `require_tls` to enforce use of TLS/STARTTLS 11 | * Add `DkimSignedMessageListener`, `SmimeEncryptedMessageListener`, and `SmimeSignedMessageListener` 12 | 13 | 7.2 14 | --- 15 | 16 | * Deprecate `TransportFactoryTestCase`, extend `AbstractTransportFactoryTestCase` instead 17 | 18 | The `testIncompleteDsnException()` test is no longer provided by default. If you make use of it by implementing the `incompleteDsnProvider()` data providers, 19 | you now need to use the `IncompleteDsnTestTrait`. 20 | 21 | * Make `TransportFactoryTestCase` compatible with PHPUnit 10+ 22 | * Support unicode email addresses such as "dømi@dømi.example" 23 | 24 | 7.1 25 | --- 26 | 27 | * Dispatch Postmark's "406 - Inactive recipient" API error code as a `PostmarkDeliveryEvent` instead of throwing an exception 28 | * Add DSN param `auto_tls` to disable automatic STARTTLS 29 | * Add support for allowing some users even if `recipients` is defined in `EnvelopeListener` 30 | 31 | 7.0 32 | --- 33 | 34 | * Remove the OhMySmtp bridge in favor of the MailPace bridge 35 | 36 | 6.4 37 | --- 38 | 39 | * Add DSN parameter `peer_fingerprint` to verify TLS certificate fingerprint 40 | * Change the default port for the `mailjet+smtp` transport from 465 to 587 41 | 42 | 6.3 43 | --- 44 | 45 | * Add `MessageEvent::reject()` to allow rejecting an email before sending it 46 | * Change the default port for the `mailgun+smtp` transport from 465 to 587 47 | * Add `$authenticators` parameter in `EsmtpTransport` constructor and `EsmtpTransport::setAuthenticators()` 48 | to allow overriding of default eSMTP authenticators 49 | 50 | 6.2.7 51 | ----- 52 | 53 | * [BC BREAK] The following data providers for `TransportFactoryTestCase` are now static: 54 | `supportsProvider()`, `createProvider()`, `unsupportedSchemeProvider()`and `incompleteDsnProvider()` 55 | 56 | 6.2 57 | --- 58 | 59 | * Add a `mailer:test` command 60 | * Add `SentMessageEvent` and `FailedMessageEvent` events 61 | 62 | 6.1 63 | --- 64 | 65 | * Make `start()` and `stop()` methods public on `SmtpTransport` 66 | * Improve extensibility of `EsmtpTransport` 67 | 68 | 6.0 69 | --- 70 | 71 | * The `HttpTransportException` class takes a string at first argument 72 | 73 | 5.4 74 | --- 75 | 76 | * Enable the mailer to operate on any PSR-14-compatible event dispatcher 77 | 78 | 5.3 79 | --- 80 | 81 | * added the `mailer` monolog channel and set it on all transport definitions 82 | 83 | 5.2.0 84 | ----- 85 | 86 | * added `NativeTransportFactory` to configure a transport based on php.ini settings 87 | * added `local_domain`, `restart_threshold`, `restart_threshold_sleep` and `ping_threshold` options for `smtp` 88 | * added `command` option for `sendmail` 89 | 90 | 4.4.0 91 | ----- 92 | 93 | * [BC BREAK] changed the `NullTransport` DSN from `smtp://null` to `null://null` 94 | * [BC BREAK] renamed `SmtpEnvelope` to `Envelope`, renamed `DelayedSmtpEnvelope` to 95 | `DelayedEnvelope` 96 | * [BC BREAK] changed the syntax for failover and roundrobin DSNs 97 | 98 | Before: 99 | 100 | dummy://a || dummy://b (for failover) 101 | dummy://a && dummy://b (for roundrobin) 102 | 103 | After: 104 | 105 | failover(dummy://a dummy://b) 106 | roundrobin(dummy://a dummy://b) 107 | 108 | * added support for multiple transports on a `Mailer` instance 109 | * [BC BREAK] removed the `auth_mode` DSN option (it is now always determined automatically) 110 | * STARTTLS cannot be enabled anymore (it is used automatically if TLS is disabled and the server supports STARTTLS) 111 | * [BC BREAK] Removed the `encryption` DSN option (use `smtps` instead) 112 | * Added support for the `smtps` protocol (does the same as using `smtp` and port `465`) 113 | * Added PHPUnit constraints 114 | * Added `MessageDataCollector` 115 | * Added `MessageEvents` and `MessageLoggerListener` to allow collecting sent emails 116 | * [BC BREAK] `TransportInterface` has a new `__toString()` method 117 | * [BC BREAK] Classes `AbstractApiTransport` and `AbstractHttpTransport` moved under `Transport` sub-namespace. 118 | * [BC BREAK] Transports depend on `Symfony\Contracts\EventDispatcher\EventDispatcherInterface` 119 | instead of `Symfony\Component\EventDispatcher\EventDispatcherInterface`. 120 | * Added possibility to register custom transport for dsn by implementing 121 | `Symfony\Component\Mailer\Transport\TransportFactoryInterface` and tagging with `mailer.transport_factory` tag in DI. 122 | * Added `Symfony\Component\Mailer\Test\TransportFactoryTestCase` to ease testing custom transport factories. 123 | * Added `SentMessage::getDebug()` and `TransportExceptionInterface::getDebug` to help debugging 124 | * Made `MessageEvent` final 125 | * add DSN parameter `verify_peer` to disable TLS peer verification for SMTP transport 126 | 127 | 4.3.0 128 | ----- 129 | 130 | * Added the component. 131 | -------------------------------------------------------------------------------- /Command/MailerTestCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Command; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Input\InputArgument; 17 | use Symfony\Component\Console\Input\InputInterface; 18 | use Symfony\Component\Console\Input\InputOption; 19 | use Symfony\Component\Console\Output\OutputInterface; 20 | use Symfony\Component\Mailer\Transport\TransportInterface; 21 | use Symfony\Component\Mime\Email; 22 | 23 | /** 24 | * A console command to test Mailer transports. 25 | */ 26 | #[AsCommand(name: 'mailer:test', description: 'Test Mailer transports by sending an email')] 27 | final class MailerTestCommand extends Command 28 | { 29 | public function __construct(private TransportInterface $transport) 30 | { 31 | parent::__construct(); 32 | } 33 | 34 | protected function configure(): void 35 | { 36 | $this 37 | ->addArgument('to', InputArgument::REQUIRED, 'The recipient of the message') 38 | ->addOption('from', null, InputOption::VALUE_OPTIONAL, 'The sender of the message', 'from@example.org') 39 | ->addOption('subject', null, InputOption::VALUE_OPTIONAL, 'The subject of the message', 'Testing transport') 40 | ->addOption('body', null, InputOption::VALUE_OPTIONAL, 'The body of the message', 'Testing body') 41 | ->addOption('transport', null, InputOption::VALUE_OPTIONAL, 'The transport to be used') 42 | ->setHelp(<<<'EOF' 43 | The %command.name% command tests a Mailer transport by sending a simple email message: 44 | 45 | php %command.full_name% to@example.com 46 | 47 | You can also specify a specific transport: 48 | 49 | php %command.full_name% to@example.com --transport=transport_name 50 | 51 | Note that this command bypasses the Messenger bus if configured. 52 | 53 | EOF 54 | ); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $message = (new Email()) 60 | ->to($input->getArgument('to')) 61 | ->from($input->getOption('from')) 62 | ->subject($input->getOption('subject')) 63 | ->text($input->getOption('body')) 64 | ; 65 | if ($transport = $input->getOption('transport')) { 66 | $message->getHeaders()->addTextHeader('X-Transport', $transport); 67 | } 68 | 69 | $this->transport->send($message); 70 | 71 | return 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DataCollector/MessageDataCollector.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\DataCollector; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Symfony\Component\HttpFoundation\Response; 16 | use Symfony\Component\HttpKernel\DataCollector\DataCollector; 17 | use Symfony\Component\Mailer\Event\MessageEvents; 18 | use Symfony\Component\Mailer\EventListener\MessageLoggerListener; 19 | 20 | /** 21 | * @author Fabien Potencier 22 | */ 23 | final class MessageDataCollector extends DataCollector 24 | { 25 | private MessageEvents $events; 26 | 27 | public function __construct(MessageLoggerListener $logger) 28 | { 29 | $this->events = $logger->getEvents(); 30 | } 31 | 32 | public function collect(Request $request, Response $response, ?\Throwable $exception = null): void 33 | { 34 | $this->data['events'] = $this->events; 35 | } 36 | 37 | public function getEvents(): MessageEvents 38 | { 39 | return $this->data['events']; 40 | } 41 | 42 | /** 43 | * @internal 44 | */ 45 | public function base64Encode(string $data): string 46 | { 47 | return base64_encode($data); 48 | } 49 | 50 | public function reset(): void 51 | { 52 | $this->data = []; 53 | } 54 | 55 | public function getName(): string 56 | { 57 | return 'mailer'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /DelayedEnvelope.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Symfony\Component\Mailer\Exception\LogicException; 15 | use Symfony\Component\Mime\Address; 16 | use Symfony\Component\Mime\Header\Headers; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * @author Fabien Potencier 21 | * 22 | * @internal 23 | */ 24 | final class DelayedEnvelope extends Envelope 25 | { 26 | private bool $senderSet = false; 27 | private bool $recipientsSet = false; 28 | 29 | public function __construct( 30 | private Message $message, 31 | ) { 32 | } 33 | 34 | public function setSender(Address $sender): void 35 | { 36 | parent::setSender($sender); 37 | 38 | $this->senderSet = true; 39 | } 40 | 41 | public function getSender(): Address 42 | { 43 | if (!$this->senderSet) { 44 | parent::setSender(self::getSenderFromHeaders($this->message->getHeaders())); 45 | } 46 | 47 | return parent::getSender(); 48 | } 49 | 50 | public function setRecipients(array $recipients): void 51 | { 52 | parent::setRecipients($recipients); 53 | 54 | $this->recipientsSet = (bool) parent::getRecipients(); 55 | } 56 | 57 | /** 58 | * @return Address[] 59 | */ 60 | public function getRecipients(): array 61 | { 62 | if ($this->recipientsSet) { 63 | return parent::getRecipients(); 64 | } 65 | 66 | return self::getRecipientsFromHeaders($this->message->getHeaders()); 67 | } 68 | 69 | private static function getRecipientsFromHeaders(Headers $headers): array 70 | { 71 | $recipients = []; 72 | foreach (['to', 'cc', 'bcc'] as $name) { 73 | foreach ($headers->all($name) as $header) { 74 | foreach ($header->getAddresses() as $address) { 75 | $recipients[] = $address; 76 | } 77 | } 78 | } 79 | 80 | return $recipients; 81 | } 82 | 83 | private static function getSenderFromHeaders(Headers $headers): Address 84 | { 85 | if ($sender = $headers->get('Sender')) { 86 | return $sender->getAddress(); 87 | } 88 | if ($return = $headers->get('Return-Path')) { 89 | return $return->getAddress(); 90 | } 91 | if ($from = $headers->get('From')) { 92 | return $from->getAddresses()[0]; 93 | } 94 | 95 | throw new LogicException('Unable to determine the sender of the message.'); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Envelope.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 15 | use Symfony\Component\Mailer\Exception\LogicException; 16 | use Symfony\Component\Mime\Address; 17 | use Symfony\Component\Mime\RawMessage; 18 | 19 | /** 20 | * @author Fabien Potencier 21 | */ 22 | class Envelope 23 | { 24 | private Address $sender; 25 | private array $recipients = []; 26 | 27 | /** 28 | * @param Address[] $recipients 29 | */ 30 | public function __construct(Address $sender, array $recipients) 31 | { 32 | $this->setSender($sender); 33 | $this->setRecipients($recipients); 34 | } 35 | 36 | public static function create(RawMessage $message): self 37 | { 38 | if (RawMessage::class === $message::class) { 39 | throw new LogicException('Cannot send a RawMessage instance without an explicit Envelope.'); 40 | } 41 | 42 | return new DelayedEnvelope($message); 43 | } 44 | 45 | public function setSender(Address $sender): void 46 | { 47 | // to ensure deliverability of bounce emails independent of UTF-8 capabilities of SMTP servers 48 | if (!preg_match('/^[^@\x80-\xFF]++@/', $sender->getAddress())) { 49 | throw new InvalidArgumentException(\sprintf('Invalid sender "%s": non-ASCII characters not supported in local-part of email.', $sender->getAddress())); 50 | } 51 | $this->sender = $sender; 52 | } 53 | 54 | /** 55 | * @return Address Returns a "mailbox" as specified by RFC 2822 56 | * Must be converted to an "addr-spec" when used as a "MAIL FROM" value in SMTP (use getAddress()) 57 | */ 58 | public function getSender(): Address 59 | { 60 | return $this->sender; 61 | } 62 | 63 | /** 64 | * @param Address[] $recipients 65 | */ 66 | public function setRecipients(array $recipients): void 67 | { 68 | if (!$recipients) { 69 | throw new InvalidArgumentException('An envelope must have at least one recipient.'); 70 | } 71 | 72 | $this->recipients = []; 73 | foreach ($recipients as $recipient) { 74 | if (!$recipient instanceof Address) { 75 | throw new InvalidArgumentException(\sprintf('A recipient must be an instance of "%s" (got "%s").', Address::class, get_debug_type($recipient))); 76 | } 77 | $this->recipients[] = new Address($recipient->getAddress()); 78 | } 79 | } 80 | 81 | /** 82 | * @return Address[] 83 | */ 84 | public function getRecipients(): array 85 | { 86 | return $this->recipients; 87 | } 88 | 89 | /** 90 | * Returns true if any address' localpart contains at least one 91 | * non-ASCII character, and false if all addresses have all-ASCII 92 | * localparts. 93 | * 94 | * This helps to decide whether to the SMTPUTF8 extensions (RFC 95 | * 6530 and following) for any given message. 96 | * 97 | * The SMTPUTF8 extension is strictly required if any address 98 | * contains a non-ASCII character in its localpart. If non-ASCII 99 | * is only used in domains (e.g. horst@freiherr-von-mühlhausen.de) 100 | * then it is possible to send the message using IDN encoding 101 | * instead of SMTPUTF8. The most common software will display the 102 | * message as intended. 103 | */ 104 | public function anyAddressHasUnicodeLocalpart(): bool 105 | { 106 | if ($this->getSender()->hasUnicodeLocalpart()) { 107 | return true; 108 | } 109 | foreach ($this->getRecipients() as $r) { 110 | if ($r->hasUnicodeLocalpart()) { 111 | return true; 112 | } 113 | } 114 | 115 | return false; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Event/FailedMessageEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Event; 13 | 14 | use Symfony\Component\Mime\RawMessage; 15 | use Symfony\Contracts\EventDispatcher\Event; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | final class FailedMessageEvent extends Event 21 | { 22 | public function __construct( 23 | private RawMessage $message, 24 | private \Throwable $error, 25 | ) { 26 | } 27 | 28 | public function getMessage(): RawMessage 29 | { 30 | return $this->message; 31 | } 32 | 33 | public function getError(): \Throwable 34 | { 35 | return $this->error; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Event/MessageEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Event; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Exception\LogicException; 16 | use Symfony\Component\Messenger\Stamp\StampInterface; 17 | use Symfony\Component\Mime\RawMessage; 18 | use Symfony\Contracts\EventDispatcher\Event; 19 | 20 | /** 21 | * Allows the transformation of a Message and the Envelope before the email is sent. 22 | * 23 | * @author Fabien Potencier 24 | */ 25 | final class MessageEvent extends Event 26 | { 27 | private bool $rejected = false; 28 | 29 | /** @var StampInterface[] */ 30 | private array $stamps = []; 31 | 32 | public function __construct( 33 | private RawMessage $message, 34 | private Envelope $envelope, 35 | private string $transport, 36 | private bool $queued = false, 37 | ) { 38 | } 39 | 40 | public function getMessage(): RawMessage 41 | { 42 | return $this->message; 43 | } 44 | 45 | public function setMessage(RawMessage $message): void 46 | { 47 | $this->message = $message; 48 | } 49 | 50 | public function getEnvelope(): Envelope 51 | { 52 | return $this->envelope; 53 | } 54 | 55 | public function setEnvelope(Envelope $envelope): void 56 | { 57 | $this->envelope = $envelope; 58 | } 59 | 60 | public function getTransport(): string 61 | { 62 | return $this->transport; 63 | } 64 | 65 | public function isQueued(): bool 66 | { 67 | return $this->queued; 68 | } 69 | 70 | public function isRejected(): bool 71 | { 72 | return $this->rejected; 73 | } 74 | 75 | public function reject(): void 76 | { 77 | $this->rejected = true; 78 | $this->stopPropagation(); 79 | } 80 | 81 | public function addStamp(StampInterface $stamp): void 82 | { 83 | if (!$this->queued) { 84 | throw new LogicException(\sprintf('Cannot call "%s()" on a message that is not meant to be queued.', __METHOD__)); 85 | } 86 | 87 | $this->stamps[] = $stamp; 88 | } 89 | 90 | /** 91 | * @return StampInterface[] 92 | */ 93 | public function getStamps(): array 94 | { 95 | if (!$this->queued) { 96 | throw new LogicException(\sprintf('Cannot call "%s()" on a message that is not meant to be queued.', __METHOD__)); 97 | } 98 | 99 | return $this->stamps; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Event/MessageEvents.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Event; 13 | 14 | use Symfony\Component\Mime\RawMessage; 15 | 16 | /** 17 | * @author Fabien Potencier 18 | */ 19 | class MessageEvents 20 | { 21 | /** 22 | * @var MessageEvent[] 23 | */ 24 | private array $events = []; 25 | 26 | /** 27 | * @var array 28 | */ 29 | private array $transports = []; 30 | 31 | public function add(MessageEvent $event): void 32 | { 33 | $this->events[] = $event; 34 | $this->transports[$event->getTransport()] = true; 35 | } 36 | 37 | public function getTransports(): array 38 | { 39 | return array_keys($this->transports); 40 | } 41 | 42 | /** 43 | * @return MessageEvent[] 44 | */ 45 | public function getEvents(?string $name = null): array 46 | { 47 | if (null === $name) { 48 | return $this->events; 49 | } 50 | 51 | $events = []; 52 | foreach ($this->events as $event) { 53 | if ($name === $event->getTransport()) { 54 | $events[] = $event; 55 | } 56 | } 57 | 58 | return $events; 59 | } 60 | 61 | /** 62 | * @return RawMessage[] 63 | */ 64 | public function getMessages(?string $name = null): array 65 | { 66 | $events = $this->getEvents($name); 67 | $messages = []; 68 | foreach ($events as $event) { 69 | $messages[] = $event->getMessage(); 70 | } 71 | 72 | return $messages; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Event/SentMessageEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Event; 13 | 14 | use Symfony\Component\Mailer\SentMessage; 15 | use Symfony\Contracts\EventDispatcher\Event; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | final class SentMessageEvent extends Event 21 | { 22 | public function __construct(private SentMessage $message) 23 | { 24 | } 25 | 26 | public function getMessage(): SentMessage 27 | { 28 | return $this->message; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /EventListener/DkimSignedMessageListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mime\Crypto\DkimSigner; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * Signs messages using DKIM. 21 | * 22 | * @author Elías Fernández 23 | */ 24 | class DkimSignedMessageListener implements EventSubscriberInterface 25 | { 26 | public function __construct( 27 | private DkimSigner $signer, 28 | ) { 29 | } 30 | 31 | public function onMessage(MessageEvent $event): void 32 | { 33 | $message = $event->getMessage(); 34 | if (!$message instanceof Message) { 35 | return; 36 | } 37 | $event->setMessage($this->signer->sign($message)); 38 | } 39 | 40 | public static function getSubscribedEvents(): array 41 | { 42 | return [ 43 | MessageEvent::class => ['onMessage', -128], 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /EventListener/EnvelopeListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mime\Address; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * Manipulates the Envelope of a Message. 21 | * 22 | * @author Fabien Potencier 23 | * @author Grégoire Pineau 24 | */ 25 | class EnvelopeListener implements EventSubscriberInterface 26 | { 27 | private ?Address $sender = null; 28 | 29 | /** 30 | * @var Address[]|null 31 | */ 32 | private ?array $recipients = null; 33 | 34 | /** 35 | * @param array $recipients 36 | * @param string[] $allowedRecipients An array of regex to match the allowed recipients 37 | */ 38 | public function __construct( 39 | Address|string|null $sender = null, 40 | ?array $recipients = null, 41 | private array $allowedRecipients = [], 42 | ) { 43 | if (null !== $sender) { 44 | $this->sender = Address::create($sender); 45 | } 46 | if (null !== $recipients) { 47 | $this->recipients = Address::createArray($recipients); 48 | } 49 | } 50 | 51 | public function onMessage(MessageEvent $event): void 52 | { 53 | if ($this->sender) { 54 | $event->getEnvelope()->setSender($this->sender); 55 | 56 | $message = $event->getMessage(); 57 | if ($message instanceof Message) { 58 | if (!$message->getHeaders()->has('Sender') && !$message->getHeaders()->has('From')) { 59 | $message->getHeaders()->addMailboxHeader('Sender', $this->sender); 60 | } 61 | } 62 | } 63 | 64 | if ($this->recipients) { 65 | $recipients = $this->recipients; 66 | if ($this->allowedRecipients) { 67 | foreach ($event->getEnvelope()->getRecipients() as $recipient) { 68 | foreach ($this->allowedRecipients as $allowedRecipient) { 69 | if (!preg_match('{\A'.$allowedRecipient.'\z}', $recipient->getAddress())) { 70 | continue; 71 | } 72 | // dedup 73 | foreach ($recipients as $r) { 74 | if ($r->getName() === $recipient->getName() && $r->getAddress() === $recipient->getAddress()) { 75 | continue 2; 76 | } 77 | } 78 | 79 | $recipients[] = $recipient; 80 | continue 2; 81 | } 82 | } 83 | } 84 | 85 | $event->getEnvelope()->setRecipients($recipients); 86 | } 87 | } 88 | 89 | public static function getSubscribedEvents(): array 90 | { 91 | return [ 92 | // should be the last one to allow header changes by other listeners first 93 | MessageEvent::class => ['onMessage', -255], 94 | ]; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /EventListener/MessageListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 17 | use Symfony\Component\Mailer\Exception\RuntimeException; 18 | use Symfony\Component\Mime\BodyRendererInterface; 19 | use Symfony\Component\Mime\Header\Headers; 20 | use Symfony\Component\Mime\Header\MailboxListHeader; 21 | use Symfony\Component\Mime\Message; 22 | 23 | /** 24 | * Manipulates the headers and the body of a Message. 25 | * 26 | * @author Fabien Potencier 27 | */ 28 | class MessageListener implements EventSubscriberInterface 29 | { 30 | public const HEADER_SET_IF_EMPTY = 1; 31 | public const HEADER_ADD = 2; 32 | public const HEADER_REPLACE = 3; 33 | public const DEFAULT_RULES = [ 34 | 'from' => self::HEADER_SET_IF_EMPTY, 35 | 'return-path' => self::HEADER_SET_IF_EMPTY, 36 | 'reply-to' => self::HEADER_ADD, 37 | 'to' => self::HEADER_SET_IF_EMPTY, 38 | 'cc' => self::HEADER_ADD, 39 | 'bcc' => self::HEADER_ADD, 40 | ]; 41 | 42 | private array $headerRules = []; 43 | 44 | public function __construct( 45 | private ?Headers $headers = null, 46 | private ?BodyRendererInterface $renderer = null, 47 | array $headerRules = self::DEFAULT_RULES, 48 | ) { 49 | foreach ($headerRules as $headerName => $rule) { 50 | $this->addHeaderRule($headerName, $rule); 51 | } 52 | } 53 | 54 | public function addHeaderRule(string $headerName, int $rule): void 55 | { 56 | if ($rule < 1 || $rule > 3) { 57 | throw new InvalidArgumentException(\sprintf('The "%d" rule is not supported.', $rule)); 58 | } 59 | 60 | $this->headerRules[strtolower($headerName)] = $rule; 61 | } 62 | 63 | public function onMessage(MessageEvent $event): void 64 | { 65 | $message = $event->getMessage(); 66 | if (!$message instanceof Message) { 67 | return; 68 | } 69 | 70 | $this->setHeaders($message); 71 | $this->renderMessage($message); 72 | } 73 | 74 | private function setHeaders(Message $message): void 75 | { 76 | if (!$this->headers) { 77 | return; 78 | } 79 | 80 | $headers = $message->getHeaders(); 81 | foreach ($this->headers->all() as $name => $header) { 82 | if (!$headers->has($name)) { 83 | $headers->add($header); 84 | 85 | continue; 86 | } 87 | 88 | switch ($this->headerRules[$name] ?? self::HEADER_SET_IF_EMPTY) { 89 | case self::HEADER_SET_IF_EMPTY: 90 | break; 91 | 92 | case self::HEADER_REPLACE: 93 | $headers->remove($name); 94 | $headers->add($header); 95 | 96 | break; 97 | 98 | case self::HEADER_ADD: 99 | if (!Headers::isUniqueHeader($name)) { 100 | $headers->add($header); 101 | 102 | break; 103 | } 104 | 105 | $h = $headers->get($name); 106 | if (!$h instanceof MailboxListHeader) { 107 | throw new RuntimeException(\sprintf('Unable to set header "%s".', $name)); 108 | } 109 | 110 | Headers::checkHeaderClass($header); 111 | foreach ($header->getAddresses() as $address) { 112 | $h->addAddress($address); 113 | } 114 | } 115 | } 116 | } 117 | 118 | private function renderMessage(Message $message): void 119 | { 120 | if (!$this->renderer) { 121 | return; 122 | } 123 | 124 | $this->renderer->render($message); 125 | } 126 | 127 | public static function getSubscribedEvents(): array 128 | { 129 | return [ 130 | MessageEvent::class => 'onMessage', 131 | ]; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /EventListener/MessageLoggerListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mailer\Event\MessageEvents; 17 | use Symfony\Contracts\Service\ResetInterface; 18 | 19 | /** 20 | * Logs Messages. 21 | * 22 | * @author Fabien Potencier 23 | */ 24 | class MessageLoggerListener implements EventSubscriberInterface, ResetInterface 25 | { 26 | private MessageEvents $events; 27 | 28 | public function __construct() 29 | { 30 | $this->events = new MessageEvents(); 31 | } 32 | 33 | public function reset(): void 34 | { 35 | $this->events = new MessageEvents(); 36 | } 37 | 38 | public function onMessage(MessageEvent $event): void 39 | { 40 | $this->events->add($event); 41 | } 42 | 43 | public function getEvents(): MessageEvents 44 | { 45 | return $this->events; 46 | } 47 | 48 | public static function getSubscribedEvents(): array 49 | { 50 | return [ 51 | MessageEvent::class => ['onMessage', -255], 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /EventListener/MessengerTransportListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Messenger\Stamp\TransportNamesStamp; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * Allows messages to be sent to specific Messenger transports via the "X-Bus-Transport" MIME header. 21 | * 22 | * @author Fabien Potencier 23 | */ 24 | final class MessengerTransportListener implements EventSubscriberInterface 25 | { 26 | public function onMessage(MessageEvent $event): void 27 | { 28 | if (!$event->isQueued()) { 29 | return; 30 | } 31 | 32 | $message = $event->getMessage(); 33 | if (!$message instanceof Message || !$message->getHeaders()->has('X-Bus-Transport')) { 34 | return; 35 | } 36 | 37 | $names = $message->getHeaders()->get('X-Bus-Transport')->getBody(); 38 | $names = array_map('trim', explode(',', $names)); 39 | $event->addStamp(new TransportNamesStamp($names)); 40 | $message->getHeaders()->remove('X-Bus-Transport'); 41 | } 42 | 43 | public static function getSubscribedEvents(): array 44 | { 45 | return [ 46 | MessageEvent::class => 'onMessage', 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /EventListener/SmimeCertificateRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | /** 15 | * Encrypts messages using S/MIME. 16 | * 17 | * @author Florent Morselli 18 | */ 19 | interface SmimeCertificateRepositoryInterface 20 | { 21 | /** 22 | * @return ?string The path to the certificate. null if not found 23 | */ 24 | public function findCertificatePathFor(string $email): ?string; 25 | } 26 | -------------------------------------------------------------------------------- /EventListener/SmimeEncryptedMessageListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mime\Crypto\SMimeEncrypter; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * Encrypts messages using S/MIME. 21 | * 22 | * @author Elías Fernández 23 | */ 24 | final class SmimeEncryptedMessageListener implements EventSubscriberInterface 25 | { 26 | public function __construct( 27 | private readonly SmimeCertificateRepositoryInterface $smimeRepository, 28 | private readonly ?int $cipher = null, 29 | ) { 30 | } 31 | 32 | public function onMessage(MessageEvent $event): void 33 | { 34 | $message = $event->getMessage(); 35 | if (!$message instanceof Message) { 36 | return; 37 | } 38 | if (!$message->getHeaders()->has('X-SMime-Encrypt')) { 39 | return; 40 | } 41 | $message->getHeaders()->remove('X-SMime-Encrypt'); 42 | $certificatePaths = []; 43 | foreach ($event->getEnvelope()->getRecipients() as $recipient) { 44 | $certificatePath = $this->smimeRepository->findCertificatePathFor($recipient->getAddress()); 45 | if (null === $certificatePath) { 46 | return; 47 | } 48 | $certificatePaths[] = $certificatePath; 49 | } 50 | if (0 === \count($certificatePaths)) { 51 | return; 52 | } 53 | $encrypter = new SMimeEncrypter($certificatePaths, $this->cipher); 54 | 55 | $event->setMessage($encrypter->encrypt($message)); 56 | } 57 | 58 | public static function getSubscribedEvents(): array 59 | { 60 | return [ 61 | MessageEvent::class => ['onMessage', -128], 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /EventListener/SmimeSignedMessageListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mime\Crypto\SMimeSigner; 17 | use Symfony\Component\Mime\Message; 18 | 19 | /** 20 | * Signs messages using S/MIME. 21 | * 22 | * @author Elías Fernández 23 | */ 24 | class SmimeSignedMessageListener implements EventSubscriberInterface 25 | { 26 | public function __construct( 27 | private SMimeSigner $signer, 28 | ) { 29 | } 30 | 31 | public function onMessage(MessageEvent $event): void 32 | { 33 | $message = $event->getMessage(); 34 | if (!$message instanceof Message) { 35 | return; 36 | } 37 | 38 | $event->setMessage($this->signer->sign($message)); 39 | } 40 | 41 | public static function getSubscribedEvents(): array 42 | { 43 | return [ 44 | MessageEvent::class => ['onMessage', -128], 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * Exception interface for all exceptions thrown by the component. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/HttpTransportException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\ResponseInterface; 15 | 16 | /** 17 | * @author Fabien Potencier 18 | */ 19 | class HttpTransportException extends TransportException 20 | { 21 | public function __construct( 22 | string $message, 23 | private ResponseInterface $response, 24 | int $code = 0, 25 | ?\Throwable $previous = null, 26 | ) { 27 | parent::__construct($message, $code, $previous); 28 | } 29 | 30 | public function getResponse(): ResponseInterface 31 | { 32 | return $this->response; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Exception/IncompleteDsnException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Konstantin Myakshin 16 | */ 17 | class IncompleteDsnException extends InvalidArgumentException 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/LogicException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | class LogicException extends \LogicException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | class RuntimeException extends \RuntimeException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/TransportException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | class TransportException extends RuntimeException implements TransportExceptionInterface 18 | { 19 | private string $debug = ''; 20 | 21 | public function getDebug(): string 22 | { 23 | return $this->debug; 24 | } 25 | 26 | public function appendDebug(string $debug): void 27 | { 28 | $this->debug .= $debug; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Exception/TransportExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | interface TransportExceptionInterface extends ExceptionInterface 18 | { 19 | public function getDebug(): string; 20 | 21 | public function appendDebug(string $debug): void; 22 | } 23 | -------------------------------------------------------------------------------- /Exception/UnexpectedResponseException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | class UnexpectedResponseException extends TransportException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /Exception/UnsupportedSchemeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Exception; 13 | 14 | use Symfony\Component\Mailer\Bridge; 15 | use Symfony\Component\Mailer\Transport\Dsn; 16 | 17 | /** 18 | * @author Konstantin Myakshin 19 | */ 20 | class UnsupportedSchemeException extends LogicException 21 | { 22 | private const SCHEME_TO_PACKAGE_MAP = [ 23 | 'ahasend' => [ 24 | 'class' => Bridge\AhaSend\Transport\AhaSendTransportFactory::class, 25 | 'package' => 'symfony/aha-send-mailer', 26 | ], 27 | 'azure' => [ 28 | 'class' => Bridge\Azure\Transport\AzureTransportFactory::class, 29 | 'package' => 'symfony/azure-mailer', 30 | ], 31 | 'brevo' => [ 32 | 'class' => Bridge\Brevo\Transport\BrevoTransportFactory::class, 33 | 'package' => 'symfony/brevo-mailer', 34 | ], 35 | 'gmail' => [ 36 | 'class' => Bridge\Google\Transport\GmailTransportFactory::class, 37 | 'package' => 'symfony/google-mailer', 38 | ], 39 | 'infobip' => [ 40 | 'class' => Bridge\Infobip\Transport\InfobipTransportFactory::class, 41 | 'package' => 'symfony/infobip-mailer', 42 | ], 43 | 'mailersend' => [ 44 | 'class' => Bridge\MailerSend\Transport\MailerSendTransportFactory::class, 45 | 'package' => 'symfony/mailersend-mailer', 46 | ], 47 | 'mailgun' => [ 48 | 'class' => Bridge\Mailgun\Transport\MailgunTransportFactory::class, 49 | 'package' => 'symfony/mailgun-mailer', 50 | ], 51 | 'mailjet' => [ 52 | 'class' => Bridge\Mailjet\Transport\MailjetTransportFactory::class, 53 | 'package' => 'symfony/mailjet-mailer', 54 | ], 55 | 'mailomat' => [ 56 | 'class' => Bridge\Mailomat\Transport\MailomatTransportFactory::class, 57 | 'package' => 'symfony/mailomat-mailer', 58 | ], 59 | 'mailpace' => [ 60 | 'class' => Bridge\MailPace\Transport\MailPaceTransportFactory::class, 61 | 'package' => 'symfony/mail-pace-mailer', 62 | ], 63 | 'mandrill' => [ 64 | 'class' => Bridge\Mailchimp\Transport\MandrillTransportFactory::class, 65 | 'package' => 'symfony/mailchimp-mailer', 66 | ], 67 | 'postal' => [ 68 | 'class' => Bridge\Postal\Transport\PostalTransportFactory::class, 69 | 'package' => 'symfony/postal-mailer', 70 | ], 71 | 'postmark' => [ 72 | 'class' => Bridge\Postmark\Transport\PostmarkTransportFactory::class, 73 | 'package' => 'symfony/postmark-mailer', 74 | ], 75 | 'mailtrap' => [ 76 | 'class' => Bridge\Mailtrap\Transport\MailtrapTransportFactory::class, 77 | 'package' => 'symfony/mailtrap-mailer', 78 | ], 79 | 'resend' => [ 80 | 'class' => Bridge\Resend\Transport\ResendTransportFactory::class, 81 | 'package' => 'symfony/resend-mailer', 82 | ], 83 | 'scaleway' => [ 84 | 'class' => Bridge\Scaleway\Transport\ScalewayTransportFactory::class, 85 | 'package' => 'symfony/scaleway-mailer', 86 | ], 87 | 'sendgrid' => [ 88 | 'class' => Bridge\Sendgrid\Transport\SendgridTransportFactory::class, 89 | 'package' => 'symfony/sendgrid-mailer', 90 | ], 91 | 'ses' => [ 92 | 'class' => Bridge\Amazon\Transport\SesTransportFactory::class, 93 | 'package' => 'symfony/amazon-mailer', 94 | ], 95 | 'sweego' => [ 96 | 'class' => Bridge\Sweego\Transport\SweegoTransportFactory::class, 97 | 'package' => 'symfony/sweego-mailer', 98 | ], 99 | ]; 100 | 101 | public function __construct(Dsn $dsn, ?string $name = null, array $supported = []) 102 | { 103 | $provider = $dsn->getScheme(); 104 | if (false !== $pos = strpos($provider, '+')) { 105 | $provider = substr($provider, 0, $pos); 106 | } 107 | $package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null; 108 | if ($package && !class_exists($package['class'])) { 109 | parent::__construct(\sprintf('Unable to send emails via "%s" as the bridge is not installed. Try running "composer require %s".', $provider, $package['package'])); 110 | 111 | return; 112 | } 113 | 114 | $message = \sprintf('The "%s" scheme is not supported', $dsn->getScheme()); 115 | if ($name && $supported) { 116 | $message .= \sprintf('; supported schemes for mailer "%s" are: "%s"', $name, implode('", "', $supported)); 117 | } 118 | 119 | parent::__construct($message.'.'); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Header/MetadataHeader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Header; 13 | 14 | use Symfony\Component\Mime\Header\UnstructuredHeader; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class MetadataHeader extends UnstructuredHeader 20 | { 21 | public function __construct( 22 | private string $key, 23 | string $value, 24 | ) { 25 | parent::__construct('X-Metadata-'.$key, $value); 26 | } 27 | 28 | public function getKey(): string 29 | { 30 | return $this->key; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Header/TagHeader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Header; 13 | 14 | use Symfony\Component\Mime\Header\UnstructuredHeader; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class TagHeader extends UnstructuredHeader 20 | { 21 | public function __construct(string $value) 22 | { 23 | parent::__construct('X-Tag', $value); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Mailer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 17 | use Symfony\Component\Mailer\Messenger\SendEmailMessage; 18 | use Symfony\Component\Mailer\Transport\TransportInterface; 19 | use Symfony\Component\Messenger\Exception\HandlerFailedException; 20 | use Symfony\Component\Messenger\MessageBusInterface; 21 | use Symfony\Component\Mime\RawMessage; 22 | 23 | /** 24 | * @author Fabien Potencier 25 | */ 26 | final class Mailer implements MailerInterface 27 | { 28 | public function __construct( 29 | private TransportInterface $transport, 30 | private ?MessageBusInterface $bus = null, 31 | private ?EventDispatcherInterface $dispatcher = null, 32 | ) { 33 | } 34 | 35 | public function send(RawMessage $message, ?Envelope $envelope = null): void 36 | { 37 | if (null === $this->bus) { 38 | $this->transport->send($message, $envelope); 39 | 40 | return; 41 | } 42 | 43 | $stamps = []; 44 | if (null !== $this->dispatcher) { 45 | // The dispatched event here has `queued` set to `true`; the goal is NOT to render the message, but to let 46 | // listeners do something before a message is sent to the queue. 47 | // We are using a cloned message as we still want to dispatch the **original** message, not the one modified by listeners. 48 | // That's because the listeners will run again when the email is sent via Messenger by the transport (see `AbstractTransport`). 49 | // Listeners should act depending on the `$queued` argument of the `MessageEvent` instance. 50 | $clonedMessage = clone $message; 51 | $clonedEnvelope = null !== $envelope ? clone $envelope : Envelope::create($clonedMessage); 52 | $event = new MessageEvent($clonedMessage, $clonedEnvelope, (string) $this->transport, true); 53 | $this->dispatcher->dispatch($event); 54 | $stamps = $event->getStamps(); 55 | 56 | if ($event->isRejected()) { 57 | return; 58 | } 59 | } 60 | 61 | try { 62 | $this->bus->dispatch(new SendEmailMessage($message, $envelope), $stamps); 63 | } catch (HandlerFailedException $e) { 64 | foreach ($e->getWrappedExceptions() as $nested) { 65 | if ($nested instanceof TransportExceptionInterface) { 66 | throw $nested; 67 | } 68 | } 69 | throw $e; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /MailerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 15 | use Symfony\Component\Mime\RawMessage; 16 | 17 | /** 18 | * Interface for mailers able to send emails synchronously and/or asynchronously. 19 | * 20 | * Implementations must support synchronous and asynchronous sending. 21 | * 22 | * @author Fabien Potencier 23 | */ 24 | interface MailerInterface 25 | { 26 | /** 27 | * @throws TransportExceptionInterface 28 | */ 29 | public function send(RawMessage $message, ?Envelope $envelope = null): void; 30 | } 31 | -------------------------------------------------------------------------------- /Messenger/MessageHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Messenger; 13 | 14 | use Symfony\Component\Mailer\SentMessage; 15 | use Symfony\Component\Mailer\Transport\TransportInterface; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | class MessageHandler 21 | { 22 | public function __construct( 23 | private TransportInterface $transport, 24 | ) { 25 | } 26 | 27 | public function __invoke(SendEmailMessage $message): ?SentMessage 28 | { 29 | return $this->transport->send($message->getMessage(), $message->getEnvelope()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Messenger/SendEmailMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Messenger; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mime\RawMessage; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | class SendEmailMessage 21 | { 22 | public function __construct( 23 | private RawMessage $message, 24 | private ?Envelope $envelope = null, 25 | ) { 26 | } 27 | 28 | public function getMessage(): RawMessage 29 | { 30 | return $this->message; 31 | } 32 | 33 | public function getEnvelope(): ?Envelope 34 | { 35 | return $this->envelope; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mailer Component 2 | ================ 3 | 4 | The Mailer component helps sending emails. 5 | 6 | Getting Started 7 | --------------- 8 | 9 | ```bash 10 | composer require symfony/mailer 11 | ``` 12 | 13 | ```php 14 | use Symfony\Component\Mailer\Transport; 15 | use Symfony\Component\Mailer\Mailer; 16 | use Symfony\Component\Mime\Email; 17 | 18 | $transport = Transport::fromDsn('smtp://localhost'); 19 | $mailer = new Mailer($transport); 20 | 21 | $email = (new Email()) 22 | ->from('hello@example.com') 23 | ->to('you@example.com') 24 | //->cc('cc@example.com') 25 | //->bcc('bcc@example.com') 26 | //->replyTo('fabien@example.com') 27 | //->priority(Email::PRIORITY_HIGH) 28 | ->subject('Time for Symfony Mailer!') 29 | ->text('Sending emails is fun again!') 30 | ->html('

See Twig integration for better HTML integration!

'); 31 | 32 | $mailer->send($email); 33 | ``` 34 | 35 | To enable the Twig integration of the Mailer, require `symfony/twig-bridge` and 36 | set up the `BodyRenderer`: 37 | 38 | ```php 39 | use Symfony\Bridge\Twig\Mime\BodyRenderer; 40 | use Symfony\Bridge\Twig\Mime\TemplatedEmail; 41 | use Symfony\Component\EventDispatcher\EventDispatcher; 42 | use Symfony\Component\Mailer\EventListener\MessageListener; 43 | use Symfony\Component\Mailer\Mailer; 44 | use Symfony\Component\Mailer\Transport; 45 | use Twig\Environment as TwigEnvironment; 46 | 47 | $twig = new TwigEnvironment(...); 48 | $messageListener = new MessageListener(null, new BodyRenderer($twig)); 49 | 50 | $eventDispatcher = new EventDispatcher(); 51 | $eventDispatcher->addSubscriber($messageListener); 52 | 53 | $transport = Transport::fromDsn('smtp://localhost', $eventDispatcher); 54 | $mailer = new Mailer($transport, null, $eventDispatcher); 55 | 56 | $email = (new TemplatedEmail()) 57 | // ... 58 | ->htmlTemplate('emails/signup.html.twig') 59 | ->context([ 60 | 'expiration_date' => new \DateTimeImmutable('+7 days'), 61 | 'username' => 'foo', 62 | ]) 63 | ; 64 | $mailer->send($email); 65 | ``` 66 | 67 | Sponsor 68 | ------- 69 | 70 | The Mailer component for Symfony 7.2 is [backed][1] by: 71 | 72 | * [Sweego][2], a European email and SMS sending platform for developers and product builders. Easily create, deliver, and monitor your emails and notifications. 73 | 74 | Help Symfony by [sponsoring][3] its development! 75 | 76 | Resources 77 | --------- 78 | 79 | * [Documentation](https://symfony.com/doc/current/mailer.html) 80 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 81 | * [Report issues](https://github.com/symfony/symfony/issues) and 82 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 83 | in the [main Symfony repository](https://github.com/symfony/symfony) 84 | 85 | [1]: https://symfony.com/backers 86 | [2]: https://www.sweego.io/ 87 | [3]: https://symfony.com/sponsor 88 | -------------------------------------------------------------------------------- /SentMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Symfony\Component\Mime\Message; 15 | use Symfony\Component\Mime\RawMessage; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | class SentMessage 21 | { 22 | private RawMessage $original; 23 | private RawMessage $raw; 24 | private string $messageId; 25 | private string $debug = ''; 26 | 27 | /** 28 | * @internal 29 | */ 30 | public function __construct( 31 | RawMessage $message, 32 | private Envelope $envelope, 33 | ) { 34 | $message->ensureValidity(); 35 | 36 | $this->original = $message; 37 | 38 | if ($message instanceof Message) { 39 | $message = clone $message; 40 | $headers = $message->getHeaders(); 41 | if (!$headers->has('Message-ID')) { 42 | $headers->addIdHeader('Message-ID', $message->generateMessageId()); 43 | } 44 | $this->messageId = $headers->get('Message-ID')->getId(); 45 | $this->raw = new RawMessage($message->toIterable()); 46 | } else { 47 | $this->raw = $message; 48 | } 49 | } 50 | 51 | public function getMessage(): RawMessage 52 | { 53 | return $this->raw; 54 | } 55 | 56 | public function getOriginalMessage(): RawMessage 57 | { 58 | return $this->original; 59 | } 60 | 61 | public function getEnvelope(): Envelope 62 | { 63 | return $this->envelope; 64 | } 65 | 66 | public function setMessageId(string $id): void 67 | { 68 | $this->messageId = $id; 69 | } 70 | 71 | public function getMessageId(): string 72 | { 73 | return $this->messageId; 74 | } 75 | 76 | public function getDebug(): string 77 | { 78 | return $this->debug; 79 | } 80 | 81 | public function appendDebug(string $debug): void 82 | { 83 | $this->debug .= $debug; 84 | } 85 | 86 | public function toString(): string 87 | { 88 | return $this->raw->toString(); 89 | } 90 | 91 | public function toIterable(): iterable 92 | { 93 | return $this->raw->toIterable(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Test/AbstractTransportFactoryTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use PHPUnit\Framework\TestCase; 16 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 17 | use Symfony\Component\Mailer\Transport\Dsn; 18 | use Symfony\Component\Mailer\Transport\TransportFactoryInterface; 19 | use Symfony\Component\Mailer\Transport\TransportInterface; 20 | 21 | abstract class AbstractTransportFactoryTestCase extends TestCase 22 | { 23 | protected const USER = 'u$er'; 24 | protected const PASSWORD = 'pa$s'; 25 | 26 | abstract public function getFactory(): TransportFactoryInterface; 27 | 28 | /** 29 | * @psalm-return iterable 30 | */ 31 | abstract public static function supportsProvider(): iterable; 32 | 33 | /** 34 | * @psalm-return iterable 35 | */ 36 | abstract public static function createProvider(): iterable; 37 | 38 | /** 39 | * @psalm-return iterable 40 | */ 41 | abstract public static function unsupportedSchemeProvider(): iterable; 42 | 43 | /** 44 | * @dataProvider supportsProvider 45 | */ 46 | #[DataProvider('supportsProvider')] 47 | public function testSupports(Dsn $dsn, bool $supports) 48 | { 49 | $factory = $this->getFactory(); 50 | 51 | $this->assertSame($supports, $factory->supports($dsn)); 52 | } 53 | 54 | /** 55 | * @dataProvider createProvider 56 | */ 57 | #[DataProvider('createProvider')] 58 | public function testCreate(Dsn $dsn, TransportInterface $transport) 59 | { 60 | $factory = $this->getFactory(); 61 | 62 | $this->assertEquals($transport, $factory->create($dsn)); 63 | if (str_contains('smtp', $dsn->getScheme())) { 64 | $this->assertStringMatchesFormat($dsn->getScheme().'://%S'.$dsn->getHost().'%S', (string) $transport); 65 | } 66 | } 67 | 68 | /** 69 | * @dataProvider unsupportedSchemeProvider 70 | */ 71 | #[DataProvider('unsupportedSchemeProvider')] 72 | public function testUnsupportedSchemeException(Dsn $dsn, ?string $message = null) 73 | { 74 | $factory = $this->getFactory(); 75 | 76 | $this->expectException(UnsupportedSchemeException::class); 77 | if (null !== $message) { 78 | $this->expectExceptionMessage($message); 79 | } 80 | 81 | $factory->create($dsn); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Test/Constraint/EmailCount.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Test\Constraint; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\Mailer\Event\MessageEvents; 16 | 17 | final class EmailCount extends Constraint 18 | { 19 | public function __construct( 20 | private int $expectedValue, 21 | private ?string $transport = null, 22 | private bool $queued = false, 23 | ) { 24 | } 25 | 26 | public function toString(): string 27 | { 28 | return \sprintf('%shas %s "%d" emails', $this->transport ? $this->transport.' ' : '', $this->queued ? 'queued' : 'sent', $this->expectedValue); 29 | } 30 | 31 | /** 32 | * @param MessageEvents $events 33 | */ 34 | protected function matches($events): bool 35 | { 36 | return $this->expectedValue === $this->countEmails($events); 37 | } 38 | 39 | /** 40 | * @param MessageEvents $events 41 | */ 42 | protected function failureDescription($events): string 43 | { 44 | return \sprintf('the Transport %s (%d %s)', $this->toString(), $this->countEmails($events), $this->queued ? 'queued' : 'sent'); 45 | } 46 | 47 | private function countEmails(MessageEvents $events): int 48 | { 49 | $count = 0; 50 | foreach ($events->getEvents($this->transport) as $event) { 51 | if ( 52 | ($this->queued && $event->isQueued()) 53 | || (!$this->queued && !$event->isQueued()) 54 | ) { 55 | ++$count; 56 | } 57 | } 58 | 59 | return $count; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Test/Constraint/EmailIsQueued.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Test\Constraint; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\Mailer\Event\MessageEvent; 16 | 17 | final class EmailIsQueued extends Constraint 18 | { 19 | public function toString(): string 20 | { 21 | return 'is queued'; 22 | } 23 | 24 | /** 25 | * @param MessageEvent $event 26 | */ 27 | protected function matches($event): bool 28 | { 29 | return $event->isQueued(); 30 | } 31 | 32 | /** 33 | * @param MessageEvent $event 34 | */ 35 | protected function failureDescription($event): string 36 | { 37 | return 'the Email '.$this->toString(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Test/IncompleteDsnTestTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use Symfony\Component\Mailer\Exception\IncompleteDsnException; 16 | use Symfony\Component\Mailer\Transport\Dsn; 17 | 18 | trait IncompleteDsnTestTrait 19 | { 20 | /** 21 | * @psalm-return iterable 22 | */ 23 | abstract public static function incompleteDsnProvider(): iterable; 24 | 25 | /** 26 | * @dataProvider incompleteDsnProvider 27 | */ 28 | #[DataProvider('incompleteDsnProvider')] 29 | public function testIncompleteDsnException(Dsn $dsn) 30 | { 31 | $factory = $this->getFactory(); 32 | 33 | $this->expectException(IncompleteDsnException::class); 34 | $factory->create($dsn); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Test/TransportFactoryTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Test; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Component\Mailer\Transport\Dsn; 16 | use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; 17 | use Symfony\Contracts\HttpClient\HttpClientInterface; 18 | 19 | /** 20 | * A test case to ease testing Transport Factory. 21 | * 22 | * @author Konstantin Myakshin 23 | * 24 | * @deprecated since Symfony 7.2, use AbstractTransportFactoryTestCase instead 25 | */ 26 | abstract class TransportFactoryTestCase extends AbstractTransportFactoryTestCase 27 | { 28 | use IncompleteDsnTestTrait; 29 | 30 | protected EventDispatcherInterface $dispatcher; 31 | protected HttpClientInterface $client; 32 | protected LoggerInterface $logger; 33 | 34 | /** 35 | * @psalm-return iterable 36 | */ 37 | public static function unsupportedSchemeProvider(): iterable 38 | { 39 | return []; 40 | } 41 | 42 | /** 43 | * @psalm-return iterable 44 | */ 45 | public static function incompleteDsnProvider(): iterable 46 | { 47 | return []; 48 | } 49 | 50 | protected function getDispatcher(): EventDispatcherInterface 51 | { 52 | return $this->dispatcher ??= $this->createMock(EventDispatcherInterface::class); 53 | } 54 | 55 | protected function getClient(): HttpClientInterface 56 | { 57 | return $this->client ??= $this->createMock(HttpClientInterface::class); 58 | } 59 | 60 | protected function getLogger(): LoggerInterface 61 | { 62 | return $this->logger ??= $this->createMock(LoggerInterface::class); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Transport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; 17 | use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; 18 | use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; 19 | use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; 20 | use Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory; 21 | use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; 22 | use Symfony\Component\Mailer\Bridge\Mailchimp\Transport\MandrillTransportFactory; 23 | use Symfony\Component\Mailer\Bridge\MailerSend\Transport\MailerSendTransportFactory; 24 | use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; 25 | use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; 26 | use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatTransportFactory; 27 | use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; 28 | use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapTransportFactory; 29 | use Symfony\Component\Mailer\Bridge\Postal\Transport\PostalTransportFactory; 30 | use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; 31 | use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; 32 | use Symfony\Component\Mailer\Bridge\Scaleway\Transport\ScalewayTransportFactory; 33 | use Symfony\Component\Mailer\Bridge\Sendgrid\Transport\SendgridTransportFactory; 34 | use Symfony\Component\Mailer\Bridge\Sweego\Transport\SweegoTransportFactory; 35 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 36 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 37 | use Symfony\Component\Mailer\Transport\Dsn; 38 | use Symfony\Component\Mailer\Transport\FailoverTransport; 39 | use Symfony\Component\Mailer\Transport\NativeTransportFactory; 40 | use Symfony\Component\Mailer\Transport\NullTransportFactory; 41 | use Symfony\Component\Mailer\Transport\RoundRobinTransport; 42 | use Symfony\Component\Mailer\Transport\SendmailTransportFactory; 43 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory; 44 | use Symfony\Component\Mailer\Transport\TransportFactoryInterface; 45 | use Symfony\Component\Mailer\Transport\TransportInterface; 46 | use Symfony\Component\Mailer\Transport\Transports; 47 | use Symfony\Contracts\HttpClient\HttpClientInterface; 48 | 49 | /** 50 | * @author Fabien Potencier 51 | * @author Konstantin Myakshin 52 | */ 53 | final class Transport 54 | { 55 | private const FACTORY_CLASSES = [ 56 | AhaSendTransportFactory::class, 57 | AzureTransportFactory::class, 58 | BrevoTransportFactory::class, 59 | GmailTransportFactory::class, 60 | InfobipTransportFactory::class, 61 | MailerSendTransportFactory::class, 62 | MailgunTransportFactory::class, 63 | MailjetTransportFactory::class, 64 | MailomatTransportFactory::class, 65 | MailPaceTransportFactory::class, 66 | MandrillTransportFactory::class, 67 | PostalTransportFactory::class, 68 | PostmarkTransportFactory::class, 69 | MailtrapTransportFactory::class, 70 | ResendTransportFactory::class, 71 | ScalewayTransportFactory::class, 72 | SendgridTransportFactory::class, 73 | SesTransportFactory::class, 74 | SweegoTransportFactory::class, 75 | ]; 76 | 77 | public static function fromDsn(#[\SensitiveParameter] string $dsn, ?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): TransportInterface 78 | { 79 | $factory = new self(iterator_to_array(self::getDefaultFactories($dispatcher, $client, $logger))); 80 | 81 | return $factory->fromString($dsn); 82 | } 83 | 84 | public static function fromDsns(#[\SensitiveParameter] array $dsns, ?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): TransportInterface 85 | { 86 | $factory = new self(iterator_to_array(self::getDefaultFactories($dispatcher, $client, $logger))); 87 | 88 | return $factory->fromStrings($dsns); 89 | } 90 | 91 | /** 92 | * @param TransportFactoryInterface[] $factories 93 | */ 94 | public function __construct( 95 | private iterable $factories, 96 | ) { 97 | } 98 | 99 | public function fromStrings(#[\SensitiveParameter] array $dsns): Transports 100 | { 101 | $transports = []; 102 | foreach ($dsns as $name => $dsn) { 103 | $transports[$name] = $this->fromString($dsn); 104 | } 105 | 106 | return new Transports($transports); 107 | } 108 | 109 | public function fromString(#[\SensitiveParameter] string $dsn): TransportInterface 110 | { 111 | [$transport, $offset] = $this->parseDsn($dsn); 112 | if ($offset !== \strlen($dsn)) { 113 | throw new InvalidArgumentException('The mailer DSN has some garbage at the end.'); 114 | } 115 | 116 | return $transport; 117 | } 118 | 119 | private function parseDsn(#[\SensitiveParameter] string $dsn, int $offset = 0): array 120 | { 121 | static $keywords = [ 122 | 'failover' => FailoverTransport::class, 123 | 'roundrobin' => RoundRobinTransport::class, 124 | ]; 125 | 126 | while (true) { 127 | foreach ($keywords as $name => $class) { 128 | $name .= '('; 129 | if ($name === substr($dsn, $offset, \strlen($name))) { 130 | $offset += \strlen($name) - 1; 131 | preg_match('{\(([^()]|(?R))*\)}A', $dsn, $matches, 0, $offset); 132 | if (!isset($matches[0])) { 133 | continue; 134 | } 135 | 136 | ++$offset; 137 | $args = []; 138 | while (true) { 139 | [$arg, $offset] = $this->parseDsn($dsn, $offset); 140 | $args[] = $arg; 141 | if (\strlen($dsn) === $offset) { 142 | break; 143 | } 144 | ++$offset; 145 | if (')' === $dsn[$offset - 1]) { 146 | break; 147 | } 148 | } 149 | 150 | parse_str(substr($dsn, $offset + 1), $query); 151 | if ($period = $query['retry_period'] ?? 0) { 152 | return [new $class($args, (int) $period), $offset + \strlen('retry_period='.$period) + 1]; 153 | } 154 | 155 | return [new $class($args), $offset]; 156 | } 157 | } 158 | 159 | if (preg_match('{(\w+)\(}A', $dsn, $matches, 0, $offset)) { 160 | throw new InvalidArgumentException(\sprintf('The "%s" keyword is not valid (valid ones are "%s"), ', $matches[1], implode('", "', array_keys($keywords)))); 161 | } 162 | 163 | if ($pos = strcspn($dsn, ' )', $offset)) { 164 | return [$this->fromDsnObject(Dsn::fromString(substr($dsn, $offset, $pos))), $offset + $pos]; 165 | } 166 | 167 | return [$this->fromDsnObject(Dsn::fromString(substr($dsn, $offset))), \strlen($dsn)]; 168 | } 169 | } 170 | 171 | public function fromDsnObject(Dsn $dsn): TransportInterface 172 | { 173 | foreach ($this->factories as $factory) { 174 | if ($factory->supports($dsn)) { 175 | return $factory->create($dsn); 176 | } 177 | } 178 | 179 | throw new UnsupportedSchemeException($dsn); 180 | } 181 | 182 | /** 183 | * @return \Traversable 184 | */ 185 | public static function getDefaultFactories(?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): \Traversable 186 | { 187 | foreach (self::FACTORY_CLASSES as $factoryClass) { 188 | if (class_exists($factoryClass)) { 189 | yield new $factoryClass($dispatcher, $client, $logger); 190 | } 191 | } 192 | 193 | yield new NullTransportFactory($dispatcher, $client, $logger); 194 | 195 | yield new SendmailTransportFactory($dispatcher, $client, $logger); 196 | 197 | yield new EsmtpTransportFactory($dispatcher, $client, $logger); 198 | 199 | yield new NativeTransportFactory($dispatcher, $client, $logger); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Transport/AbstractApiTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Exception\RuntimeException; 16 | use Symfony\Component\Mailer\SentMessage; 17 | use Symfony\Component\Mime\Address; 18 | use Symfony\Component\Mime\Email; 19 | use Symfony\Component\Mime\MessageConverter; 20 | use Symfony\Contracts\HttpClient\ResponseInterface; 21 | 22 | /** 23 | * @author Fabien Potencier 24 | */ 25 | abstract class AbstractApiTransport extends AbstractHttpTransport 26 | { 27 | abstract protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface; 28 | 29 | protected function doSendHttp(SentMessage $message): ResponseInterface 30 | { 31 | try { 32 | $email = MessageConverter::toEmail($message->getOriginalMessage()); 33 | } catch (\Exception $e) { 34 | throw new RuntimeException(\sprintf('Unable to send message with the "%s" transport: ', __CLASS__).$e->getMessage(), 0, $e); 35 | } 36 | 37 | return $this->doSendApi($message, $email, $message->getEnvelope()); 38 | } 39 | 40 | /** 41 | * @return Address[] 42 | */ 43 | protected function getRecipients(Email $email, Envelope $envelope): array 44 | { 45 | return array_filter($envelope->getRecipients(), fn (Address $address) => false === \in_array($address, array_merge($email->getCc(), $email->getBcc()), true)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Transport/AbstractHttpTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\HttpClient\HttpClient; 17 | use Symfony\Component\Mailer\Exception\HttpTransportException; 18 | use Symfony\Component\Mailer\SentMessage; 19 | use Symfony\Contracts\HttpClient\HttpClientInterface; 20 | use Symfony\Contracts\HttpClient\ResponseInterface; 21 | 22 | /** 23 | * @author Victor Bocharsky 24 | */ 25 | abstract class AbstractHttpTransport extends AbstractTransport 26 | { 27 | protected ?string $host = null; 28 | protected ?int $port = null; 29 | 30 | public function __construct( 31 | protected ?HttpClientInterface $client = null, 32 | ?EventDispatcherInterface $dispatcher = null, 33 | ?LoggerInterface $logger = null, 34 | ) { 35 | if (null === $client) { 36 | if (!class_exists(HttpClient::class)) { 37 | throw new \LogicException(\sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); 38 | } 39 | 40 | $this->client = HttpClient::create(); 41 | } 42 | 43 | parent::__construct($dispatcher, $logger); 44 | } 45 | 46 | /** 47 | * @return $this 48 | */ 49 | public function setHost(?string $host): static 50 | { 51 | $this->host = $host; 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * @return $this 58 | */ 59 | public function setPort(?int $port): static 60 | { 61 | $this->port = $port; 62 | 63 | return $this; 64 | } 65 | 66 | abstract protected function doSendHttp(SentMessage $message): ResponseInterface; 67 | 68 | protected function doSend(SentMessage $message): void 69 | { 70 | try { 71 | $response = $this->doSendHttp($message); 72 | $message->appendDebug($response->getInfo('debug') ?? ''); 73 | } catch (HttpTransportException $e) { 74 | $e->appendDebug($e->getResponse()->getInfo('debug') ?? ''); 75 | 76 | throw $e; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Transport/AbstractTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Psr\Log\NullLogger; 17 | use Symfony\Bridge\Twig\Mime\TemplatedEmail; 18 | use Symfony\Component\Mailer\Envelope; 19 | use Symfony\Component\Mailer\Event\FailedMessageEvent; 20 | use Symfony\Component\Mailer\Event\MessageEvent; 21 | use Symfony\Component\Mailer\Event\SentMessageEvent; 22 | use Symfony\Component\Mailer\Exception\LogicException; 23 | use Symfony\Component\Mailer\SentMessage; 24 | use Symfony\Component\Mime\Address; 25 | use Symfony\Component\Mime\BodyRendererInterface; 26 | use Symfony\Component\Mime\RawMessage; 27 | 28 | /** 29 | * @author Fabien Potencier 30 | */ 31 | abstract class AbstractTransport implements TransportInterface 32 | { 33 | private LoggerInterface $logger; 34 | private float $rate = 0; 35 | private float $lastSent = 0; 36 | 37 | public function __construct( 38 | private ?EventDispatcherInterface $dispatcher = null, 39 | ?LoggerInterface $logger = null, 40 | ) { 41 | $this->logger = $logger ?? new NullLogger(); 42 | } 43 | 44 | /** 45 | * Sets the maximum number of messages to send per second (0 to disable). 46 | * 47 | * @return $this 48 | */ 49 | public function setMaxPerSecond(float $rate): static 50 | { 51 | if (0 >= $rate) { 52 | $rate = 0; 53 | } 54 | 55 | $this->rate = $rate; 56 | $this->lastSent = 0; 57 | 58 | return $this; 59 | } 60 | 61 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 62 | { 63 | $message = clone $message; 64 | $envelope = null !== $envelope ? clone $envelope : Envelope::create($message); 65 | 66 | try { 67 | if (!$this->dispatcher) { 68 | $sentMessage = new SentMessage($message, $envelope); 69 | $this->doSend($sentMessage); 70 | 71 | return $sentMessage; 72 | } 73 | 74 | $event = new MessageEvent($message, $envelope, (string) $this); 75 | $this->dispatcher->dispatch($event); 76 | if ($event->isRejected()) { 77 | return null; 78 | } 79 | 80 | $envelope = $event->getEnvelope(); 81 | $message = $event->getMessage(); 82 | 83 | if ($message instanceof TemplatedEmail && !$message->isRendered()) { 84 | throw new LogicException(\sprintf('You must configure a "%s" when a "%s" instance has a text or HTML template set.', BodyRendererInterface::class, get_debug_type($message))); 85 | } 86 | 87 | $sentMessage = new SentMessage($message, $envelope); 88 | 89 | try { 90 | $this->doSend($sentMessage); 91 | } catch (\Throwable $error) { 92 | $this->dispatcher->dispatch(new FailedMessageEvent($message, $error)); 93 | $this->checkThrottling(); 94 | 95 | throw $error; 96 | } 97 | 98 | $this->dispatcher->dispatch(new SentMessageEvent($sentMessage)); 99 | 100 | return $sentMessage; 101 | } finally { 102 | $this->checkThrottling(); 103 | } 104 | } 105 | 106 | abstract protected function doSend(SentMessage $message): void; 107 | 108 | /** 109 | * @param Address[] $addresses 110 | * 111 | * @return string[] 112 | */ 113 | protected function stringifyAddresses(array $addresses): array 114 | { 115 | return array_map(fn (Address $a) => $a->toString(), $addresses); 116 | } 117 | 118 | protected function getLogger(): LoggerInterface 119 | { 120 | return $this->logger; 121 | } 122 | 123 | private function checkThrottling(): void 124 | { 125 | if (0 == $this->rate) { 126 | return; 127 | } 128 | 129 | $sleep = (1 / $this->rate) - (microtime(true) - $this->lastSent); 130 | if (0 < $sleep) { 131 | $this->logger->debug(\sprintf('Email transport "%s" sleeps for %.2f seconds', __CLASS__, $sleep)); 132 | usleep((int) ($sleep * 1000000)); 133 | } 134 | $this->lastSent = microtime(true); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Transport/AbstractTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Exception\IncompleteDsnException; 17 | use Symfony\Contracts\HttpClient\HttpClientInterface; 18 | 19 | /** 20 | * @author Konstantin Myakshin 21 | */ 22 | abstract class AbstractTransportFactory implements TransportFactoryInterface 23 | { 24 | public function __construct( 25 | protected ?EventDispatcherInterface $dispatcher = null, 26 | protected ?HttpClientInterface $client = null, 27 | protected ?LoggerInterface $logger = null, 28 | ) { 29 | } 30 | 31 | public function supports(Dsn $dsn): bool 32 | { 33 | return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true); 34 | } 35 | 36 | abstract protected function getSupportedSchemes(): array; 37 | 38 | protected function getUser(Dsn $dsn): string 39 | { 40 | return $dsn->getUser() ?? throw new IncompleteDsnException('User is not set.'); 41 | } 42 | 43 | protected function getPassword(Dsn $dsn): string 44 | { 45 | return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Transport/Dsn.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * @author Konstantin Myakshin 18 | */ 19 | final class Dsn 20 | { 21 | public function __construct( 22 | private string $scheme, 23 | private string $host, 24 | private ?string $user = null, 25 | #[\SensitiveParameter] private ?string $password = null, 26 | private ?int $port = null, 27 | private array $options = [], 28 | ) { 29 | } 30 | 31 | public static function fromString(#[\SensitiveParameter] string $dsn): self 32 | { 33 | if (false === $params = parse_url($dsn)) { 34 | throw new InvalidArgumentException('The mailer DSN is invalid.'); 35 | } 36 | 37 | if (!isset($params['scheme'])) { 38 | throw new InvalidArgumentException('The mailer DSN must contain a scheme.'); 39 | } 40 | 41 | if (!isset($params['host'])) { 42 | throw new InvalidArgumentException('The mailer DSN must contain a host (use "default" by default).'); 43 | } 44 | 45 | $user = '' !== ($params['user'] ?? '') ? rawurldecode($params['user']) : null; 46 | $password = '' !== ($params['pass'] ?? '') ? rawurldecode($params['pass']) : null; 47 | $port = $params['port'] ?? null; 48 | parse_str($params['query'] ?? '', $query); 49 | 50 | return new self($params['scheme'], $params['host'], $user, $password, $port, $query); 51 | } 52 | 53 | public function getScheme(): string 54 | { 55 | return $this->scheme; 56 | } 57 | 58 | public function getHost(): string 59 | { 60 | return $this->host; 61 | } 62 | 63 | public function getUser(): ?string 64 | { 65 | return $this->user; 66 | } 67 | 68 | public function getPassword(): ?string 69 | { 70 | return $this->password; 71 | } 72 | 73 | public function getPort(?int $default = null): ?int 74 | { 75 | return $this->port ?? $default; 76 | } 77 | 78 | public function getOption(string $key, mixed $default = null): mixed 79 | { 80 | return $this->options[$key] ?? $default; 81 | } 82 | 83 | public function getBooleanOption(string $key, bool $default = false): bool 84 | { 85 | return filter_var($this->getOption($key, $default), \FILTER_VALIDATE_BOOLEAN); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Transport/FailoverTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | /** 15 | * Uses several Transports using a failover algorithm. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class FailoverTransport extends RoundRobinTransport 20 | { 21 | private ?TransportInterface $currentTransport = null; 22 | 23 | protected function getNextTransport(): ?TransportInterface 24 | { 25 | if (null === $this->currentTransport || $this->isTransportDead($this->currentTransport)) { 26 | $this->currentTransport = parent::getNextTransport(); 27 | } 28 | 29 | return $this->currentTransport; 30 | } 31 | 32 | protected function getInitialCursor(): int 33 | { 34 | return 0; 35 | } 36 | 37 | protected function getNameSymbol(): string 38 | { 39 | return 'failover'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Transport/NativeTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportException; 15 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 16 | use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport; 17 | use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; 18 | 19 | /** 20 | * Factory that configures a transport (sendmail or SMTP) based on php.ini settings. 21 | * 22 | * @author Laurent VOULLEMIER 23 | */ 24 | final class NativeTransportFactory extends AbstractTransportFactory 25 | { 26 | public function create(Dsn $dsn): TransportInterface 27 | { 28 | if (!\in_array($dsn->getScheme(), $this->getSupportedSchemes(), true)) { 29 | throw new UnsupportedSchemeException($dsn, 'native', $this->getSupportedSchemes()); 30 | } 31 | 32 | if ($sendMailPath = ini_get('sendmail_path')) { 33 | return new SendmailTransport($sendMailPath, $this->dispatcher, $this->logger); 34 | } 35 | 36 | if ('\\' !== \DIRECTORY_SEPARATOR) { 37 | throw new TransportException('sendmail_path is not configured in php.ini.'); 38 | } 39 | 40 | // Only for windows hosts; at this point non-windows 41 | // host have already thrown an exception or returned a transport 42 | $host = ini_get('SMTP'); 43 | $port = (int) ini_get('smtp_port'); 44 | 45 | if (!$host || !$port) { 46 | throw new TransportException('smtp or smtp_port is not configured in php.ini.'); 47 | } 48 | 49 | $socketStream = new SocketStream(); 50 | $socketStream->setHost($host); 51 | $socketStream->setPort($port); 52 | if (465 !== $port) { 53 | $socketStream->disableTls(); 54 | } 55 | 56 | return new SmtpTransport($socketStream, $this->dispatcher, $this->logger); 57 | } 58 | 59 | protected function getSupportedSchemes(): array 60 | { 61 | return ['native']; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Transport/NullTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\SentMessage; 15 | 16 | /** 17 | * Pretends messages have been sent, but just ignores them. 18 | * 19 | * @author Fabien Potencier 20 | */ 21 | final class NullTransport extends AbstractTransport 22 | { 23 | protected function doSend(SentMessage $message): void 24 | { 25 | } 26 | 27 | public function __toString(): string 28 | { 29 | return 'null://'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Transport/NullTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 15 | 16 | /** 17 | * @author Konstantin Myakshin 18 | */ 19 | final class NullTransportFactory extends AbstractTransportFactory 20 | { 21 | public function create(Dsn $dsn): TransportInterface 22 | { 23 | if ('null' === $dsn->getScheme()) { 24 | return new NullTransport($this->dispatcher, $this->logger); 25 | } 26 | 27 | throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes()); 28 | } 29 | 30 | protected function getSupportedSchemes(): array 31 | { 32 | return ['null']; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Transport/RoundRobinTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Exception\TransportException; 16 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 17 | use Symfony\Component\Mailer\SentMessage; 18 | use Symfony\Component\Mime\RawMessage; 19 | 20 | /** 21 | * Uses several Transports using a round robin algorithm. 22 | * 23 | * @author Fabien Potencier 24 | */ 25 | class RoundRobinTransport implements TransportInterface 26 | { 27 | /** 28 | * @var \SplObjectStorage 29 | */ 30 | private \SplObjectStorage $deadTransports; 31 | private int $cursor = -1; 32 | 33 | /** 34 | * @param TransportInterface[] $transports 35 | */ 36 | public function __construct( 37 | private array $transports, 38 | private int $retryPeriod = 60, 39 | ) { 40 | if (!$transports) { 41 | throw new TransportException(\sprintf('"%s" must have at least one transport configured.', static::class)); 42 | } 43 | 44 | $this->deadTransports = new \SplObjectStorage(); 45 | } 46 | 47 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 48 | { 49 | $exception = null; 50 | 51 | while ($transport = $this->getNextTransport()) { 52 | try { 53 | return $transport->send($message, $envelope); 54 | } catch (TransportExceptionInterface $e) { 55 | $exception ??= new TransportException('All transports failed.'); 56 | $exception->appendDebug(\sprintf("Transport \"%s\": %s\n", $transport, $e->getDebug())); 57 | $this->deadTransports[$transport] = microtime(true); 58 | } 59 | } 60 | 61 | throw $exception ?? new TransportException('No transports found.'); 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return $this->getNameSymbol().'('.implode(' ', array_map('strval', $this->transports)).')'; 67 | } 68 | 69 | /** 70 | * Rotates the transport list around and returns the first instance. 71 | */ 72 | protected function getNextTransport(): ?TransportInterface 73 | { 74 | if (-1 === $this->cursor) { 75 | $this->cursor = $this->getInitialCursor(); 76 | } 77 | 78 | $cursor = $this->cursor; 79 | while (true) { 80 | $transport = $this->transports[$cursor]; 81 | 82 | if (!$this->isTransportDead($transport)) { 83 | break; 84 | } 85 | 86 | if ((microtime(true) - $this->deadTransports[$transport]) > $this->retryPeriod) { 87 | $this->deadTransports->detach($transport); 88 | 89 | break; 90 | } 91 | 92 | if ($this->cursor === $cursor = $this->moveCursor($cursor)) { 93 | return null; 94 | } 95 | } 96 | 97 | $this->cursor = $this->moveCursor($cursor); 98 | 99 | return $transport; 100 | } 101 | 102 | protected function isTransportDead(TransportInterface $transport): bool 103 | { 104 | return $this->deadTransports->contains($transport); 105 | } 106 | 107 | protected function getInitialCursor(): int 108 | { 109 | // the cursor initial value is randomized so that 110 | // when are not in a daemon, we are still rotating the transports 111 | return mt_rand(0, \count($this->transports) - 1); 112 | } 113 | 114 | protected function getNameSymbol(): string 115 | { 116 | return 'roundrobin'; 117 | } 118 | 119 | private function moveCursor(int $cursor): int 120 | { 121 | return ++$cursor >= \count($this->transports) ? 0 : $cursor; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Transport/SendmailTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Envelope; 17 | use Symfony\Component\Mailer\SentMessage; 18 | use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport; 19 | use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; 20 | use Symfony\Component\Mailer\Transport\Smtp\Stream\ProcessStream; 21 | use Symfony\Component\Mime\RawMessage; 22 | 23 | /** 24 | * SendmailTransport for sending mail through a Sendmail/Postfix (etc..) binary. 25 | * 26 | * Transport can be instantiated through SendmailTransportFactory or NativeTransportFactory: 27 | * 28 | * - SendmailTransportFactory to use most common sendmail path and recommended options 29 | * - NativeTransportFactory when configuration is set via php.ini 30 | * 31 | * @author Fabien Potencier 32 | * @author Chris Corbyn 33 | */ 34 | class SendmailTransport extends AbstractTransport 35 | { 36 | private string $command = '/usr/sbin/sendmail -bs'; 37 | private ProcessStream $stream; 38 | private ?SmtpTransport $transport = null; 39 | 40 | /** 41 | * Constructor. 42 | * 43 | * Supported modes are -bs and -t, with any additional flags desired. 44 | * 45 | * The recommended mode is "-bs" since it is interactive and failure notifications are hence possible. 46 | * Note that the -t mode does not support error reporting and does not support Bcc properly (the Bcc headers are not removed). 47 | * 48 | * If using -t mode, you are strongly advised to include -oi or -i in the flags (like /usr/sbin/sendmail -oi -t) 49 | * 50 | * -f flag will be appended automatically if one is not present. 51 | */ 52 | public function __construct(?string $command = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) 53 | { 54 | parent::__construct($dispatcher, $logger); 55 | 56 | if (null !== $command) { 57 | if (!str_contains($command, ' -bs') && !str_contains($command, ' -t')) { 58 | throw new \InvalidArgumentException(\sprintf('Unsupported sendmail command flags "%s"; must be one of "-bs" or "-t" but can include additional flags.', $command)); 59 | } 60 | 61 | $this->command = $command; 62 | } 63 | 64 | $this->stream = new ProcessStream(); 65 | if (str_contains($this->command, ' -bs')) { 66 | $this->stream->setCommand($this->command); 67 | $this->stream->setInteractive(true); 68 | $this->transport = new SmtpTransport($this->stream, $dispatcher, $logger); 69 | } 70 | } 71 | 72 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 73 | { 74 | if ($this->transport) { 75 | return $this->transport->send($message, $envelope); 76 | } 77 | 78 | return parent::send($message, $envelope); 79 | } 80 | 81 | public function __toString(): string 82 | { 83 | if ($this->transport) { 84 | return (string) $this->transport; 85 | } 86 | 87 | return 'smtp://sendmail'; 88 | } 89 | 90 | protected function doSend(SentMessage $message): void 91 | { 92 | $this->getLogger()->debug(\sprintf('Email transport "%s" starting', __CLASS__)); 93 | 94 | $command = $this->command; 95 | 96 | if ($recipients = $message->getEnvelope()->getRecipients()) { 97 | $command = str_replace(' -t', '', $command); 98 | } 99 | 100 | if (!str_contains($command, ' -f')) { 101 | $command .= ' -f'.escapeshellarg($message->getEnvelope()->getSender()->getEncodedAddress()); 102 | } 103 | 104 | $chunks = AbstractStream::replace("\r\n", "\n", $message->toIterable()); 105 | 106 | if (!str_contains($command, ' -i') && !str_contains($command, ' -oi')) { 107 | $chunks = AbstractStream::replace("\n.", "\n..", $chunks); 108 | } 109 | 110 | foreach ($recipients as $recipient) { 111 | $command .= ' '.escapeshellarg($recipient->getEncodedAddress()); 112 | } 113 | 114 | $this->stream->setCommand($command); 115 | $this->stream->initialize(); 116 | foreach ($chunks as $chunk) { 117 | $this->stream->write($chunk, false); 118 | } 119 | $this->stream->flush(); 120 | $this->stream->terminate(); 121 | 122 | $this->getLogger()->debug(\sprintf('Email transport "%s" stopped', __CLASS__)); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Transport/SendmailTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 15 | 16 | /** 17 | * @author Konstantin Myakshin 18 | */ 19 | final class SendmailTransportFactory extends AbstractTransportFactory 20 | { 21 | public function create(Dsn $dsn): TransportInterface 22 | { 23 | if ('sendmail+smtp' === $dsn->getScheme() || 'sendmail' === $dsn->getScheme()) { 24 | return new SendmailTransport($dsn->getOption('command'), $this->dispatcher, $this->logger); 25 | } 26 | 27 | throw new UnsupportedSchemeException($dsn, 'sendmail', $this->getSupportedSchemes()); 28 | } 29 | 30 | protected function getSupportedSchemes(): array 31 | { 32 | return ['sendmail', 'sendmail+smtp']; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Transport/Smtp/Auth/AuthenticatorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Auth; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 15 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 16 | 17 | /** 18 | * An Authentication mechanism. 19 | * 20 | * @author Chris Corbyn 21 | */ 22 | interface AuthenticatorInterface 23 | { 24 | /** 25 | * Tries to authenticate the user. 26 | * 27 | * @throws TransportExceptionInterface 28 | */ 29 | public function authenticate(EsmtpTransport $client): void; 30 | 31 | /** 32 | * Gets the name of the AUTH mechanism this Authenticator handles. 33 | */ 34 | public function getAuthKeyword(): string; 35 | } 36 | -------------------------------------------------------------------------------- /Transport/Smtp/Auth/CramMd5Authenticator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Auth; 13 | 14 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 15 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 16 | 17 | /** 18 | * Handles CRAM-MD5 authentication. 19 | * 20 | * @author Chris Corbyn 21 | */ 22 | class CramMd5Authenticator implements AuthenticatorInterface 23 | { 24 | public function getAuthKeyword(): string 25 | { 26 | return 'CRAM-MD5'; 27 | } 28 | 29 | /** 30 | * @see https://www.ietf.org/rfc/rfc4954.txt 31 | */ 32 | public function authenticate(EsmtpTransport $client): void 33 | { 34 | $challenge = $client->executeCommand("AUTH CRAM-MD5\r\n", [334]); 35 | $challenge = base64_decode(substr($challenge, 4)); 36 | $message = base64_encode($client->getUsername().' '.$this->getResponse($client->getPassword(), $challenge)); 37 | $client->executeCommand(\sprintf("%s\r\n", $message), [235]); 38 | } 39 | 40 | /** 41 | * Generates a CRAM-MD5 response from a server challenge. 42 | */ 43 | private function getResponse(#[\SensitiveParameter] string $secret, string $challenge): string 44 | { 45 | if (!$secret) { 46 | throw new InvalidArgumentException('A non-empty secret is required.'); 47 | } 48 | 49 | if (\strlen($secret) > 64) { 50 | $secret = pack('H32', md5($secret)); 51 | } 52 | 53 | if (\strlen($secret) < 64) { 54 | $secret = str_pad($secret, 64, \chr(0)); 55 | } 56 | 57 | $kipad = substr($secret, 0, 64) ^ str_repeat(\chr(0x36), 64); 58 | $kopad = substr($secret, 0, 64) ^ str_repeat(\chr(0x5C), 64); 59 | 60 | $inner = pack('H32', md5($kipad.$challenge)); 61 | 62 | return md5($kopad.$inner); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Transport/Smtp/Auth/LoginAuthenticator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Auth; 13 | 14 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 15 | 16 | /** 17 | * Handles LOGIN authentication. 18 | * 19 | * @author Chris Corbyn 20 | */ 21 | class LoginAuthenticator implements AuthenticatorInterface 22 | { 23 | public function getAuthKeyword(): string 24 | { 25 | return 'LOGIN'; 26 | } 27 | 28 | /** 29 | * @see https://www.ietf.org/rfc/rfc4954.txt 30 | */ 31 | public function authenticate(EsmtpTransport $client): void 32 | { 33 | $client->executeCommand("AUTH LOGIN\r\n", [334]); 34 | $client->executeCommand(\sprintf("%s\r\n", base64_encode($client->getUsername())), [334]); 35 | $client->executeCommand(\sprintf("%s\r\n", base64_encode($client->getPassword())), [235]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Transport/Smtp/Auth/PlainAuthenticator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Auth; 13 | 14 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 15 | 16 | /** 17 | * Handles PLAIN authentication. 18 | * 19 | * @author Chris Corbyn 20 | */ 21 | class PlainAuthenticator implements AuthenticatorInterface 22 | { 23 | public function getAuthKeyword(): string 24 | { 25 | return 'PLAIN'; 26 | } 27 | 28 | /** 29 | * @see https://www.ietf.org/rfc/rfc4954.txt 30 | */ 31 | public function authenticate(EsmtpTransport $client): void 32 | { 33 | $client->executeCommand(\sprintf("AUTH PLAIN %s\r\n", base64_encode($client->getUsername().\chr(0).$client->getUsername().\chr(0).$client->getPassword())), [235]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Transport/Smtp/Auth/XOAuth2Authenticator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Auth; 13 | 14 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 15 | 16 | /** 17 | * Handles XOAUTH2 authentication. 18 | * 19 | * @author xu.li 20 | * 21 | * @see https://developers.google.com/google-apps/gmail/xoauth2_protocol 22 | */ 23 | class XOAuth2Authenticator implements AuthenticatorInterface 24 | { 25 | public function getAuthKeyword(): string 26 | { 27 | return 'XOAUTH2'; 28 | } 29 | 30 | /** 31 | * @see https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism 32 | */ 33 | public function authenticate(EsmtpTransport $client): void 34 | { 35 | $client->executeCommand('AUTH XOAUTH2 '.base64_encode('user='.$client->getUsername()."\1auth=Bearer ".$client->getPassword()."\1\1")."\r\n", [235]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Transport/Smtp/EsmtpTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Exception\TransportException; 17 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 18 | use Symfony\Component\Mailer\Exception\UnexpectedResponseException; 19 | use Symfony\Component\Mailer\Transport\Smtp\Auth\AuthenticatorInterface; 20 | use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; 21 | use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; 22 | 23 | /** 24 | * Sends Emails over SMTP with ESMTP support. 25 | * 26 | * @author Fabien Potencier 27 | * @author Chris Corbyn 28 | */ 29 | class EsmtpTransport extends SmtpTransport 30 | { 31 | private array $authenticators = []; 32 | private string $username = ''; 33 | private string $password = ''; 34 | private array $capabilities; 35 | private bool $autoTls = true; 36 | private bool $requireTls = false; 37 | 38 | public function __construct(string $host = 'localhost', int $port = 0, ?bool $tls = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, ?AbstractStream $stream = null, ?array $authenticators = null) 39 | { 40 | parent::__construct($stream, $dispatcher, $logger); 41 | 42 | if (null === $authenticators) { 43 | // fallback to default authenticators 44 | // order is important here (roughly most secure and popular first) 45 | $authenticators = [ 46 | new Auth\CramMd5Authenticator(), 47 | new Auth\LoginAuthenticator(), 48 | new Auth\PlainAuthenticator(), 49 | new Auth\XOAuth2Authenticator(), 50 | ]; 51 | } 52 | $this->setAuthenticators($authenticators); 53 | 54 | /** @var SocketStream $stream */ 55 | $stream = $this->getStream(); 56 | 57 | if (null === $tls) { 58 | if (465 === $port) { 59 | $tls = true; 60 | } else { 61 | $tls = \defined('OPENSSL_VERSION_NUMBER') && 0 === $port && 'localhost' !== $host; 62 | } 63 | } 64 | if (!$tls) { 65 | $stream->disableTls(); 66 | } 67 | if (0 === $port) { 68 | $port = $tls ? 465 : 25; 69 | } 70 | 71 | $stream->setHost($host); 72 | $stream->setPort($port); 73 | } 74 | 75 | /** 76 | * @return $this 77 | */ 78 | public function setUsername(string $username): static 79 | { 80 | $this->username = $username; 81 | 82 | return $this; 83 | } 84 | 85 | public function getUsername(): string 86 | { 87 | return $this->username; 88 | } 89 | 90 | /** 91 | * @return $this 92 | */ 93 | public function setPassword(#[\SensitiveParameter] string $password): static 94 | { 95 | $this->password = $password; 96 | 97 | return $this; 98 | } 99 | 100 | public function getPassword(): string 101 | { 102 | return $this->password; 103 | } 104 | 105 | /** 106 | * @return $this 107 | */ 108 | public function setAutoTls(bool $autoTls): static 109 | { 110 | $this->autoTls = $autoTls; 111 | 112 | return $this; 113 | } 114 | 115 | public function isAutoTls(): bool 116 | { 117 | return $this->autoTls; 118 | } 119 | 120 | /** 121 | * @return $this 122 | */ 123 | public function setRequireTls(bool $requireTls): static 124 | { 125 | $this->requireTls = $requireTls; 126 | 127 | return $this; 128 | } 129 | 130 | public function isTlsRequired(): bool 131 | { 132 | return $this->requireTls; 133 | } 134 | 135 | public function setAuthenticators(array $authenticators): void 136 | { 137 | $this->authenticators = []; 138 | foreach ($authenticators as $authenticator) { 139 | $this->addAuthenticator($authenticator); 140 | } 141 | } 142 | 143 | public function addAuthenticator(AuthenticatorInterface $authenticator): void 144 | { 145 | $this->authenticators[] = $authenticator; 146 | } 147 | 148 | public function executeCommand(string $command, array $codes): string 149 | { 150 | return [250] === $codes && str_starts_with($command, 'HELO ') ? $this->doEhloCommand() : parent::executeCommand($command, $codes); 151 | } 152 | 153 | final protected function getCapabilities(): array 154 | { 155 | return $this->capabilities; 156 | } 157 | 158 | private function doEhloCommand(): string 159 | { 160 | try { 161 | $response = $this->executeCommand(\sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); 162 | } catch (TransportExceptionInterface $e) { 163 | try { 164 | return parent::executeCommand(\sprintf("HELO %s\r\n", $this->getLocalDomain()), [250]); 165 | } catch (TransportExceptionInterface $ex) { 166 | if (!$ex->getCode()) { 167 | throw $e; 168 | } 169 | 170 | throw $ex; 171 | } 172 | } 173 | 174 | $this->capabilities = $this->parseCapabilities($response); 175 | 176 | /** @var SocketStream $stream */ 177 | $stream = $this->getStream(); 178 | $tlsStarted = $stream->isTls(); 179 | // WARNING: !$stream->isTLS() is right, 100% sure :) 180 | // if you think that the ! should be removed, read the code again 181 | // if doing so "fixes" your issue then it probably means your SMTP server behaves incorrectly or is wrongly configured 182 | if ($this->autoTls && !$stream->isTLS() && \defined('OPENSSL_VERSION_NUMBER') && \array_key_exists('STARTTLS', $this->capabilities)) { 183 | $this->executeCommand("STARTTLS\r\n", [220]); 184 | 185 | if (!$stream->startTLS()) { 186 | throw new TransportException('Unable to connect with STARTTLS.'); 187 | } 188 | 189 | $tlsStarted = true; 190 | $response = $this->executeCommand(\sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); 191 | $this->capabilities = $this->parseCapabilities($response); 192 | } 193 | 194 | if (!$tlsStarted && $this->isTlsRequired()) { 195 | throw new TransportException('TLS required but neither TLS or STARTTLS are in use.'); 196 | } 197 | 198 | if (\array_key_exists('AUTH', $this->capabilities)) { 199 | $this->handleAuth($this->capabilities['AUTH']); 200 | } 201 | 202 | return $response; 203 | } 204 | 205 | private function parseCapabilities(string $ehloResponse): array 206 | { 207 | $capabilities = []; 208 | $lines = explode("\r\n", trim($ehloResponse)); 209 | array_shift($lines); 210 | foreach ($lines as $line) { 211 | if (preg_match('/^[0-9]{3}[ -]([A-Z0-9-]+)((?:[ =].*)?)$/Di', $line, $matches)) { 212 | $value = strtoupper(ltrim($matches[2], ' =')); 213 | $capabilities[strtoupper($matches[1])] = $value ? explode(' ', $value) : []; 214 | } 215 | } 216 | 217 | return $capabilities; 218 | } 219 | 220 | protected function serverSupportsSmtpUtf8(): bool 221 | { 222 | return \array_key_exists('SMTPUTF8', $this->capabilities); 223 | } 224 | 225 | private function handleAuth(array $modes): void 226 | { 227 | if (!$this->username) { 228 | return; 229 | } 230 | 231 | $code = null; 232 | $authNames = []; 233 | $errors = []; 234 | $modes = array_map('strtolower', $modes); 235 | foreach ($this->authenticators as $authenticator) { 236 | if (!\in_array(strtolower($authenticator->getAuthKeyword()), $modes, true)) { 237 | continue; 238 | } 239 | 240 | $code = null; 241 | $authNames[] = $authenticator->getAuthKeyword(); 242 | try { 243 | $authenticator->authenticate($this); 244 | 245 | return; 246 | } catch (UnexpectedResponseException $e) { 247 | $code = $e->getCode(); 248 | 249 | try { 250 | $this->executeCommand("RSET\r\n", [250]); 251 | } catch (TransportExceptionInterface) { 252 | // ignore this exception as it probably means that the server error was final 253 | } 254 | 255 | // keep the error message, but tries the other authenticators 256 | $errors[$authenticator->getAuthKeyword()] = $e->getMessage(); 257 | } 258 | } 259 | 260 | if (!$authNames) { 261 | throw new TransportException(\sprintf('Failed to find an authenticator supported by the SMTP server, which currently supports: "%s".', implode('", "', $modes)), $code ?: 504); 262 | } 263 | 264 | $message = \sprintf('Failed to authenticate on SMTP server with username "%s" using the following authenticators: "%s".', $this->username, implode('", "', $authNames)); 265 | foreach ($errors as $name => $error) { 266 | $message .= \sprintf(' Authenticator "%s" returned "%s".', $name, $error); 267 | } 268 | 269 | throw new TransportException($message, $code ?: 535); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /Transport/Smtp/EsmtpTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp; 13 | 14 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 15 | use Symfony\Component\Mailer\Transport\AbstractTransportFactory; 16 | use Symfony\Component\Mailer\Transport\Dsn; 17 | use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; 18 | use Symfony\Component\Mailer\Transport\TransportInterface; 19 | 20 | /** 21 | * @author Konstantin Myakshin 22 | */ 23 | final class EsmtpTransportFactory extends AbstractTransportFactory 24 | { 25 | public function create(Dsn $dsn): TransportInterface 26 | { 27 | if (!\in_array($dsn->getScheme(), $this->getSupportedSchemes(), true)) { 28 | throw new UnsupportedSchemeException($dsn, 'smtp', $this->getSupportedSchemes()); 29 | } 30 | 31 | $autoTls = '' === $dsn->getOption('auto_tls') || filter_var($dsn->getOption('auto_tls', true), \FILTER_VALIDATE_BOOL); 32 | $tls = 'smtps' === $dsn->getScheme() ? true : ($autoTls ? null : false); 33 | $port = $dsn->getPort(0); 34 | $host = $dsn->getHost(); 35 | 36 | $transport = new EsmtpTransport($host, $port, $tls, $this->dispatcher, $this->logger); 37 | $transport->setAutoTls($autoTls); 38 | $transport->setRequireTls($dsn->getBooleanOption('require_tls')); 39 | 40 | /** @var SocketStream $stream */ 41 | $stream = $transport->getStream(); 42 | if ('' !== $sourceIp = $dsn->getOption('source_ip', '')) { 43 | $stream->setSourceIp($sourceIp); 44 | } 45 | $streamOptions = $stream->getStreamOptions(); 46 | 47 | if ('' !== $dsn->getOption('verify_peer') && !filter_var($dsn->getOption('verify_peer', true), \FILTER_VALIDATE_BOOL)) { 48 | $streamOptions['ssl']['verify_peer'] = false; 49 | $streamOptions['ssl']['verify_peer_name'] = false; 50 | } 51 | 52 | if (null !== $peerFingerprint = $dsn->getOption('peer_fingerprint')) { 53 | $streamOptions['ssl']['peer_fingerprint'] = $peerFingerprint; 54 | } 55 | 56 | $stream->setStreamOptions($streamOptions); 57 | 58 | if ($user = $dsn->getUser()) { 59 | $transport->setUsername($user); 60 | } 61 | 62 | if ($password = $dsn->getPassword()) { 63 | $transport->setPassword($password); 64 | } 65 | 66 | if (null !== ($localDomain = $dsn->getOption('local_domain'))) { 67 | $transport->setLocalDomain($localDomain); 68 | } 69 | 70 | if (null !== ($maxPerSecond = $dsn->getOption('max_per_second'))) { 71 | $transport->setMaxPerSecond((float) $maxPerSecond); 72 | } 73 | 74 | if (null !== ($restartThreshold = $dsn->getOption('restart_threshold'))) { 75 | $transport->setRestartThreshold((int) $restartThreshold, (int) $dsn->getOption('restart_threshold_sleep', 0)); 76 | } 77 | 78 | if (null !== ($pingThreshold = $dsn->getOption('ping_threshold'))) { 79 | $transport->setPingThreshold((int) $pingThreshold); 80 | } 81 | 82 | return $transport; 83 | } 84 | 85 | protected function getSupportedSchemes(): array 86 | { 87 | return ['smtp', 'smtps']; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Transport/Smtp/SmtpTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Envelope; 17 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 18 | use Symfony\Component\Mailer\Exception\LogicException; 19 | use Symfony\Component\Mailer\Exception\TransportException; 20 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 21 | use Symfony\Component\Mailer\Exception\UnexpectedResponseException; 22 | use Symfony\Component\Mailer\SentMessage; 23 | use Symfony\Component\Mailer\Transport\AbstractTransport; 24 | use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; 25 | use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; 26 | use Symfony\Component\Mime\RawMessage; 27 | 28 | /** 29 | * Sends emails over SMTP. 30 | * 31 | * @author Fabien Potencier 32 | * @author Chris Corbyn 33 | */ 34 | class SmtpTransport extends AbstractTransport 35 | { 36 | private bool $started = false; 37 | private int $restartThreshold = 100; 38 | private int $restartThresholdSleep = 0; 39 | private int $restartCounter = 0; 40 | private int $pingThreshold = 100; 41 | private float $lastMessageTime = 0; 42 | private AbstractStream $stream; 43 | private string $domain = '[127.0.0.1]'; 44 | 45 | public function __construct(?AbstractStream $stream = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) 46 | { 47 | parent::__construct($dispatcher, $logger); 48 | 49 | $this->stream = $stream ?? new SocketStream(); 50 | } 51 | 52 | public function getStream(): AbstractStream 53 | { 54 | return $this->stream; 55 | } 56 | 57 | /** 58 | * Sets the maximum number of messages to send before re-starting the transport. 59 | * 60 | * By default, the threshold is set to 100 (and no sleep at restart). 61 | * 62 | * @param int $threshold The maximum number of messages (0 to disable) 63 | * @param int $sleep The number of seconds to sleep between stopping and re-starting the transport 64 | * 65 | * @return $this 66 | */ 67 | public function setRestartThreshold(int $threshold, int $sleep = 0): static 68 | { 69 | $this->restartThreshold = $threshold; 70 | $this->restartThresholdSleep = $sleep; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Sets the minimum number of seconds required between two messages, before the server is pinged. 77 | * If the transport wants to send a message and the time since the last message exceeds the specified threshold, 78 | * the transport will ping the server first (NOOP command) to check if the connection is still alive. 79 | * Otherwise the message will be sent without pinging the server first. 80 | * 81 | * Do not set the threshold too low, as the SMTP server may drop the connection if there are too many 82 | * non-mail commands (like pinging the server with NOOP). 83 | * 84 | * By default, the threshold is set to 100 seconds. 85 | * 86 | * @param int $seconds The minimum number of seconds between two messages required to ping the server 87 | * 88 | * @return $this 89 | */ 90 | public function setPingThreshold(int $seconds): static 91 | { 92 | $this->pingThreshold = $seconds; 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Sets the name of the local domain that will be used in HELO. 99 | * 100 | * This should be a fully-qualified domain name and should be truly the domain 101 | * you're using. 102 | * 103 | * If your server does not have a domain name, use the IP address. This will 104 | * automatically be wrapped in square brackets as described in RFC 5321, 105 | * section 4.1.3. 106 | * 107 | * @return $this 108 | */ 109 | public function setLocalDomain(string $domain): static 110 | { 111 | if ('' !== $domain && '[' !== $domain[0]) { 112 | if (filter_var($domain, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { 113 | $domain = '['.$domain.']'; 114 | } elseif (filter_var($domain, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { 115 | $domain = '[IPv6:'.$domain.']'; 116 | } 117 | } 118 | 119 | $this->domain = $domain; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * Gets the name of the domain that will be used in HELO. 126 | * 127 | * If an IP address was specified, this will be returned wrapped in square 128 | * brackets as described in RFC 5321, section 4.1.3. 129 | */ 130 | public function getLocalDomain(): string 131 | { 132 | return $this->domain; 133 | } 134 | 135 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 136 | { 137 | try { 138 | $message = parent::send($message, $envelope); 139 | } catch (TransportExceptionInterface $e) { 140 | if ($this->started) { 141 | try { 142 | $this->executeCommand("RSET\r\n", [250]); 143 | } catch (TransportExceptionInterface) { 144 | // ignore this exception as it probably means that the server error was final 145 | } 146 | } 147 | 148 | throw $e; 149 | } 150 | 151 | $this->checkRestartThreshold(); 152 | 153 | return $message; 154 | } 155 | 156 | protected function parseMessageId(string $mtaResult): string 157 | { 158 | $regexps = [ 159 | '/250 Ok (?P[0-9a-f-]+)\r?$/mis', 160 | '/250 Ok:? queued as (?P[A-Z0-9]+)\r?$/mis', 161 | ]; 162 | $matches = []; 163 | foreach ($regexps as $regexp) { 164 | if (preg_match($regexp, $mtaResult, $matches)) { 165 | return $matches['id']; 166 | } 167 | } 168 | 169 | return ''; 170 | } 171 | 172 | public function __toString(): string 173 | { 174 | if ($this->stream instanceof SocketStream) { 175 | $name = \sprintf('smtp%s://%s', ($tls = $this->stream->isTLS()) ? 's' : '', $this->stream->getHost()); 176 | $port = $this->stream->getPort(); 177 | if (!(25 === $port || ($tls && 465 === $port))) { 178 | $name .= ':'.$port; 179 | } 180 | 181 | return $name; 182 | } 183 | 184 | return 'smtp://sendmail'; 185 | } 186 | 187 | /** 188 | * Runs a command against the stream, expecting the given response codes. 189 | * 190 | * @param int[] $codes 191 | * 192 | * @throws TransportException when an invalid response if received 193 | */ 194 | public function executeCommand(string $command, array $codes): string 195 | { 196 | $this->stream->write($command); 197 | $response = $this->getFullResponse(); 198 | $this->assertResponseCode($response, $codes); 199 | 200 | return $response; 201 | } 202 | 203 | protected function doSend(SentMessage $message): void 204 | { 205 | if (microtime(true) - $this->lastMessageTime > $this->pingThreshold) { 206 | $this->ping(); 207 | } 208 | 209 | try { 210 | if (!$this->started) { 211 | $this->start(); 212 | } 213 | 214 | $envelope = $message->getEnvelope(); 215 | $this->doMailFromCommand($envelope->getSender()->getEncodedAddress(), $envelope->anyAddressHasUnicodeLocalpart()); 216 | foreach ($envelope->getRecipients() as $recipient) { 217 | $this->doRcptToCommand($recipient->getEncodedAddress()); 218 | } 219 | 220 | $this->executeCommand("DATA\r\n", [354]); 221 | try { 222 | foreach (AbstractStream::replace("\r\n.", "\r\n..", $message->toIterable()) as $chunk) { 223 | $this->stream->write($chunk, false); 224 | } 225 | $this->stream->flush(); 226 | } catch (TransportExceptionInterface $e) { 227 | throw $e; 228 | } catch (\Exception $e) { 229 | $this->stream->terminate(); 230 | $this->started = false; 231 | $this->getLogger()->debug(\sprintf('Email transport "%s" stopped', __CLASS__)); 232 | throw $e; 233 | } 234 | $mtaResult = $this->executeCommand("\r\n.\r\n", [250]); 235 | $message->appendDebug($this->stream->getDebug()); 236 | $this->lastMessageTime = microtime(true); 237 | 238 | if ($mtaResult && $messageId = $this->parseMessageId($mtaResult)) { 239 | $message->setMessageId($messageId); 240 | } 241 | } catch (TransportExceptionInterface $e) { 242 | $e->appendDebug($this->stream->getDebug()); 243 | $this->lastMessageTime = 0; 244 | throw $e; 245 | } 246 | } 247 | 248 | protected function serverSupportsSmtpUtf8(): bool 249 | { 250 | return false; 251 | } 252 | 253 | private function doHeloCommand(): void 254 | { 255 | $this->executeCommand(\sprintf("HELO %s\r\n", $this->domain), [250]); 256 | } 257 | 258 | private function doMailFromCommand(string $address, bool $smtputf8): void 259 | { 260 | if ($smtputf8 && !$this->serverSupportsSmtpUtf8()) { 261 | throw new InvalidArgumentException('Invalid addresses: non-ASCII characters not supported in local-part of email.'); 262 | } 263 | $this->executeCommand(\sprintf("MAIL FROM:<%s>%s\r\n", $address, $smtputf8 ? ' SMTPUTF8' : ''), [250]); 264 | } 265 | 266 | private function doRcptToCommand(string $address): void 267 | { 268 | $this->executeCommand(\sprintf("RCPT TO:<%s>\r\n", $address), [250, 251, 252]); 269 | } 270 | 271 | public function start(): void 272 | { 273 | if ($this->started) { 274 | return; 275 | } 276 | 277 | $this->getLogger()->debug(\sprintf('Email transport "%s" starting', __CLASS__)); 278 | 279 | $this->stream->initialize(); 280 | $this->assertResponseCode($this->getFullResponse(), [220]); 281 | $this->doHeloCommand(); 282 | $this->started = true; 283 | $this->lastMessageTime = 0; 284 | 285 | $this->getLogger()->debug(\sprintf('Email transport "%s" started', __CLASS__)); 286 | } 287 | 288 | /** 289 | * Manually disconnect from the SMTP server. 290 | * 291 | * In most cases this is not necessary since the disconnect happens automatically on termination. 292 | * In cases of long-running scripts, this might however make sense to avoid keeping an open 293 | * connection to the SMTP server in between sending emails. 294 | */ 295 | public function stop(): void 296 | { 297 | if (!$this->started) { 298 | return; 299 | } 300 | 301 | $this->getLogger()->debug(\sprintf('Email transport "%s" stopping', __CLASS__)); 302 | 303 | try { 304 | $this->executeCommand("QUIT\r\n", [221]); 305 | } catch (TransportExceptionInterface) { 306 | } finally { 307 | $this->stream->terminate(); 308 | $this->started = false; 309 | $this->getLogger()->debug(\sprintf('Email transport "%s" stopped', __CLASS__)); 310 | } 311 | } 312 | 313 | private function ping(): void 314 | { 315 | if (!$this->started) { 316 | return; 317 | } 318 | 319 | try { 320 | $this->executeCommand("NOOP\r\n", [250]); 321 | } catch (TransportExceptionInterface) { 322 | $this->stop(); 323 | } 324 | } 325 | 326 | /** 327 | * @throws TransportException if a response code is incorrect 328 | */ 329 | private function assertResponseCode(string $response, array $codes): void 330 | { 331 | if (!$codes) { 332 | throw new LogicException('You must set the expected response code.'); 333 | } 334 | 335 | [$code] = sscanf($response, '%3d'); 336 | $valid = \in_array($code, $codes); 337 | 338 | if (!$valid || !$response) { 339 | $codeStr = $code ? \sprintf('code "%s"', $code) : 'empty code'; 340 | $responseStr = $response ? \sprintf(', with message "%s"', trim($response)) : ''; 341 | 342 | throw new UnexpectedResponseException(\sprintf('Expected response code "%s" but got ', implode('/', $codes)).$codeStr.$responseStr.'.', $code ?: 0); 343 | } 344 | } 345 | 346 | private function getFullResponse(): string 347 | { 348 | $response = ''; 349 | do { 350 | $line = $this->stream->readLine(); 351 | $response .= $line; 352 | } while ($line && isset($line[3]) && ' ' !== $line[3]); 353 | 354 | return $response; 355 | } 356 | 357 | private function checkRestartThreshold(): void 358 | { 359 | // when using sendmail via non-interactive mode, the transport is never "started" 360 | if (!$this->started) { 361 | return; 362 | } 363 | 364 | ++$this->restartCounter; 365 | if ($this->restartCounter < $this->restartThreshold) { 366 | return; 367 | } 368 | 369 | $this->stop(); 370 | if (0 < $sleep = $this->restartThresholdSleep) { 371 | $this->getLogger()->debug(\sprintf('Email transport "%s" sleeps for %d seconds after stopping', __CLASS__, $sleep)); 372 | 373 | sleep($sleep); 374 | } 375 | $this->start(); 376 | $this->restartCounter = 0; 377 | } 378 | 379 | public function __sleep(): array 380 | { 381 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 382 | } 383 | 384 | public function __wakeup(): void 385 | { 386 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 387 | } 388 | 389 | public function __destruct() 390 | { 391 | $this->stop(); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /Transport/Smtp/Stream/AbstractStream.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Stream; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportException; 15 | 16 | /** 17 | * A stream supporting remote sockets and local processes. 18 | * 19 | * @author Fabien Potencier 20 | * @author Nicolas Grekas 21 | * @author Chris Corbyn 22 | * 23 | * @internal 24 | */ 25 | abstract class AbstractStream 26 | { 27 | /** @var resource|null */ 28 | protected $stream; 29 | /** @var resource|null */ 30 | protected $in; 31 | /** @var resource|null */ 32 | protected $out; 33 | protected $err; 34 | 35 | private string $debug = ''; 36 | 37 | public function write(string $bytes, bool $debug = true): void 38 | { 39 | if ($debug) { 40 | $timestamp = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.up'); 41 | foreach (explode("\n", trim($bytes)) as $line) { 42 | $this->debug .= \sprintf("[%s] > %s\n", $timestamp, $line); 43 | } 44 | } 45 | 46 | $bytesToWrite = \strlen($bytes); 47 | $totalBytesWritten = 0; 48 | while ($totalBytesWritten < $bytesToWrite) { 49 | $bytesWritten = @fwrite($this->in, substr($bytes, $totalBytesWritten)); 50 | if (false === $bytesWritten || 0 === $bytesWritten) { 51 | throw new TransportException('Unable to write bytes on the wire.'); 52 | } 53 | 54 | $totalBytesWritten += $bytesWritten; 55 | } 56 | } 57 | 58 | /** 59 | * Flushes the contents of the stream (empty it) and set the internal pointer to the beginning. 60 | */ 61 | public function flush(): void 62 | { 63 | fflush($this->in); 64 | } 65 | 66 | /** 67 | * Performs any initialization needed. 68 | */ 69 | abstract public function initialize(): void; 70 | 71 | public function terminate(): void 72 | { 73 | $this->stream = $this->err = $this->out = $this->in = null; 74 | } 75 | 76 | public function readLine(): string 77 | { 78 | if (feof($this->out)) { 79 | return ''; 80 | } 81 | 82 | $line = @fgets($this->out); 83 | if ('' === $line || false === $line) { 84 | if (stream_get_meta_data($this->out)['timed_out']) { 85 | throw new TransportException(\sprintf('Connection to "%s" timed out.', $this->getReadConnectionDescription())); 86 | } 87 | if (feof($this->out)) { // don't use "eof" metadata, it's not accurate on Windows 88 | throw new TransportException(\sprintf('Connection to "%s" has been closed unexpectedly.', $this->getReadConnectionDescription())); 89 | } 90 | if (false === $line) { 91 | throw new TransportException(\sprintf('Unable to read from connection to "%s": ', $this->getReadConnectionDescription().error_get_last()['message'] ?? '')); 92 | } 93 | } 94 | 95 | $this->debug .= \sprintf('[%s] < %s', (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.up'), $line); 96 | 97 | return $line; 98 | } 99 | 100 | public function getDebug(): string 101 | { 102 | $debug = $this->debug; 103 | $this->debug = ''; 104 | 105 | return $debug; 106 | } 107 | 108 | public static function replace(string $from, string $to, iterable $chunks): \Generator 109 | { 110 | if ('' === $from) { 111 | yield from $chunks; 112 | 113 | return; 114 | } 115 | 116 | $carry = ''; 117 | $fromLen = \strlen($from); 118 | 119 | foreach ($chunks as $chunk) { 120 | if ('' === $chunk = $carry.$chunk) { 121 | continue; 122 | } 123 | 124 | if (str_contains($chunk, $from)) { 125 | $chunk = explode($from, $chunk); 126 | $carry = array_pop($chunk); 127 | 128 | yield implode($to, $chunk).$to; 129 | } else { 130 | $carry = $chunk; 131 | } 132 | 133 | if (\strlen($carry) > $fromLen) { 134 | yield substr($carry, 0, -$fromLen); 135 | $carry = substr($carry, -$fromLen); 136 | } 137 | } 138 | 139 | if ('' !== $carry) { 140 | yield $carry; 141 | } 142 | } 143 | 144 | abstract protected function getReadConnectionDescription(): string; 145 | } 146 | -------------------------------------------------------------------------------- /Transport/Smtp/Stream/ProcessStream.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Stream; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportException; 15 | 16 | /** 17 | * A stream supporting local processes. 18 | * 19 | * @author Fabien Potencier 20 | * @author Chris Corbyn 21 | * 22 | * @internal 23 | */ 24 | final class ProcessStream extends AbstractStream 25 | { 26 | private string $command; 27 | private bool $interactive = false; 28 | 29 | public function setCommand(string $command): void 30 | { 31 | $this->command = $command; 32 | } 33 | 34 | public function setInteractive(bool $interactive): void 35 | { 36 | $this->interactive = $interactive; 37 | } 38 | 39 | public function initialize(): void 40 | { 41 | $descriptorSpec = [ 42 | 0 => ['pipe', 'r'], 43 | 1 => ['pipe', 'w'], 44 | 2 => ['pipe', '\\' === \DIRECTORY_SEPARATOR ? 'a' : 'w'], 45 | ]; 46 | $pipes = []; 47 | $this->stream = proc_open($this->command, $descriptorSpec, $pipes); 48 | stream_set_blocking($pipes[2], false); 49 | if ($err = stream_get_contents($pipes[2])) { 50 | throw new TransportException('Process could not be started: '.$err); 51 | } 52 | $this->in = &$pipes[0]; 53 | $this->out = &$pipes[1]; 54 | $this->err = &$pipes[2]; 55 | } 56 | 57 | public function terminate(): void 58 | { 59 | if (null !== $this->stream) { 60 | fclose($this->in); 61 | $out = stream_get_contents($this->out); 62 | fclose($this->out); 63 | $err = stream_get_contents($this->err); 64 | fclose($this->err); 65 | if (0 !== $exitCode = proc_close($this->stream)) { 66 | $errorMessage = 'Process failed with exit code '.$exitCode.': '.$out.$err; 67 | } 68 | } 69 | 70 | parent::terminate(); 71 | 72 | if (!$this->interactive && isset($errorMessage)) { 73 | throw new TransportException($errorMessage); 74 | } 75 | } 76 | 77 | protected function getReadConnectionDescription(): string 78 | { 79 | return 'process '.$this->command; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Transport/Smtp/Stream/SocketStream.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport\Smtp\Stream; 13 | 14 | use Symfony\Component\Mailer\Exception\TransportException; 15 | 16 | /** 17 | * A stream supporting remote sockets. 18 | * 19 | * @author Fabien Potencier 20 | * @author Chris Corbyn 21 | * 22 | * @internal 23 | */ 24 | final class SocketStream extends AbstractStream 25 | { 26 | private string $url; 27 | private string $host = 'localhost'; 28 | private int $port = 465; 29 | private float $timeout; 30 | private bool $tls = true; 31 | private ?string $sourceIp = null; 32 | private array $streamContextOptions = []; 33 | 34 | /** 35 | * @return $this 36 | */ 37 | public function setTimeout(float $timeout): static 38 | { 39 | $this->timeout = $timeout; 40 | 41 | return $this; 42 | } 43 | 44 | public function getTimeout(): float 45 | { 46 | return $this->timeout ?? (float) \ini_get('default_socket_timeout'); 47 | } 48 | 49 | /** 50 | * Literal IPv6 addresses should be wrapped in square brackets. 51 | * 52 | * @return $this 53 | */ 54 | public function setHost(string $host): static 55 | { 56 | $this->host = $host; 57 | 58 | return $this; 59 | } 60 | 61 | public function getHost(): string 62 | { 63 | return $this->host; 64 | } 65 | 66 | /** 67 | * @return $this 68 | */ 69 | public function setPort(int $port): static 70 | { 71 | $this->port = $port; 72 | 73 | return $this; 74 | } 75 | 76 | public function getPort(): int 77 | { 78 | return $this->port; 79 | } 80 | 81 | /** 82 | * Sets the TLS/SSL on the socket (disables STARTTLS). 83 | * 84 | * @return $this 85 | */ 86 | public function disableTls(): static 87 | { 88 | $this->tls = false; 89 | 90 | return $this; 91 | } 92 | 93 | public function isTLS(): bool 94 | { 95 | return $this->tls; 96 | } 97 | 98 | /** 99 | * @return $this 100 | */ 101 | public function setStreamOptions(array $options): static 102 | { 103 | $this->streamContextOptions = $options; 104 | 105 | return $this; 106 | } 107 | 108 | public function getStreamOptions(): array 109 | { 110 | return $this->streamContextOptions; 111 | } 112 | 113 | /** 114 | * Sets the source IP. 115 | * 116 | * IPv6 addresses should be wrapped in square brackets. 117 | * 118 | * @return $this 119 | */ 120 | public function setSourceIp(string $ip): static 121 | { 122 | $this->sourceIp = $ip; 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * Returns the IP used to connect to the destination. 129 | */ 130 | public function getSourceIp(): ?string 131 | { 132 | return $this->sourceIp; 133 | } 134 | 135 | public function initialize(): void 136 | { 137 | $this->url = $this->host.':'.$this->port; 138 | if ($this->tls) { 139 | $this->url = 'ssl://'.$this->url; 140 | } 141 | $options = []; 142 | if ($this->sourceIp) { 143 | $options['socket']['bindto'] = $this->sourceIp.':0'; 144 | } 145 | if ($this->streamContextOptions) { 146 | $options = array_merge($options, $this->streamContextOptions); 147 | } 148 | // do it unconditionally as it will be used by STARTTLS as well if supported 149 | $options['ssl']['crypto_method'] ??= \STREAM_CRYPTO_METHOD_TLS_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; 150 | $streamContext = stream_context_create($options); 151 | 152 | $timeout = $this->getTimeout(); 153 | set_error_handler(function ($type, $msg) { 154 | throw new TransportException(\sprintf('Connection could not be established with host "%s": ', $this->url).$msg); 155 | }); 156 | try { 157 | $this->stream = stream_socket_client($this->url, $errno, $errstr, $timeout, \STREAM_CLIENT_CONNECT, $streamContext); 158 | } finally { 159 | restore_error_handler(); 160 | } 161 | 162 | stream_set_blocking($this->stream, true); 163 | stream_set_timeout($this->stream, (int) $timeout, (int) (($timeout - (int) $timeout) * 1000000)); 164 | $this->in = &$this->stream; 165 | $this->out = &$this->stream; 166 | } 167 | 168 | public function startTLS(): bool 169 | { 170 | set_error_handler(function ($type, $msg) { 171 | throw new TransportException('Unable to connect with STARTTLS: '.$msg); 172 | }); 173 | try { 174 | return stream_socket_enable_crypto($this->stream, true); 175 | } finally { 176 | restore_error_handler(); 177 | } 178 | } 179 | 180 | public function terminate(): void 181 | { 182 | if (null !== $this->stream) { 183 | fclose($this->stream); 184 | } 185 | 186 | parent::terminate(); 187 | } 188 | 189 | protected function getReadConnectionDescription(): string 190 | { 191 | return $this->url; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Transport/TransportFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\IncompleteDsnException; 15 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 16 | 17 | /** 18 | * @author Konstantin Myakshin 19 | */ 20 | interface TransportFactoryInterface 21 | { 22 | /** 23 | * @throws UnsupportedSchemeException 24 | * @throws IncompleteDsnException 25 | */ 26 | public function create(Dsn $dsn): TransportInterface; 27 | 28 | public function supports(Dsn $dsn): bool; 29 | } 30 | -------------------------------------------------------------------------------- /Transport/TransportInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 16 | use Symfony\Component\Mailer\SentMessage; 17 | use Symfony\Component\Mime\RawMessage; 18 | 19 | /** 20 | * Interface for all mailer transports. 21 | * 22 | * When sending emails, you should prefer MailerInterface implementations 23 | * as they allow asynchronous sending. 24 | * 25 | * @author Fabien Potencier 26 | */ 27 | interface TransportInterface extends \Stringable 28 | { 29 | /** 30 | * @throws TransportExceptionInterface 31 | */ 32 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage; 33 | } 34 | -------------------------------------------------------------------------------- /Transport/Transports.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Transport; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 16 | use Symfony\Component\Mailer\Exception\LogicException; 17 | use Symfony\Component\Mailer\SentMessage; 18 | use Symfony\Component\Mime\Message; 19 | use Symfony\Component\Mime\RawMessage; 20 | 21 | /** 22 | * @author Fabien Potencier 23 | */ 24 | final class Transports implements TransportInterface 25 | { 26 | /** 27 | * @var array 28 | */ 29 | private array $transports = []; 30 | private TransportInterface $default; 31 | 32 | /** 33 | * @param iterable $transports 34 | */ 35 | public function __construct(iterable $transports) 36 | { 37 | foreach ($transports as $name => $transport) { 38 | $this->default ??= $transport; 39 | $this->transports[$name] = $transport; 40 | } 41 | 42 | if (!$this->transports) { 43 | throw new LogicException(\sprintf('"%s" must have at least one transport configured.', __CLASS__)); 44 | } 45 | } 46 | 47 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 48 | { 49 | /** @var Message $message */ 50 | if (RawMessage::class === $message::class || !$message->getHeaders()->has('X-Transport')) { 51 | return $this->default->send($message, $envelope); 52 | } 53 | 54 | $headers = $message->getHeaders(); 55 | $transport = $headers->get('X-Transport')->getBody(); 56 | $headers->remove('X-Transport'); 57 | 58 | if (!isset($this->transports[$transport])) { 59 | throw new InvalidArgumentException(\sprintf('The "%s" transport does not exist (available transports: "%s").', $transport, implode('", "', array_keys($this->transports)))); 60 | } 61 | 62 | try { 63 | return $this->transports[$transport]->send($message, $envelope); 64 | } catch (\Throwable $e) { 65 | $headers->addTextHeader('X-Transport', $transport); 66 | 67 | throw $e; 68 | } 69 | } 70 | 71 | public function __toString(): string 72 | { 73 | return '['.implode(',', array_keys($this->transports)).']'; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/mailer", 3 | "type": "library", 4 | "description": "Helps sending emails", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "egulias/email-validator": "^2.1.10|^3|^4", 21 | "psr/event-dispatcher": "^1", 22 | "psr/log": "^1|^2|^3", 23 | "symfony/event-dispatcher": "^6.4|^7.0", 24 | "symfony/mime": "^7.2", 25 | "symfony/service-contracts": "^2.5|^3" 26 | }, 27 | "require-dev": { 28 | "symfony/console": "^6.4|^7.0", 29 | "symfony/http-client": "^6.4|^7.0", 30 | "symfony/messenger": "^6.4|^7.0", 31 | "symfony/twig-bridge": "^6.4|^7.0" 32 | }, 33 | "conflict": { 34 | "symfony/http-client-contracts": "<2.5", 35 | "symfony/http-kernel": "<6.4", 36 | "symfony/messenger": "<6.4", 37 | "symfony/mime": "<6.4", 38 | "symfony/twig-bridge": "<6.4" 39 | }, 40 | "autoload": { 41 | "psr-4": { "Symfony\\Component\\Mailer\\": "" }, 42 | "exclude-from-classmap": [ 43 | "/Tests/" 44 | ] 45 | }, 46 | "minimum-stability": "dev" 47 | } 48 | --------------------------------------------------------------------------------