diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4355e632f331..ef5fa8862e85 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\Store\SemaphoreStore; +use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -112,6 +113,7 @@ public function getConfigTreeBuilder() $this->addMessengerSection($rootNode); $this->addRobotsIndexSection($rootNode); $this->addHttpClientSection($rootNode); + $this->addMailerSection($rootNode); return $treeBuilder; } @@ -1344,4 +1346,19 @@ private function addHttpClientOptionsSection(NodeBuilder $rootNode) ->end() ; } + + private function addMailerSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('mailer') + ->info('Mailer configuration') + ->{!class_exists(FullStack::class) && class_exists(Mailer::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->children() + ->scalarNode('dsn')->defaultValue('smtp://null')->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 670033be015b..ceb3062c60c1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -74,6 +74,7 @@ use Symfony\Component\Lock\Store\FlockStore; use Symfony\Component\Lock\Store\StoreFactory; use Symfony\Component\Lock\StoreInterface; +use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; @@ -316,6 +317,10 @@ public function load(array $configs, ContainerBuilder $container) $this->registerHttpClientConfiguration($config['http_client'], $container, $loader); } + if ($this->isConfigEnabled($container, $config['mailer'])) { + $this->registerMailerConfiguration($config['mailer'], $container, $loader); + } + if ($this->isConfigEnabled($container, $config['web_link'])) { if (!class_exists(HttpHeaderSerializer::class)) { throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".'); @@ -1854,6 +1859,16 @@ public function merge(array $options, array $defaultOptions) } } + private function registerMailerConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!class_exists(Mailer::class)) { + throw new LogicException('Mailer support cannot be enabled as the component is not installed. Try running "composer require symfony/mailer".'); + } + + $loader->load('mailer.xml'); + $container->getDefinition('mailer.transport')->setArgument(0, $config['dsn']); + } + /** * Returns the base path for the XSD files. * diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.xml new file mode 100644 index 000000000000..2365eb629d4f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 56be70050ccf..388b5bb4f4d6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Config\Definition\Processor; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Lock\Store\SemaphoreStore; +use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; class ConfigurationTest extends TestCase @@ -336,6 +337,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'enabled' => !class_exists(FullStack::class) && class_exists(HttpClient::class), 'clients' => [], ], + 'mailer' => [ + 'dsn' => 'smtp://null', + 'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class), + ], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 853711aca32c..3e406515a9f0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -42,6 +42,7 @@ "symfony/form": "^4.3", "symfony/expression-language": "~3.4|~4.0", "symfony/http-client": "^4.3", + "symfony/mailer": "^4.3", "symfony/messenger": "^4.3", "symfony/mime": "^4.3", "symfony/process": "~3.4|~4.0", diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index a9db7bf6a861..9e43596d438d 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Mailer\Mailer; use Symfony\Component\Translation\Translator; use Twig\Extension\ExtensionInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -49,6 +50,10 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('console.xml'); } + if (class_exists(Mailer::class)) { + $loader->load('mailer.xml'); + } + if (!class_exists(Translator::class)) { $container->removeDefinition('twig.translation.extractor'); } diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/mailer.xml b/src/Symfony/Bundle/TwigBundle/Resources/config/mailer.xml new file mode 100644 index 000000000000..d61bc32c4540 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/mailer.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Amazon/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Http/Api/SesTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Http/Api/SesTransport.php new file mode 100644 index 000000000000..e71e29a01366 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Http/Api/SesTransport.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Amazon\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Http\Api\AbstractApiTransport; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @experimental in 4.3 + */ +class SesTransport extends AbstractApiTransport +{ + private const ENDPOINT = 'https://email.%region%.amazonaws.com'; + + private $accessKey; + private $secretKey; + private $region; + + /** + * @param string $region Amazon SES region (currently one of us-east-1, us-west-2, or eu-west-1) + */ + public function __construct(string $accessKey, string $secretKey, string $region = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->accessKey = $accessKey; + $this->secretKey = $secretKey; + $this->region = $region ?: 'eu-west-1'; + + parent::__construct($client, $dispatcher, $logger); + } + + protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void + { + $date = gmdate('D, d M Y H:i:s e'); + $auth = sprintf('AWS3-HTTPS AWSAccessKeyId=%s,Algorithm=HmacSHA256,Signature=%s', $this->accessKey, $this->getSignature($date)); + + $endpoint = str_replace('%region%', $this->region, self::ENDPOINT); + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'X-Amzn-Authorization' => $auth, + 'Date' => $date, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => $this->getPayload($email, $envelope), + ]); + + if (200 !== $response->getStatusCode()) { + $error = new \SimpleXMLElement($response->getContent(false)); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $error->Error->Message, $error->Error->Code)); + } + } + + private function getSignature(string $string): string + { + return base64_encode(hash_hmac('sha256', $string, $this->secretKey, true)); + } + + private function getPayload(Email $email, SmtpEnvelope $envelope): array + { + if ($email->getAttachments()) { + return [ + 'Action' => 'SendRawEmail', + 'RawMessage.Data' => \base64_encode($email->toString()), + ]; + } + + $payload = [ + 'Action' => 'SendEmail', + 'Destination.ToAddresses.member' => $this->stringifyAddresses($this->getRecipients($email, $envelope)), + 'Message.Subject.Data' => $email->getSubject(), + 'Source' => $envelope->getSender()->toString(), + ]; + + if ($emails = $email->getCc()) { + $payload['Destination.CcAddresses.member'] = $this->stringifyAddresses($emails); + } + if ($emails = $email->getBcc()) { + $payload['Destination.BccAddresses.member'] = $this->stringifyAddresses($emails); + } + if ($email->getTextBody()) { + $payload['Message.Body.Text.Data'] = $email->getTextBody(); + } + if ($email->getHtmlBody()) { + $payload['Message.Body.Html.Data'] = $email->getHtmlBody(); + } + + return $payload; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Http/SesTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Http/SesTransport.php new file mode 100644 index 000000000000..3a31c8f9d8b7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Http/SesTransport.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Amazon\Http; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @experimental in 4.3 + */ +class SesTransport extends AbstractTransport +{ + private const ENDPOINT = 'https://email.%region%.amazonaws.com'; + + private $client; + private $accessKey; + private $secretKey; + private $region; + + /** + * @param string $region Amazon SES region (currently one of us-east-1, us-west-2, or eu-west-1) + */ + public function __construct(string $accessKey, string $secretKey, string $region = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->client = $client ?? HttpClient::create(); + $this->accessKey = $accessKey; + $this->secretKey = $secretKey; + $this->region = $region ?: 'eu-west-1'; + + parent::__construct($dispatcher, $logger); + } + + protected function doSend(SentMessage $message): void + { + $date = gmdate('D, d M Y H:i:s e'); + $auth = sprintf('AWS3-HTTPS AWSAccessKeyId=%s,Algorithm=HmacSHA256,Signature=%s', $this->accessKey, $this->getSignature($date)); + + $endpoint = str_replace('%region%', $this->region, self::ENDPOINT); + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'X-Amzn-Authorization' => $auth, + 'Date' => $date, + ], + 'body' => [ + 'Action' => 'SendRawEmail', + 'RawMessage.Data' => \base64_encode($message->toString()), + ], + ]); + + if (200 !== $response->getStatusCode()) { + $error = new \SimpleXMLElement($response->getContent(false)); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $error->Error->Message, $error->Error->Code)); + } + } + + private function getSignature(string $string): string + { + return base64_encode(hash_hmac('sha256', $string, $this->secretKey, true)); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE b/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/README.md b/src/Symfony/Component/Mailer/Bridge/Amazon/README.md new file mode 100644 index 000000000000..1159927c41f9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/README.md @@ -0,0 +1,12 @@ +Amazon Mailer +============= + +Provides Amazon SES integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Smtp/SesTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Smtp/SesTransport.php new file mode 100644 index 000000000000..1d666cdecb4a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Smtp/SesTransport.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Amazon\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class SesTransport extends EsmtpTransport +{ + /** + * @param string $region Amazon SES region (currently one of us-east-1, us-west-2, or eu-west-1) + */ + public function __construct(string $username, string $password, string $region = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct(\sprintf('email-smtp.%s.amazonaws.com', $region ?: 'eu-west-1'), 587, 'tls', null, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/composer.json b/src/Symfony/Component/Mailer/Bridge/Amazon/composer.json new file mode 100644 index 000000000000..bda7c65123a5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/amazon-mailer", + "type": "symfony-bridge", + "description": "Symfony Amazon Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Amazon\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Amazon/phpunit.xml.dist new file mode 100644 index 000000000000..d8f7d50fa757 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Google/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Google/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Google/LICENSE b/src/Symfony/Component/Mailer/Bridge/Google/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Google/README.md b/src/Symfony/Component/Mailer/Bridge/Google/README.md new file mode 100644 index 000000000000..ac382d2169dd --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/README.md @@ -0,0 +1,12 @@ +Google Mailer +============= + +Provides Google Gmail integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Google/Smtp/GmailTransport.php b/src/Symfony/Component/Mailer/Bridge/Google/Smtp/GmailTransport.php new file mode 100644 index 000000000000..91da68fcec70 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/Smtp/GmailTransport.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Google\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class GmailTransport extends EsmtpTransport +{ + public function __construct(string $username, string $password, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.gmail.com', 465, 'ssl', null, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Google/composer.json b/src/Symfony/Component/Mailer/Bridge/Google/composer.json new file mode 100644 index 000000000000..bca36a66feaa --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/google-mailer", + "type": "symfony-bridge", + "description": "Symfony Google Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Google\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Google/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Google/phpunit.xml.dist new file mode 100644 index 000000000000..62face7defd8 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Google/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Mailchimp/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/Api/MandrillTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/Api/MandrillTransport.php new file mode 100644 index 000000000000..c1ef083ed224 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/Api/MandrillTransport.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailchimp\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Http\Api\AbstractApiTransport; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @experimental in 4.3 + */ +class MandrillTransport extends AbstractApiTransport +{ + private const ENDPOINT = 'https://mandrillapp.com/api/1.0/messages/send.json'; + + private $key; + + public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + + parent::__construct($client, $dispatcher, $logger); + } + + protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void + { + $response = $this->client->request('POST', self::ENDPOINT, [ + 'json' => $this->getPayload($email, $envelope), + ]); + + if (200 !== $response->getStatusCode()) { + $result = $response->toArray(false); + if ('error' === ($result['status'] ?? false)) { + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $result['message'], $result['code'])); + } + + throw new TransportException(sprintf('Unable to send an email (code %s).', $result['code'])); + } + } + + private function getPayload(Email $email, SmtpEnvelope $envelope): array + { + $payload = [ + 'key' => $this->key, + 'message' => [ + 'html' => $email->getHtmlBody(), + 'text' => $email->getTextBody(), + 'subject' => $email->getSubject(), + 'from_email' => $envelope->getSender()->toString(), + 'to' => $this->getRecipients($email, $envelope), + ], + ]; + + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'content' => $attachment->bodyToString(), + 'type' => $headers->get('Content-Type')->getBody(), + ]; + + if ('inline' === $disposition) { + $payload['images'][] = $att; + } else { + $payload['attachments'][] = $att; + } + } + + $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type']; + foreach ($email->getHeaders()->getAll() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + $payload['message']['headers'][] = $name.': '.$header->toString(); + } + + return $payload; + } + + protected function getRecipients(Email $email, SmtpEnvelope $envelope): array + { + $recipients = []; + foreach ($envelope->getRecipients() as $recipient) { + $type = 'to'; + if (\in_array($recipient, $email->getBcc(), true)) { + $type = 'bcc'; + } elseif (\in_array($recipient, $email->getCc(), true)) { + $type = 'cc'; + } + + $recipients[] = [ + 'email' => $recipient->toString(), + 'type' => $type, + ]; + } + + return $recipients; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/MandrillTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/MandrillTransport.php new file mode 100644 index 000000000000..188d0bcf90a7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Http/MandrillTransport.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailchimp\Http; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @experimental in 4.3 + */ +class MandrillTransport extends AbstractTransport +{ + private const ENDPOINT = 'https://mandrillapp.com/api/1.0/messages/send-raw.json'; + private $client; + private $key; + + public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + $this->client = $client ?? HttpClient::create(); + + parent::__construct($dispatcher, $logger); + } + + protected function doSend(SentMessage $message): void + { + $envelope = $message->getEnvelope(); + $response = $this->client->request('POST', self::ENDPOINT, [ + 'json' => [ + 'key' => $this->key, + 'to' => $this->stringifyAddresses($envelope->getRecipients()), + 'from_email' => $envelope->getSender()->toString(), + 'raw_message' => $message->toString(), + ], + ]); + + if (200 !== $response->getStatusCode()) { + $result = $response->toArray(false); + if ('error' === ($result['status'] ?? false)) { + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $result['message'], $result['code'])); + } + + throw new TransportException(sprintf('Unable to send an email (code %s).', $result['code'])); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/README.md b/src/Symfony/Component/Mailer/Bridge/Mailchimp/README.md new file mode 100644 index 000000000000..56224554a504 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/README.md @@ -0,0 +1,12 @@ +Mailchimp Mailer +================ + +Provides Mandrill integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/Smtp/MandrillTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Smtp/MandrillTransport.php new file mode 100644 index 000000000000..cc61702d8fc4 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/Smtp/MandrillTransport.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailchimp\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @experimental in 4.3 + */ +class MandrillTransport extends EsmtpTransport +{ + public function __construct(string $username, string $password, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.mandrillapp.com', 587, 'tls', null, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/composer.json b/src/Symfony/Component/Mailer/Bridge/Mailchimp/composer.json new file mode 100644 index 000000000000..761ec6989a0a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/mailchimp-mailer", + "type": "symfony-bridge", + "description": "Symfony Mailchimp Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Mailchimp\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Mailchimp/phpunit.xml.dist new file mode 100644 index 000000000000..85e6b6989617 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Mailgun/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php new file mode 100644 index 000000000000..f3e69d00db57 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailgun\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Http\Api\AbstractApiTransport; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MailgunTransport extends AbstractApiTransport +{ + private const ENDPOINT = 'https://api.mailgun.net/v3/%domain%/messages'; + + private $key; + private $domain; + + public function __construct(string $key, string $domain, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + $this->domain = $domain; + + parent::__construct($client, $dispatcher, $logger); + } + + protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void + { + $body = new FormDataPart($this->getPayload($email, $envelope)); + $headers = []; + foreach ($body->getPreparedHeaders()->getAll() as $header) { + $headers[] = $header->toString(); + } + + $endpoint = str_replace('%domain%', urlencode($this->domain), self::ENDPOINT); + $response = $this->client->request('POST', $endpoint, [ + 'auth_basic' => 'api:'.$this->key, + 'headers' => $headers, + 'body' => $body->bodyToIterable(), + ]); + + if (200 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $error['message'], $response->getStatusCode())); + } + } + + private function getPayload(Email $email, SmtpEnvelope $envelope): array + { + $headers = $email->getHeaders(); + $html = $email->getHtmlBody(); + if (null !== $html) { + if (stream_get_meta_data($html)['seekable'] ?? false) { + rewind($html); + } + $html = stream_get_contents($html); + } + [$attachments, $inlines, $html] = $this->prepareAttachments($email, $html); + + $payload = [ + 'from' => $envelope->getSender()->toString(), + 'to' => implode(',', $this->stringifyAddresses($this->getRecipients($email, $envelope))), + 'subject' => $email->getSubject(), + 'attachment' => $attachments, + 'inline' => $inlines, + ]; + if ($emails = $email->getCc()) { + $payload['cc'] = implode(',', $this->stringifyAddresses($emails)); + } + if ($emails = $email->getBcc()) { + $payload['bcc'] = implode(',', $this->stringifyAddresses($emails)); + } + if ($email->getTextBody()) { + $payload['text'] = $email->getTextBody(); + } + if ($html) { + $payload['html'] = $html; + } + + $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type']; + foreach ($headers->getAll() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + $payload['h:'.$name] = $header->toString(); + } + + return $payload; + } + + private function prepareAttachments(Email $email, ?string $html): array + { + $attachments = $inlines = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + if ('inline' === $headers->getHeaderBody('Content-Disposition')) { + // replace the cid with just a file name (the only supported way by Mailgun) + if ($html) { + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $new = basename($filename); + $html = str_replace('cid:'.$filename, 'cid:'.$new, $html); + $p = new \ReflectionProperty($attachment, 'filename'); + $p->setAccessible(true); + $p->setValue($attachment, $new); + } + $inlines[] = $attachment; + } else { + $attachments[] = $attachment; + } + } + + return [$attachments, $inlines, $html]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php new file mode 100644 index 000000000000..0cc7fccd9343 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailgun\Http; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MailgunTransport extends AbstractTransport +{ + private const ENDPOINT = 'https://api.mailgun.net/v3/%domain%/messages.mime'; + private $key; + private $domain; + private $client; + + public function __construct(string $key, string $domain, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + $this->domain = $domain; + $this->client = $client ?? HttpClient::create(); + + parent::__construct($dispatcher, $logger); + } + + protected function doSend(SentMessage $message): void + { + $body = new FormDataPart([ + 'to' => implode(',', $this->stringifyAddresses($message->getEnvelope()->getRecipients())), + 'message' => new DataPart($message->toString(), 'message.mime'), + ]); + $headers = []; + foreach ($body->getPreparedHeaders()->getAll() as $header) { + $headers[] = $header->toString(); + } + $endpoint = str_replace('%domain%', urlencode($this->domain), self::ENDPOINT); + $response = $this->client->request('POST', $endpoint, [ + 'auth_basic' => 'api:'.$this->key, + 'headers' => $headers, + 'body' => $body->bodyToIterable(), + ]); + + if (200 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $error['message'], $response->getStatusCode())); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/README.md b/src/Symfony/Component/Mailer/Bridge/Mailgun/README.md new file mode 100644 index 000000000000..4c04b71595d5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/README.md @@ -0,0 +1,12 @@ +Mailgun Mailer +============== + +Provides Mailgun integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php new file mode 100644 index 000000000000..c9cf087bad3e --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailgun\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MailgunTransport extends EsmtpTransport +{ + public function __construct(string $username, string $password, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.mailgun.org', 465, 'ssl', null, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/composer.json b/src/Symfony/Component/Mailer/Bridge/Mailgun/composer.json new file mode 100644 index 000000000000..6f00d507ebe6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/mailgun-mailer", + "type": "symfony-bridge", + "description": "Symfony Mailgun Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Mailgun\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Mailgun/phpunit.xml.dist new file mode 100644 index 000000000000..7c705f80d49b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Postmark/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/Http/Api/PostmarkTransport.php b/src/Symfony/Component/Mailer/Bridge/Postmark/Http/Api/PostmarkTransport.php new file mode 100644 index 000000000000..7a73579ce500 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/Http/Api/PostmarkTransport.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Postmark\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Http\Api\AbstractApiTransport; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class PostmarkTransport extends AbstractApiTransport +{ + private const ENDPOINT = 'http://api.postmarkapp.com/email'; + + private $key; + + public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + + parent::__construct($client, $dispatcher, $logger); + } + + protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void + { + $response = $this->client->request('POST', self::ENDPOINT, [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-Postmark-Server-Token' => $this->key, + ], + 'json' => $this->getPayload($email, $envelope), + ]); + + if (200 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', $error['Message'], $error['ErrorCode'])); + } + } + + private function getPayload(Email $email, SmtpEnvelope $envelope): array + { + $payload = [ + 'From' => $envelope->getSender()->toString(), + 'To' => implode(',', $this->stringifyAddresses($this->getRecipients($email, $envelope))), + 'Cc' => implode(',', $this->stringifyAddresses($email->getCc())), + 'Bcc' => implode(',', $this->stringifyAddresses($email->getBcc())), + 'Subject' => $email->getSubject(), + 'TextBody' => $email->getTextBody(), + 'HtmlBody' => $email->getHtmlBody(), + 'Attachments' => $this->getAttachments($email), + ]; + + $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'sender']; + foreach ($email->getHeaders()->getAll() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + $payload['Headers'][] = [ + 'Name' => $name, + 'Value' => $header->toString(), + ]; + } + + return $payload; + } + + private function getAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'Name' => $filename, + 'Content' => $attachment->bodyToString(), + 'ContentType' => $headers->get('Content-Type')->getBody(), + ]; + + if ('inline' === $disposition) { + $att['ContentID'] = 'cid:'.$filename; + } + + $attachments[] = $att; + } + + return $attachments; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE b/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/README.md b/src/Symfony/Component/Mailer/Bridge/Postmark/README.md new file mode 100644 index 000000000000..44246cfe0904 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/README.md @@ -0,0 +1,12 @@ +Postmark Bridge +=============== + +Provides Postmark integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/Smtp/PostmarkTransport.php b/src/Symfony/Component/Mailer/Bridge/Postmark/Smtp/PostmarkTransport.php new file mode 100644 index 000000000000..ceee67d722a3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/Smtp/PostmarkTransport.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Postmark\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class PostmarkTransport extends EsmtpTransport +{ + public function __construct(string $id, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.postmarkapp.com', 587, 'tls', null, $dispatcher, $logger); + + $this->setUsername($id); + $this->setPassword($id); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/composer.json b/src/Symfony/Component/Mailer/Bridge/Postmark/composer.json new file mode 100644 index 000000000000..0493f1dfb085 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/postmark-mailer", + "type": "symfony-bridge", + "description": "Symfony Postmark Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Postmark\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Postmark/phpunit.xml.dist new file mode 100644 index 000000000000..07e40cc0c53a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Postmark/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/.gitignore b/src/Symfony/Component/Mailer/Bridge/Sendgrid/.gitignore new file mode 100644 index 000000000000..c49a5d8df5c6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md new file mode 100644 index 000000000000..453e0d98fa8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Http/Api/SendgridTransport.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Http/Api/SendgridTransport.php new file mode 100644 index 000000000000..9e4871e72dd7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Http/Api/SendgridTransport.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Http\Api\AbstractApiTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @experimental in 4.3 + */ +class SendgridTransport extends AbstractApiTransport +{ + private const ENDPOINT = 'https://api.sendgrid.com/v3/mail/send'; + + private $key; + + public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + + parent::__construct($client, $dispatcher, $logger); + } + + protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void + { + $response = $this->client->request('POST', self::ENDPOINT, [ + 'json' => $this->getPayload($email, $envelope), + 'auth_bearer' => $this->key, + ]); + + if (202 !== $response->getStatusCode()) { + $errors = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send an email: %s (code %s).', implode('; ', array_column($errors['errors'], 'message')), $response->getStatusCode())); + } + } + + private function getPayload(Email $email, SmtpEnvelope $envelope): array + { + $addressStringifier = function (Address $address) {return ['email' => $address->toString()]; }; + + $payload = [ + 'personalizations' => [], + 'from' => ['email' => $envelope->getSender()->toString()], + 'content' => $this->getContent($email), + ]; + + if ($email->getAttachments()) { + $payload['attachments'] = $this->getAttachments($email); + } + + $personalization = [ + 'to' => \array_map($addressStringifier, $this->getRecipients($email, $envelope)), + 'subject' => $email->getSubject(), + ]; + if ($emails = array_map($addressStringifier, $email->getCc())) { + $personalization['cc'] = $emails; + } + if ($emails = array_map($addressStringifier, $email->getBcc())) { + $personalization['bcc'] = $emails; + } + + $payload['personalizations'][] = $personalization; + + // these headers can't be overwritten according to Sendgrid docs + // see https://developers.pepipost.com/migration-api/new-subpage/email-send + $headersToBypass = ['x-sg-id', 'x-sg-eid', 'received', 'dkim-signature', 'content-transfer-encoding', 'from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'reply-to']; + foreach ($email->getHeaders()->getAll() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + $payload['headers'][$name] = $header->toString(); + } + + return $payload; + } + + private function getContent(Email $email): array + { + $content = []; + if (null !== $text = $email->getTextBody()) { + $content[] = ['type' => 'text/plain', 'value' => $text]; + } + if (null !== $html = $email->getHtmlBody()) { + $content[] = ['type' => 'text/html', 'value' => $html]; + } + + return $content; + } + + private function getAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'content' => $attachment->bodyToString(), + 'type' => $headers->get('Content-Type')->getBody(), + 'filename' => $filename, + 'disposition' => $disposition, + ]; + + if ('inline' === $disposition) { + $att['content_id'] = $filename; + } + + $attachments[] = $att; + } + + return $attachments; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE b/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md b/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md new file mode 100644 index 000000000000..647d746be973 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/README.md @@ -0,0 +1,12 @@ +Sendgrid Bridge +=============== + +Provides Sendgrid integration for Symfony Mailer. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Smtp/SendgridTransport.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Smtp/SendgridTransport.php new file mode 100644 index 000000000000..fc28a6e2cb37 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Smtp/SendgridTransport.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sendgrid\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class SendgridTransport extends EsmtpTransport +{ + public function __construct(string $key, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.sendgrid.net', 465, 'ssl', null, $dispatcher, $logger); + + $this->setUsername('apikey'); + $this->setPassword($key); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json new file mode 100644 index 000000000000..5630f5d3f40f --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/sendgrid-mailer", + "type": "symfony-bridge", + "description": "Symfony Sendgrid Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "symfony/mailer": "^4.3" + }, + "require-dev": { + "symfony/http-client": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Sendgrid\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Sendgrid/phpunit.xml.dist new file mode 100644 index 000000000000..350d6c2059c1 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md new file mode 100644 index 000000000000..086e3305a7eb --- /dev/null +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * Added the component diff --git a/src/Symfony/Component/Mailer/Event/MessageEvent.php b/src/Symfony/Component/Mailer/Event/MessageEvent.php new file mode 100644 index 000000000000..a0891e98688c --- /dev/null +++ b/src/Symfony/Component/Mailer/Event/MessageEvent.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Event; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\RawMessage; + +/** + * Allows the transformation of a Message. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MessageEvent extends Event +{ + private $message; + private $envelope; + + public function __construct(RawMessage $message, SmtpEnvelope $envelope) + { + $this->message = $message; + $this->envelope = $envelope; + } + + public function getMessage(): RawMessage + { + return $this->message; + } + + public function setMessage(RawMessage $message): void + { + $this->message = $message; + } + + public function getEnvelope(): SmtpEnvelope + { + return $this->envelope; + } + + public function setEnvelope(SmtpEnvelope $envelope): void + { + $this->envelope = $envelope; + } +} diff --git a/src/Symfony/Component/Mailer/EventListener/EnvelopeListener.php b/src/Symfony/Component/Mailer/EventListener/EnvelopeListener.php new file mode 100644 index 000000000000..e4b22b48baaa --- /dev/null +++ b/src/Symfony/Component/Mailer/EventListener/EnvelopeListener.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Mailer\Event\MessageEvent; +use Symfony\Component\Mime\Address; + +/** + * Manipulates the Envelope of a Message. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class EnvelopeListener implements EventSubscriberInterface +{ + private $sender; + private $recipients; + + /** + * @param Address|string $sender + * @param (Address|string)[] $recipients + */ + public function __construct($sender = null, array $recipients = null) + { + if (null !== $sender) { + $this->sender = Address::create($sender); + } + if (null !== $recipients) { + $this->recipients = Address::createArray($recipients); + } + } + + public function onMessage(MessageEvent $event): void + { + if ($this->sender) { + $event->getEnvelope()->setSender($this->sender); + } + + if ($this->recipients) { + $event->getEnvelope()->setRecipients($this->recipients); + } + } + + public static function getSubscribedEvents() + { + return [ + // should be the last one to allow header changes by other listeners first + MessageEvent::class => ['onMessage', -255], + ]; + } +} diff --git a/src/Symfony/Component/Mailer/EventListener/MessageListener.php b/src/Symfony/Component/Mailer/EventListener/MessageListener.php new file mode 100644 index 000000000000..c63595ada02f --- /dev/null +++ b/src/Symfony/Component/Mailer/EventListener/MessageListener.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Mailer\Event\MessageEvent; +use Symfony\Component\Mime\BodyRendererInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\Message; + +/** + * Manipulates the headers and the body of a Message. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MessageListener implements EventSubscriberInterface +{ + private $headers; + private $renderer; + + public function __construct(Headers $headers = null, BodyRendererInterface $renderer = null) + { + $this->headers = $headers; + $this->renderer = $renderer; + } + + public function onMessage(MessageEvent $event): void + { + $message = $event->getMessage(); + if (!$message instanceof Message) { + return; + } + + $this->setHeaders($message); + $this->renderMessage($message); + } + + private function setHeaders(Message $message): void + { + if (!$this->headers) { + return; + } + + $headers = $message->getHeaders(); + foreach ($this->headers->getAll() as $name => $header) { + if (!$headers->has($name)) { + $headers->add($header); + } else { + if (Headers::isUniqueHeader($name)) { + continue; + } + $headers->add($header); + } + } + $message->setHeaders($headers); + } + + private function renderMessage(Message $message): void + { + if (!$this->renderer) { + return; + } + + $this->renderer->render($message); + } + + public static function getSubscribedEvents() + { + return [ + MessageEvent::class => 'onMessage', + ]; + } +} diff --git a/src/Symfony/Component/Mailer/Exception/ExceptionInterface.php b/src/Symfony/Component/Mailer/Exception/ExceptionInterface.php new file mode 100644 index 000000000000..6339d82260d9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * Exception interface for all exceptions thrown by the component. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/HttpTransportException.php b/src/Symfony/Component/Mailer/Exception/HttpTransportException.php new file mode 100644 index 000000000000..ea9c1c85fb8f --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/HttpTransportException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class HttpTransportException extends TransportException +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/InvalidArgumentException.php b/src/Symfony/Component/Mailer/Exception/InvalidArgumentException.php new file mode 100644 index 000000000000..371bef87dd28 --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/LogicException.php b/src/Symfony/Component/Mailer/Exception/LogicException.php new file mode 100644 index 000000000000..9cbc6c5ea32f --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/RuntimeException.php b/src/Symfony/Component/Mailer/Exception/RuntimeException.php new file mode 100644 index 000000000000..0904c65d8883 --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/TransportException.php b/src/Symfony/Component/Mailer/Exception/TransportException.php new file mode 100644 index 000000000000..3763694f68ed --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/TransportException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class TransportException extends RuntimeException implements TransportExceptionInterface +{ +} diff --git a/src/Symfony/Component/Mailer/Exception/TransportExceptionInterface.php b/src/Symfony/Component/Mailer/Exception/TransportExceptionInterface.php new file mode 100644 index 000000000000..47e7e8dc3e32 --- /dev/null +++ b/src/Symfony/Component/Mailer/Exception/TransportExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +interface TransportExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Mailer/LICENSE b/src/Symfony/Component/Mailer/LICENSE new file mode 100644 index 000000000000..1a1869751d25 --- /dev/null +++ b/src/Symfony/Component/Mailer/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Mailer.php b/src/Symfony/Component/Mailer/Mailer.php new file mode 100644 index 000000000000..6ed345146fe2 --- /dev/null +++ b/src/Symfony/Component/Mailer/Mailer.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Symfony\Component\Mailer\Messenger\SendEmailMessage; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class Mailer implements MailerInterface +{ + private $transport; + private $bus; + + public function __construct(TransportInterface $transport, MessageBusInterface $bus = null) + { + $this->transport = $transport; + $this->bus = $bus; + } + + public function send(RawMessage $message, SmtpEnvelope $envelope = null): void + { + if (null === $this->bus) { + $this->transport->send($message, $envelope); + + return; + } + + $this->bus->dispatch(new SendEmailMessage($message, $envelope)); + } +} diff --git a/src/Symfony/Component/Mailer/MailerInterface.php b/src/Symfony/Component/Mailer/MailerInterface.php new file mode 100644 index 000000000000..1a54e4d4c063 --- /dev/null +++ b/src/Symfony/Component/Mailer/MailerInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mime\RawMessage; + +/** + * Interface for mailers able to send emails synchronous and/or asynchronous. + * + * Implementations must support synchronous and asynchronous sending. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +interface MailerInterface +{ + /** + * @throws TransportExceptionInterface + */ + public function send(RawMessage $message, SmtpEnvelope $envelope = null): void; +} diff --git a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php new file mode 100644 index 000000000000..6f1d609ceed1 --- /dev/null +++ b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Messenger; + +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class MessageHandler +{ + private $transport; + + public function __construct(TransportInterface $transport) + { + $this->transport = $transport; + } + + public function __invoke(SendEmailMessage $message) + { + $this->transport->send($message->getMessage(), $message->getEnvelope()); + } +} diff --git a/src/Symfony/Component/Mailer/Messenger/SendEmailMessage.php b/src/Symfony/Component/Mailer/Messenger/SendEmailMessage.php new file mode 100644 index 000000000000..862a1eecc83f --- /dev/null +++ b/src/Symfony/Component/Mailer/Messenger/SendEmailMessage.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Messenger; + +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class SendEmailMessage +{ + private $message; + private $envelope; + + /** + * @internal + */ + public function __construct(RawMessage $message, SmtpEnvelope $envelope = null) + { + $this->message = $message; + $this->envelope = $envelope; + } + + public function getMessage(): RawMessage + { + return $this->message; + } + + public function getEnvelope(): ?SmtpEnvelope + { + return $this->envelope; + } +} diff --git a/src/Symfony/Component/Mailer/README.md b/src/Symfony/Component/Mailer/README.md new file mode 100644 index 000000000000..0f70cc30d74b --- /dev/null +++ b/src/Symfony/Component/Mailer/README.md @@ -0,0 +1,12 @@ +Mailer Component +================ + +The Mailer component helps sending emails. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/SentMessage.php b/src/Symfony/Component/Mailer/SentMessage.php new file mode 100644 index 000000000000..3a7f5ddfa86b --- /dev/null +++ b/src/Symfony/Component/Mailer/SentMessage.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class SentMessage +{ + private $original; + private $raw; + private $envelope; + + /** + * @internal + */ + public function __construct(RawMessage $message, SmtpEnvelope $envelope) + { + $this->raw = $message instanceof Message ? new RawMessage($message->toIterable()) : $message; + $this->original = $message; + $this->envelope = $envelope; + } + + public function getMessage(): RawMessage + { + return $this->raw; + } + + public function getOriginalMessage(): RawMessage + { + return $this->original; + } + + public function getEnvelope(): SmtpEnvelope + { + return $this->envelope; + } + + public function toString(): string + { + return $this->raw->toString(); + } + + public function toIterable(): iterable + { + return $this->raw->toIterable(); + } +} diff --git a/src/Symfony/Component/Mailer/SmtpEnvelope.php b/src/Symfony/Component/Mailer/SmtpEnvelope.php new file mode 100644 index 000000000000..6a41027305c1 --- /dev/null +++ b/src/Symfony/Component/Mailer/SmtpEnvelope.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\NamedAddress; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class SmtpEnvelope +{ + private $sender; + private $recipients = []; + + /** + * @param Address[] $recipients + */ + public function __construct(Address $sender, array $recipients) + { + $this->setSender($sender); + $this->setRecipients($recipients); + } + + public static function create(RawMessage $message): self + { + if ($message instanceof Message) { + $headers = $message->getHeaders(); + + return new self(self::getSenderFromHeaders($headers), self::getRecipientsFromHeaders($headers)); + } + + // FIXME: parse the raw message to create the envelope? + throw new InvalidArgumentException(sprintf('Unable to create an SmtpEnvelope from a "%s" message.', RawMessage::class)); + } + + public function setSender(Address $sender): void + { + $this->sender = $sender instanceof NamedAddress ? new Address($sender->getAddress()) : $sender; + } + + public function getSender(): Address + { + return $this->sender; + } + + public function setRecipients(array $recipients): void + { + if (!$recipients) { + throw new InvalidArgumentException('An envelope must have at least one recipient.'); + } + + $this->recipients = []; + foreach ($recipients as $recipient) { + if ($recipient instanceof NamedAddress) { + $recipient = new Address($recipient->getAddress()); + } elseif (!$recipient instanceof Address) { + throw new InvalidArgumentException(sprintf('A recipient must be an instance of "%s" (got "%s").', Address::class, \is_object($recipient) ? \get_class($recipient) : \gettype($recipient))); + } + $this->recipients[] = $recipient; + } + } + + /** + * @return Address[] + */ + public function getRecipients(): array + { + return $this->recipients; + } + + private static function getRecipientsFromHeaders(Headers $headers): array + { + $recipients = []; + foreach (['to', 'cc', 'bcc'] as $name) { + foreach ($headers->getAll($name) as $header) { + $recipients = array_merge($recipients, $header->getAddresses()); + } + } + + return $recipients; + } + + private static function getSenderFromHeaders(Headers $headers): Address + { + if ($return = $headers->get('Return-Path')) { + return $return->getAddress(); + } + if ($sender = $headers->get('Sender')) { + return $sender->getAddress(); + } + if ($from = $headers->get('From')) { + return $from->getAddresses()[0]; + } + + throw new LogicException('Unable to determine the sender of the message.'); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/SentMessageTest.php b/src/Symfony/Component/Mailer/Tests/SentMessageTest.php new file mode 100644 index 000000000000..a8193bb04a5d --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/SentMessageTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\RawMessage; + +class SentMessageTest extends TestCase +{ + public function test() + { + $m = new SentMessage($r = new RawMessage('Email'), $e = new SmtpEnvelope(new Address('fabien@example.com'), [new Address('helene@example.com')])); + $this->assertSame($r, $m->getOriginalMessage()); + $this->assertSame($r, $m->getMessage()); + $this->assertSame($e, $m->getEnvelope()); + $this->assertEquals($r->toString(), $m->toString()); + $this->assertEquals($r->toIterable(), $m->toIterable()); + + $m = new SentMessage($r = (new Email())->from('fabien@example.com')->to('helene@example.com')->text('text'), $e); + $this->assertSame($r, $m->getOriginalMessage()); + $this->assertNotSame($r, $m->getMessage()); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/SmtpEnvelopeTest.php b/src/Symfony/Component/Mailer/Tests/SmtpEnvelopeTest.php new file mode 100644 index 000000000000..4e0c17f5e838 --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/SmtpEnvelopeTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\NamedAddress; +use Symfony\Component\Mime\RawMessage; + +class SmtpEnvelopeTest extends TestCase +{ + public function testConstructorWithAddressSender() + { + $e = new SmtpEnvelope(new Address('fabien@symfony.com'), [new Address('thomas@symfony.com')]); + $this->assertEquals(new Address('fabien@symfony.com'), $e->getSender()); + } + + public function testConstructorWithNamedAddressSender() + { + $e = new SmtpEnvelope(new NamedAddress('fabien@symfony.com', 'Fabien'), [new Address('thomas@symfony.com')]); + $this->assertEquals(new Address('fabien@symfony.com'), $e->getSender()); + } + + public function testConstructorWithAddressRecipients() + { + $e = new SmtpEnvelope(new Address('fabien@symfony.com'), [new Address('thomas@symfony.com'), new NamedAddress('lucas@symfony.com', 'Lucas')]); + $this->assertEquals([new Address('thomas@symfony.com'), new Address('lucas@symfony.com')], $e->getRecipients()); + } + + public function testConstructorWithNoRecipients() + { + $this->expectException(\InvalidArgumentException::class); + $e = new SmtpEnvelope(new Address('fabien@symfony.com'), []); + } + + public function testConstructorWithWrongRecipients() + { + $this->expectException(\InvalidArgumentException::class); + $e = new SmtpEnvelope(new Address('fabien@symfony.com'), ['lucas@symfony.com']); + } + + public function testSenderFromHeaders() + { + $headers = new Headers(); + $headers->addPathHeader('Return-Path', 'return@symfony.com'); + $headers->addMailboxListHeader('To', ['from@symfony.com']); + $e = SmtpEnvelope::create(new Message($headers)); + $this->assertEquals('return@symfony.com', $e->getSender()->getAddress()); + + $headers = new Headers(); + $headers->addMailboxHeader('Sender', 'sender@symfony.com'); + $headers->addMailboxListHeader('To', ['from@symfony.com']); + $e = SmtpEnvelope::create(new Message($headers)); + $this->assertEquals('sender@symfony.com', $e->getSender()->getAddress()); + + $headers = new Headers(); + $headers->addMailboxListHeader('From', ['from@symfony.com', 'some@symfony.com']); + $headers->addMailboxListHeader('To', ['from@symfony.com']); + $e = SmtpEnvelope::create(new Message($headers)); + $this->assertEquals('from@symfony.com', $e->getSender()->getAddress()); + } + + public function testSenderFromHeadersWithoutData() + { + $this->expectException(\LogicException::class); + $headers = new Headers(); + $headers->addMailboxListHeader('To', ['from@symfony.com']); + SmtpEnvelope::create(new Message($headers)); + } + + public function testRecipientsFromHeaders() + { + $headers = new Headers(); + $headers->addPathHeader('Return-Path', 'return@symfony.com'); + $headers->addMailboxListHeader('To', ['to@symfony.com']); + $headers->addMailboxListHeader('Cc', ['cc@symfony.com']); + $headers->addMailboxListHeader('Bcc', ['bcc@symfony.com']); + $e = SmtpEnvelope::create(new Message($headers)); + $this->assertEquals([new Address('to@symfony.com'), new Address('cc@symfony.com'), new Address('bcc@symfony.com')], $e->getRecipients()); + } + + public function testCreateWithRawMessage() + { + $this->expectException(\InvalidArgumentException::class); + SmtpEnvelope::create(new RawMessage('')); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php new file mode 100644 index 000000000000..d3d8e438bd3f --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\NullTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\RawMessage; + +/** + * @group time-sensitive + */ +class AbstractTransportTest extends TestCase +{ + public function testThrottling() + { + $transport = new NullTransport(); + $transport->setMaxPerSecond(2 / 10); + $message = new RawMessage(''); + $envelope = new SmtpEnvelope(new Address('fabien@example.com'), [new Address('helene@example.com')]); + + $start = time(); + $transport->send($message, $envelope); + $this->assertEquals(0, time() - $start, '', 1); + $transport->send($message, $envelope); + $this->assertEquals(5, time() - $start, '', 1); + $transport->send($message, $envelope); + $this->assertEquals(10, time() - $start, '', 1); + $transport->send($message, $envelope); + $this->assertEquals(15, time() - $start, '', 1); + + $start = time(); + $transport->setMaxPerSecond(-3); + $transport->send($message, $envelope); + $this->assertEquals(0, time() - $start, '', 1); + $transport->send($message, $envelope); + $this->assertEquals(0, time() - $start, '', 1); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/Transport/FailoverTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/FailoverTransportTest.php new file mode 100644 index 000000000000..dc3bc21a7dc4 --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/Transport/FailoverTransportTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Transport\FailoverTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\RawMessage; + +/** + * @group time-sensitive + */ +class FailoverTransportTest extends TestCase +{ + public function testSendNoTransports() + { + $this->expectException(TransportException::class); + new FailoverTransport([]); + } + + public function testSendFirstWork() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->exactly(3))->method('send'); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->never())->method('send'); + $t = new FailoverTransport([$t1, $t2]); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + } + + public function testSendAllDead() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t = new FailoverTransport([$t1, $t2]); + $this->expectException(TransportException::class); + $this->expectExceptionMessage('All transports failed.'); + $t->send(new RawMessage('')); + } + + public function testSendOneDead() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->exactly(3))->method('send'); + $t = new FailoverTransport([$t1, $t2]); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + } + + public function testSendOneDeadButRecover() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->at(0))->method('send')->will($this->throwException(new TransportException())); + $t1->expects($this->at(1))->method('send'); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->at(0))->method('send'); + $t2->expects($this->at(1))->method('send'); + $t2->expects($this->at(2))->method('send')->will($this->throwException(new TransportException())); + $t = new FailoverTransport([$t1, $t2], 1); + $t->send(new RawMessage('')); + sleep(1); + $t->send(new RawMessage('')); + sleep(1); + $t->send(new RawMessage('')); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/Transport/RoundRobinTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/RoundRobinTransportTest.php new file mode 100644 index 000000000000..b27a3e794984 --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/Transport/RoundRobinTransportTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Transport\RoundRobinTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\RawMessage; + +/** + * @group time-sensitive + */ +class RoundRobinTransportTest extends TestCase +{ + public function testSendNoTransports() + { + $this->expectException(TransportException::class); + new RoundRobinTransport([]); + } + + public function testSendAlternate() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->exactly(2))->method('send'); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->once())->method('send'); + $t = new RoundRobinTransport([$t1, $t2]); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + } + + public function testSendAllDead() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t = new RoundRobinTransport([$t1, $t2]); + $this->expectException(TransportException::class); + $this->expectExceptionMessage('All transports failed.'); + $t->send(new RawMessage('')); + } + + public function testSendOneDead() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->once())->method('send')->will($this->throwException(new TransportException())); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->exactly(3))->method('send'); + $t = new RoundRobinTransport([$t1, $t2]); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + $t->send(new RawMessage('')); + } + + public function testSendOneDeadButRecover() + { + $t1 = $this->createMock(TransportInterface::class); + $t1->expects($this->at(0))->method('send')->will($this->throwException(new TransportException())); + $t1->expects($this->at(1))->method('send'); + $t2 = $this->createMock(TransportInterface::class); + $t2->expects($this->once())->method('send'); + $t = new RoundRobinTransport([$t1, $t2], 1); + $t->send(new RawMessage('')); + sleep(2); + $t->send(new RawMessage('')); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/Stream/AbstractStreamTest.php b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/Stream/AbstractStreamTest.php new file mode 100644 index 000000000000..cc901ccb7cea --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/Stream/AbstractStreamTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests\Transport\Smtp\Stream; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; + +class AbstractStreamTest extends TestCase +{ + /** + * @dataProvider provideReplace + */ + public function testReplace(string $expected, string $from, string $to, array $chunks) + { + $result = ''; + foreach (AbstractStream::replace($from, $to, $chunks) as $chunk) { + $result .= $chunk; + } + + $this->assertSame($expected, $result); + } + + public function provideReplace() + { + yield ['ca', 'ab', 'c', ['a', 'b', 'a']]; + yield ['ac', 'ab', 'c', ['a', 'ab']]; + yield ['cbc', 'aba', 'c', ['ababa', 'ba']]; + } +} diff --git a/src/Symfony/Component/Mailer/Tests/TransportTest.php b/src/Symfony/Component/Mailer/Tests/TransportTest.php new file mode 100644 index 000000000000..4ecec2e66a0f --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/TransportTest.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Bridge\Amazon; +use Symfony\Component\Mailer\Bridge\Google; +use Symfony\Component\Mailer\Bridge\Mailchimp; +use Symfony\Component\Mailer\Bridge\Mailgun; +use Symfony\Component\Mailer\Bridge\Postmark; +use Symfony\Component\Mailer\Bridge\Sendgrid; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mailer\Transport; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class TransportTest extends TestCase +{ + public function testFromDsnNull() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://null', $dispatcher, null, $logger); + $this->assertInstanceOf(Transport\NullTransport::class, $transport); + $p = new \ReflectionProperty(Transport\AbstractTransport::class, 'dispatcher'); + $p->setAccessible(true); + $this->assertSame($dispatcher, $p->getValue($transport)); + } + + public function testFromDsnSendmail() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://sendmail', $dispatcher, null, $logger); + $this->assertInstanceOf(Transport\SendmailTransport::class, $transport); + $p = new \ReflectionProperty(Transport\AbstractTransport::class, 'dispatcher'); + $p->setAccessible(true); + $this->assertSame($dispatcher, $p->getValue($transport)); + } + + public function testFromDsnSmtp() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://localhost:44?auth_mode=plain&encryption=tls', $dispatcher, null, $logger); + $this->assertInstanceOf(Transport\Smtp\SmtpTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger); + $this->assertEquals('localhost', $transport->getStream()->getHost()); + $this->assertEquals('plain', $transport->getAuthMode()); + $this->assertTrue($transport->getStream()->isTLS()); + $this->assertEquals(44, $transport->getStream()->getPort()); + } + + public function testFromInvalidDsn() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "some://" mailer DSN is invalid.'); + Transport::fromDsn('some://'); + } + + public function testFromInvalidDsnNoHost() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "?!" mailer DSN must contain a mailer name.'); + Transport::fromDsn('?!'); + } + + public function testFromInvalidTransportName() + { + $this->expectException(LogicException::class); + Transport::fromDsn('api://foobar'); + } + + public function testFromDsnGmail() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@gmail', $dispatcher, null, $logger); + $this->assertInstanceOf(Google\Smtp\GmailTransport::class, $transport); + $this->assertEquals('u$er', $transport->getUsername()); + $this->assertEquals('pa$s', $transport->getPassword()); + $this->assertProperties($transport, $dispatcher, $logger); + + $this->expectException(LogicException::class); + Transport::fromDsn('http://gmail'); + } + + public function testFromDsnMailgun() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, null, $logger); + $this->assertInstanceOf(Mailgun\Smtp\MailgunTransport::class, $transport); + $this->assertEquals('u$er', $transport->getUsername()); + $this->assertEquals('pa$s', $transport->getPassword()); + $this->assertProperties($transport, $dispatcher, $logger); + + $client = $this->createMock(HttpClientInterface::class); + $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); + $this->assertInstanceOf(Mailgun\Http\MailgunTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'domain' => 'pa$s', + 'client' => $client, + ]); + + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); + $this->assertInstanceOf(Mailgun\Http\Api\MailgunTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'domain' => 'pa$s', + 'client' => $client, + ]); + + $this->expectException(LogicException::class); + Transport::fromDsn('foo://mailgun'); + } + + public function testFromDsnPostmark() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').'@postmark', $dispatcher, null, $logger); + $this->assertInstanceOf(Postmark\Smtp\PostmarkTransport::class, $transport); + $this->assertEquals('u$er', $transport->getUsername()); + $this->assertEquals('u$er', $transport->getPassword()); + $this->assertProperties($transport, $dispatcher, $logger); + + $client = $this->createMock(HttpClientInterface::class); + $transport = Transport::fromDsn('api://'.urlencode('u$er').'@postmark', $dispatcher, $client, $logger); + $this->assertInstanceOf(Postmark\Http\Api\PostmarkTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'client' => $client, + ]); + + $this->expectException(LogicException::class); + Transport::fromDsn('http://postmark'); + } + + public function testFromDsnSendgrid() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').'@sendgrid', $dispatcher, null, $logger); + $this->assertInstanceOf(Sendgrid\Smtp\SendgridTransport::class, $transport); + $this->assertEquals('apikey', $transport->getUsername()); + $this->assertEquals('u$er', $transport->getPassword()); + $this->assertProperties($transport, $dispatcher, $logger); + + $client = $this->createMock(HttpClientInterface::class); + $transport = Transport::fromDsn('api://'.urlencode('u$er').'@sendgrid', $dispatcher, $client, $logger); + $this->assertInstanceOf(Sendgrid\Http\Api\SendgridTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'client' => $client, + ]); + + $this->expectException(LogicException::class); + Transport::fromDsn('http://sendgrid'); + } + + public function testFromDsnAmazonSes() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@ses?region=sun', $dispatcher, null, $logger); + $this->assertInstanceOf(Amazon\Smtp\SesTransport::class, $transport); + $this->assertEquals('u$er', $transport->getUsername()); + $this->assertEquals('pa$s', $transport->getPassword()); + $this->assertContains('.sun.', $transport->getStream()->getHost()); + $this->assertProperties($transport, $dispatcher, $logger); + + $client = $this->createMock(HttpClientInterface::class); + $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@ses?region=sun', $dispatcher, $client, $logger); + $this->assertInstanceOf(Amazon\Http\SesTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'accessKey' => 'u$er', + 'secretKey' => 'pa$s', + 'region' => 'sun', + 'client' => $client, + ]); + + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@ses?region=sun', $dispatcher, $client, $logger); + $this->assertInstanceOf(Amazon\Http\Api\SesTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'accessKey' => 'u$er', + 'secretKey' => 'pa$s', + 'region' => 'sun', + 'client' => $client, + ]); + + $this->expectException(LogicException::class); + Transport::fromDsn('foo://ses'); + } + + public function testFromDsnMailchimp() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@mandrill', $dispatcher, null, $logger); + $this->assertInstanceOf(Mailchimp\Smtp\MandrillTransport::class, $transport); + $this->assertEquals('u$er', $transport->getUsername()); + $this->assertEquals('pa$s', $transport->getPassword()); + $this->assertProperties($transport, $dispatcher, $logger); + + $client = $this->createMock(HttpClientInterface::class); + $transport = Transport::fromDsn('http://'.urlencode('u$er').'@mandrill', $dispatcher, $client, $logger); + $this->assertInstanceOf(Mailchimp\Http\MandrillTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'client' => $client, + ]); + + $transport = Transport::fromDsn('api://'.urlencode('u$er').'@mandrill', $dispatcher, $client, $logger); + $this->assertInstanceOf(Mailchimp\Http\Api\MandrillTransport::class, $transport); + $this->assertProperties($transport, $dispatcher, $logger, [ + 'key' => 'u$er', + 'client' => $client, + ]); + + $this->expectException(LogicException::class); + Transport::fromDsn('foo://mandrill'); + } + + public function testFromDsnFailover() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://null || smtp://null || smtp://null', $dispatcher, null, $logger); + $this->assertInstanceOf(Transport\FailoverTransport::class, $transport); + $p = new \ReflectionProperty(Transport\RoundRobinTransport::class, 'transports'); + $p->setAccessible(true); + $transports = $p->getValue($transport); + $this->assertCount(3, $transports); + foreach ($transports as $transport) { + $this->assertProperties($transport, $dispatcher, $logger); + } + } + + public function testFromDsnRoundRobin() + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $transport = Transport::fromDsn('smtp://null && smtp://null && smtp://null', $dispatcher, null, $logger); + $this->assertInstanceOf(Transport\RoundRobinTransport::class, $transport); + $p = new \ReflectionProperty(Transport\RoundRobinTransport::class, 'transports'); + $p->setAccessible(true); + $transports = $p->getValue($transport); + $this->assertCount(3, $transports); + foreach ($transports as $transport) { + $this->assertProperties($transport, $dispatcher, $logger); + } + } + + private function assertProperties(Transport\TransportInterface $transport, EventDispatcherInterface $dispatcher, LoggerInterface $logger, array $props = []) + { + $p = new \ReflectionProperty(Transport\AbstractTransport::class, 'dispatcher'); + $p->setAccessible(true); + $this->assertSame($dispatcher, $p->getValue($transport)); + + $p = new \ReflectionProperty(Transport\AbstractTransport::class, 'logger'); + $p->setAccessible(true); + $this->assertSame($logger, $p->getValue($transport)); + + foreach ($props as $prop => $value) { + $p = new \ReflectionProperty($transport, $prop); + $p->setAccessible(true); + $this->assertEquals($value, $p->getValue($transport)); + } + } +} diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php new file mode 100644 index 000000000000..c97f0c49a3a5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Bridge\Amazon; +use Symfony\Component\Mailer\Bridge\Google; +use Symfony\Component\Mailer\Bridge\Mailchimp; +use Symfony\Component\Mailer\Bridge\Mailgun; +use Symfony\Component\Mailer\Bridge\Postmark; +use Symfony\Component\Mailer\Bridge\Sendgrid; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class Transport +{ + public static function fromDsn(string $dsn, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null): TransportInterface + { + // failover? + $dsns = preg_split('/\s++\|\|\s++/', $dsn); + if (\count($dsns) > 1) { + $transports = []; + foreach ($dsns as $dsn) { + $transports[] = self::createTransport($dsn, $dispatcher, $client, $logger); + } + + return new Transport\FailoverTransport($transports); + } + + // round robin? + $dsns = preg_split('/\s++&&\s++/', $dsn); + if (\count($dsns) > 1) { + $transports = []; + foreach ($dsns as $dsn) { + $transports[] = self::createTransport($dsn, $dispatcher, $client, $logger); + } + + return new Transport\RoundRobinTransport($transports); + } + + return self::createTransport($dsn, $dispatcher, $client, $logger); + } + + private static function createTransport(string $dsn, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null): TransportInterface + { + if (false === $parsedDsn = parse_url($dsn)) { + throw new InvalidArgumentException(sprintf('The "%s" mailer DSN is invalid.', $dsn)); + } + + if (!isset($parsedDsn['host'])) { + throw new InvalidArgumentException(sprintf('The "%s" mailer DSN must contain a mailer name.', $dsn)); + } + + $user = \urldecode($parsedDsn['user'] ?? ''); + $pass = \urldecode($parsedDsn['pass'] ?? ''); + \parse_str($parsedDsn['query'] ?? '', $query); + + switch ($parsedDsn['host']) { + case 'null': + if ('smtp' === $parsedDsn['scheme']) { + return new Transport\NullTransport($dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'sendmail': + if ('smtp' === $parsedDsn['scheme']) { + return new Transport\SendmailTransport(null, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'gmail': + if (!class_exists(Google\Smtp\GmailTransport::class)) { + throw new \LogicException('Unable to send emails via Gmail as the Google bridge is not installed. Try running "composer require symfony/google-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Google\Smtp\GmailTransport($user, $pass, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'mailgun': + if (!class_exists(Mailgun\Smtp\MailgunTransport::class)) { + throw new \LogicException('Unable to send emails via Mailgun as the bridge is not installed. Try running "composer require symfony/mailgun-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Mailgun\Smtp\MailgunTransport($user, $pass, $dispatcher, $logger); + } + if ('http' === $parsedDsn['scheme']) { + return new Mailgun\Http\MailgunTransport($user, $pass, $client, $dispatcher, $logger); + } + if ('api' === $parsedDsn['scheme']) { + return new Mailgun\Http\Api\MailgunTransport($user, $pass, $client, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'postmark': + if (!class_exists(Postmark\Smtp\PostmarkTransport::class)) { + throw new \LogicException('Unable to send emails via Postmark as the bridge is not installed. Try running "composer require symfony/postmark-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Postmark\Smtp\PostmarkTransport($user, $dispatcher, $logger); + } + if ('api' === $parsedDsn['scheme']) { + return new Postmark\Http\Api\PostmarkTransport($user, $client, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'sendgrid': + if (!class_exists(Sendgrid\Smtp\SendgridTransport::class)) { + throw new \LogicException('Unable to send emails via Sendgrid as the bridge is not installed. Try running "composer require symfony/sendgrid-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Sendgrid\Smtp\SendgridTransport($user, $dispatcher, $logger); + } + if ('api' === $parsedDsn['scheme']) { + return new Sendgrid\Http\Api\SendgridTransport($user, $client, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'ses': + if (!class_exists(Amazon\Smtp\SesTransport::class)) { + throw new \LogicException('Unable to send emails via Amazon SES as the bridge is not installed. Try running "composer require symfony/amazon-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Amazon\Smtp\SesTransport($user, $pass, $query['region'] ?? null, $dispatcher, $logger); + } + if ('api' === $parsedDsn['scheme']) { + return new Amazon\Http\Api\SesTransport($user, $pass, $query['region'] ?? null, $client, $dispatcher, $logger); + } + if ('http' === $parsedDsn['scheme']) { + return new Amazon\Http\SesTransport($user, $pass, $query['region'] ?? null, $client, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + case 'mandrill': + if (!class_exists(Mailchimp\Smtp\MandrillTransport::class)) { + throw new \LogicException('Unable to send emails via Mandrill as the bridge is not installed. Try running "composer require symfony/mailchimp-bridge".'); + } + + if ('smtp' === $parsedDsn['scheme']) { + return new Mailchimp\Smtp\MandrillTransport($user, $pass, $dispatcher, $logger); + } + if ('api' === $parsedDsn['scheme']) { + return new Mailchimp\Http\Api\MandrillTransport($user, $client, $dispatcher, $logger); + } + if ('http' === $parsedDsn['scheme']) { + return new Mailchimp\Http\MandrillTransport($user, $client, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); + default: + if ('smtp' === $parsedDsn['scheme']) { + return new Transport\Smtp\EsmtpTransport($parsedDsn['host'], $parsedDsn['port'] ?? 25, $query['encryption'] ?? null, $query['auth_mode'] ?? null, $dispatcher, $logger); + } + + throw new LogicException(sprintf('The "%s" mailer is not supported.', $parsedDsn['host'])); + } + } +} diff --git a/src/Symfony/Component/Mailer/Transport/AbstractTransport.php b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php new file mode 100644 index 000000000000..deebd538f5e7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Event\MessageEvent; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +abstract class AbstractTransport implements TransportInterface +{ + private $dispatcher; + private $logger; + private $rate = 0; + private $lastSent = 0; + + public function __construct(EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->dispatcher = $dispatcher ?: new EventDispatcher(); + $this->logger = $logger ?: new NullLogger(); + } + + /** + * Sets the maximum number of messages to send per second (0 to disable). + */ + public function setMaxPerSecond(float $rate): self + { + if (0 >= $rate) { + $rate = 0; + } + + $this->rate = $rate; + $this->lastSent = 0; + + return $this; + } + + public function send(RawMessage $message, SmtpEnvelope $envelope = null): ?SentMessage + { + $message = clone $message; + if (null !== $envelope) { + $envelope = clone $envelope; + } else { + try { + $envelope = SmtpEnvelope::create($message); + } catch (\Exception $e) { + throw new TransportException('Cannot send message without a valid envelope.', 0, $e); + } + } + + $event = new MessageEvent($message, $envelope); + $this->dispatcher->dispatch($event); + $envelope = $event->getEnvelope(); + if (!$envelope->getRecipients()) { + return null; + } + + $message = new SentMessage($event->getMessage(), $envelope); + $this->doSend($message); + + $this->checkThrottling(); + + return $message; + } + + abstract protected function doSend(SentMessage $message): void; + + /** + * @param Address[] $addresses + * + * @return string[] + */ + protected function stringifyAddresses(array $addresses): array + { + return \array_map(function (Address $a) { + return $a->toString(); + }, $addresses); + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + private function checkThrottling() + { + if (0 == $this->rate) { + return; + } + + $sleep = (1 / $this->rate) - (microtime(true) - $this->lastSent); + if (0 < $sleep) { + $this->logger->debug(sprintf('Email transport "%s" sleeps for %.2f seconds', __CLASS__, $sleep)); + usleep($sleep * 1000000); + } + $this->lastSent = microtime(true); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/FailoverTransport.php b/src/Symfony/Component/Mailer/Transport/FailoverTransport.php new file mode 100644 index 000000000000..9bb9b58638ee --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/FailoverTransport.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +/** + * Uses several Transports using a failover algorithm. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class FailoverTransport extends RoundRobinTransport +{ + private $currentTransport; + + protected function getNextTransport(): ?TransportInterface + { + if (null === $this->currentTransport || $this->isTransportDead($this->currentTransport)) { + $this->currentTransport = parent::getNextTransport(); + } + + return $this->currentTransport; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Http/Api/AbstractApiTransport.php b/src/Symfony/Component/Mailer/Transport/Http/Api/AbstractApiTransport.php new file mode 100644 index 000000000000..89c25ca37661 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Http/Api/AbstractApiTransport.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Http\Api; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Mailer\Exception\RuntimeException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\MessageConverter; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +abstract class AbstractApiTransport extends AbstractTransport +{ + protected $client; + + public function __construct(HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->client = $client; + if (null === $client) { + if (!class_exists(HttpClient::class)) { + throw new \LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); + } + + $this->client = HttpClient::create(); + } + + parent::__construct($dispatcher, $logger); + } + + abstract protected function doSendEmail(Email $email, SmtpEnvelope $envelope): void; + + protected function doSend(SentMessage $message): void + { + try { + $email = MessageConverter::toEmail($message->getOriginalMessage()); + } catch (\Exception $e) { + throw new RuntimeException(sprintf('Unable to send message with the "%s" transport: %s', __CLASS__, $e->getMessage()), 0, $e); + } + + $this->doSendEmail($email, $message->getEnvelope()); + } + + protected function getRecipients(Email $email, SmtpEnvelope $envelope): array + { + return \array_filter($envelope->getRecipients(), function (Address $address) use ($email) { + return false === \in_array($address, \array_merge($email->getCc(), $email->getBcc()), true); + }); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/NullTransport.php b/src/Symfony/Component/Mailer/Transport/NullTransport.php new file mode 100644 index 000000000000..ac5e7d2406d1 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/NullTransport.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Symfony\Component\Mailer\SentMessage; + +/** + * Pretends messages have been sent, but just ignores them. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +final class NullTransport extends AbstractTransport +{ + protected function doSend(SentMessage $message): void + { + } +} diff --git a/src/Symfony/Component/Mailer/Transport/RoundRobinTransport.php b/src/Symfony/Component/Mailer/Transport/RoundRobinTransport.php new file mode 100644 index 000000000000..22b1ba971434 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/RoundRobinTransport.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\RawMessage; + +/** + * Uses several Transports using a round robin algorithm. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +class RoundRobinTransport implements TransportInterface +{ + private $deadTransports; + private $transports = []; + private $retryPeriod; + + /** + * @param TransportInterface[] $transports + */ + public function __construct(array $transports, int $retryPeriod = 60) + { + if (!$transports) { + throw new TransportException(__CLASS__.' must have at least one transport configured.'); + } + + $this->transports = $transports; + $this->deadTransports = new \SplObjectStorage(); + $this->retryPeriod = $retryPeriod; + } + + public function send(RawMessage $message, SmtpEnvelope $envelope = null): ?SentMessage + { + while ($transport = $this->getNextTransport()) { + try { + return $transport->send($message, $envelope); + } catch (TransportExceptionInterface $e) { + $this->deadTransports[$transport] = microtime(true); + } + } + + throw new TransportException('All transports failed.'); + } + + /** + * Rotates the transport list around and returns the first instance. + */ + protected function getNextTransport(): ?TransportInterface + { + while ($transport = array_shift($this->transports)) { + if (!$this->isTransportDead($transport)) { + break; + } + if ((microtime(true) - $this->deadTransports[$transport]) > $this->retryPeriod) { + $this->deadTransports->detach($transport); + + break; + } + } + + if ($transport) { + $this->transports[] = $transport; + } + + return $transport; + } + + protected function isTransportDead(TransportInterface $transport): bool + { + return $this->deadTransports->contains($transport); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/SendmailTransport.php b/src/Symfony/Component/Mailer/Transport/SendmailTransport.php new file mode 100644 index 000000000000..b8b4512a3603 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/SendmailTransport.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport; +use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; +use Symfony\Component\Mailer\Transport\Smtp\Stream\ProcessStream; +use Symfony\Component\Mime\RawMessage; + +/** + * SendmailTransport for sending mail through a Sendmail/Postfix (etc..) binary. + * + * Supported modes are -bs and -t, with any additional flags desired. + * It is advised to use -bs mode since error reporting with -t mode is not + * possible. + * + * @author Fabien Potencier + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class SendmailTransport extends AbstractTransport +{ + private $command = '/usr/sbin/sendmail -bs'; + private $stream; + private $transport; + + /** + * Constructor. + * + * If using -t mode you are strongly advised to include -oi or -i in the flags. + * For example: /usr/sbin/sendmail -oi -t + * -f flag will be appended automatically if one is not present. + * + * The recommended mode is "-bs" since it is interactive and failure notifications are hence possible. + */ + public function __construct(string $command = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct($dispatcher, $logger); + + if (null !== $command) { + if (false === strpos($command, ' -bs') && false === strpos($command, ' -t')) { + throw new \InvalidArgumentException(sprintf('Unsupported sendmail command flags "%s"; must be one of "-bs" or "-t" but can include additional flags.', $command)); + } + + $this->command = $command; + } + + $this->stream = new ProcessStream(); + if (false !== strpos($this->command, ' -bs')) { + $this->stream->setCommand($this->command); + $this->transport = new SmtpTransport($this->stream, $dispatcher, $logger); + } + } + + public function send(RawMessage $message, SmtpEnvelope $envelope = null): ?SentMessage + { + if ($this->transport) { + return $this->transport->send($message, $envelope); + } + + return parent::send($message, $envelope); + } + + protected function doSend(SentMessage $message): void + { + $this->getLogger()->debug(sprintf('Email transport "%s" starting', __CLASS__)); + + $command = $this->command; + if (false === strpos($command, ' -f')) { + $command .= ' -f'.escapeshellarg($message->getEnvelope()->getSender()->getEncodedAddress()); + } + + $chunks = AbstractStream::replace("\r\n", "\n", $message->toIterable()); + + if (false === strpos($command, ' -i') && false === strpos($command, ' -oi')) { + $chunks = AbstractStream::replace("\n.", "\n..", $chunks); + } + + $this->stream->setCommand($command); + $this->stream->initialize(); + foreach ($chunks as $chunk) { + $this->stream->write($chunk); + } + $this->stream->flush(); + $this->stream->terminate(); + + $this->getLogger()->debug(sprintf('Email transport "%s" stopped', __CLASS__)); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Auth/AuthenticatorInterface.php b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/AuthenticatorInterface.php new file mode 100644 index 000000000000..c5171b2e1d93 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/AuthenticatorInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Auth; + +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * An Authentication mechanism. + * + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +interface AuthenticatorInterface +{ + /** + * Tries to authenticate the user. + * + * @throws TransportExceptionInterface + */ + public function authenticate(EsmtpTransport $client): void; + + /** + * Gets the name of the AUTH mechanism this Authenticator handles. + */ + public function getAuthKeyword(): string; +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Auth/CramMd5Authenticator.php b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/CramMd5Authenticator.php new file mode 100644 index 000000000000..a79c2b445aa1 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/CramMd5Authenticator.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Auth; + +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * Handles CRAM-MD5 authentication. + * + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class CramMd5Authenticator implements AuthenticatorInterface +{ + public function getAuthKeyword(): string + { + return 'CRAM-MD5'; + } + + /** + * {@inheritdoc} + * + * @see https://www.ietf.org/rfc/rfc4954.txt + */ + public function authenticate(EsmtpTransport $client): void + { + $challenge = $client->executeCommand("AUTH CRAM-MD5\r\n", [334]); + $challenge = base64_decode(substr($challenge, 4)); + $message = base64_encode($client->getUsername().' '.$this->getResponse($client->getPassword(), $challenge)); + $client->executeCommand(sprintf("%s\r\n", $message), [235]); + } + + /** + * Generates a CRAM-MD5 response from a server challenge. + */ + private function getResponse(string $secret, string $challenge): string + { + if (\strlen($secret) > 64) { + $secret = pack('H32', md5($secret)); + } + + if (\strlen($secret) < 64) { + $secret = str_pad($secret, 64, \chr(0)); + } + + $kipad = substr($secret, 0, 64) ^ str_repeat(\chr(0x36), 64); + $kopad = substr($secret, 0, 64) ^ str_repeat(\chr(0x5C), 64); + + $inner = pack('H32', md5($kipad.$challenge)); + $digest = md5($kopad.$inner); + + return $digest; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Auth/LoginAuthenticator.php b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/LoginAuthenticator.php new file mode 100644 index 000000000000..b8203bd1363e --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/LoginAuthenticator.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Auth; + +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * Handles LOGIN authentication. + * + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class LoginAuthenticator implements AuthenticatorInterface +{ + public function getAuthKeyword(): string + { + return 'LOGIN'; + } + + /** + * {@inheritdoc} + * + * @see https://www.ietf.org/rfc/rfc4954.txt + */ + public function authenticate(EsmtpTransport $client): void + { + $client->executeCommand("AUTH LOGIN\r\n", [334]); + $client->executeCommand(sprintf("%s\r\n", base64_encode($client->getUsername())), [334]); + $client->executeCommand(sprintf("%s\r\n", base64_encode($client->getPassword())), [235]); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Auth/PlainAuthenticator.php b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/PlainAuthenticator.php new file mode 100644 index 000000000000..eb8386e17f1a --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/PlainAuthenticator.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Auth; + +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * Handles PLAIN authentication. + * + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class PlainAuthenticator implements AuthenticatorInterface +{ + public function getAuthKeyword(): string + { + return 'PLAIN'; + } + + /** + * {@inheritdoc} + * + * @see https://www.ietf.org/rfc/rfc4954.txt + */ + public function authenticate(EsmtpTransport $client): void + { + $client->executeCommand(sprintf("AUTH PLAIN %s\r\n", base64_encode($client->getUsername().\chr(0).$client->getUsername().\chr(0).$client->getPassword())), [235]); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Auth/XOAuth2Authenticator.php b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/XOAuth2Authenticator.php new file mode 100644 index 000000000000..931df6514f07 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Auth/XOAuth2Authenticator.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Auth; + +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * Handles XOAUTH2 authentication. + * + * @author xu.li + * + * @see https://developers.google.com/google-apps/gmail/xoauth2_protocol + * + * @experimental in 4.3 + */ +class XOAuth2Authenticator implements AuthenticatorInterface +{ + public function getAuthKeyword(): string + { + return 'XOAUTH2'; + } + + /** + * {@inheritdoc} + * + * @see https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism + */ + public function authenticate(EsmtpTransport $client): void + { + $client->executeCommand('AUTH XOAUTH2 '.base64_encode('user='.$client->getUsername()."\1auth=Bearer ".$client->getPassword()."\1\1")."\r\n", [235]); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php new file mode 100644 index 000000000000..fc0ee2ca8f61 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\Transport\Smtp\Auth\AuthenticatorInterface; +use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; + +/** + * Sends Emails over SMTP with ESMTP support. + * + * @author Fabien Potencier + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class EsmtpTransport extends SmtpTransport +{ + private $authenticators = []; + private $username; + private $password; + private $authMode; + + public function __construct(string $host = 'localhost', int $port = 25, string $encryption = null, string $authMode = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct(null, $dispatcher, $logger); + + $this->authenticators = [ + new Auth\PlainAuthenticator(), + new Auth\LoginAuthenticator(), + new Auth\XOAuth2Authenticator(), + new Auth\CramMd5Authenticator(), + ]; + + /** @var SocketStream $stream */ + $stream = $this->getStream(); + $stream->setHost($host); + $stream->setPort($port); + if (null !== $encryption) { + $stream->setEncryption($encryption); + } + if (null !== $authMode) { + $this->setAuthMode($authMode); + } + } + + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + public function getUsername(): string + { + return $this->username; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function getPassword(): string + { + return $this->password; + } + + public function setAuthMode(string $mode): self + { + $this->authMode = $mode; + + return $this; + } + + public function getAuthMode(): string + { + return $this->authMode; + } + + public function addAuthenticator(AuthenticatorInterface $authenticator): void + { + $this->authenticators[] = $authenticator; + } + + protected function doHeloCommand(): void + { + try { + $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); + } catch (TransportExceptionInterface $e) { + parent::doHeloCommand(); + + return; + } + + /** @var SocketStream $stream */ + $stream = $this->getStream(); + if ($stream->isTLS()) { + $this->executeCommand("STARTTLS\r\n", [220]); + + if (!$stream->startTLS()) { + throw new TransportException('Unable to connect with TLS encryption.'); + } + + try { + $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); + } catch (TransportExceptionInterface $e) { + parent::doHeloCommand(); + + return; + } + } + + $capabilities = $this->getCapabilities($response); + if (\array_key_exists('AUTH', $capabilities)) { + $this->handleAuth($capabilities['AUTH']); + } + } + + private function getCapabilities($ehloResponse): array + { + $capabilities = []; + $lines = explode("\r\n", trim($ehloResponse)); + array_shift($lines); + foreach ($lines as $line) { + if (preg_match('/^[0-9]{3}[ -]([A-Z0-9-]+)((?:[ =].*)?)$/Di', $line, $matches)) { + $value = strtoupper(ltrim($matches[2], ' =')); + $capabilities[strtoupper($matches[1])] = $value ? explode(' ', $value) : []; + } + } + + return $capabilities; + } + + private function handleAuth(array $modes): void + { + if (!$this->username) { + return; + } + + $authNames = []; + $errors = []; + $modes = array_map('strtolower', $modes); + foreach ($this->getActiveAuthenticators() as $authenticator) { + if (!\in_array(strtolower($authenticator->getAuthKeyword()), $modes, true)) { + continue; + } + + $authNames[] = $authenticator->getAuthKeyword(); + try { + $authenticator->authenticate($this); + + return; + } catch (TransportExceptionInterface $e) { + $this->executeCommand("RSET\r\n", [250]); + + // keep the error message, but tries the other authenticators + $errors[$authenticator->getAuthKeyword()] = $e; + } + } + + if (!$authNames) { + throw new TransportException('Failed to find an authenticator supported by the SMTP server.'); + } + + $message = sprintf('Failed to authenticate on SMTP server with username "%s" using the following authenticators: "%s".', $this->username, implode('", "', $authNames)); + foreach ($errors as $name => $error) { + $message .= sprintf(' Authenticator "%s" returned "%s".', $name, $error); + } + + throw new TransportException($message); + } + + /** + * @return AuthenticatorInterface[] + */ + private function getActiveAuthenticators(): array + { + if (!$mode = strtolower($this->authMode)) { + return $this->authenticators; + } + + foreach ($this->authenticators as $authenticator) { + if (strtolower($authenticator->getAuthKeyword()) === $mode) { + return [$authenticator]; + } + } + + throw new TransportException(sprintf('Auth mode "%s" is invalid.', $mode)); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php new file mode 100644 index 000000000000..54f5e2a09655 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; +use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; +use Symfony\Component\Mime\RawMessage; + +/** + * Sends emails over SMTP. + * + * @author Fabien Potencier + * @author Chris Corbyn + * + * @experimental in 4.3 + */ +class SmtpTransport extends AbstractTransport +{ + private $started = false; + private $restartThreshold = 100; + private $restartThresholdSleep = 0; + private $restartCounter; + private $stream; + private $domain = '[127.0.0.1]'; + + public function __construct(AbstractStream $stream = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct($dispatcher, $logger); + + $this->stream = $stream ?: new SocketStream(); + } + + public function getStream(): AbstractStream + { + return $this->stream; + } + + /** + * Sets the maximum number of messages to send before re-starting the transport. + * + * By default, the threshold is set to 100 (and no sleep at restart). + * + * @param int $threshold The maximum number of messages (0 to disable) + * @param int $sleep The number of seconds to sleep between stopping and re-starting the transport + */ + public function setRestartThreshold(int $threshold, int $sleep = 0): self + { + $this->restartThreshold = $threshold; + $this->restartThresholdSleep = $sleep; + + return $this; + } + + /** + * Sets the name of the local domain that will be used in HELO. + * + * This should be a fully-qualified domain name and should be truly the domain + * you're using. + * + * If your server does not have a domain name, use the IP address. This will + * automatically be wrapped in square brackets as described in RFC 5321, + * section 4.1.3. + */ + public function setLocalDomain(string $domain): self + { + if ('' !== $domain && '[' !== $domain[0]) { + if (filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $domain = '['.$domain.']'; + } elseif (filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $domain = '[IPv6:'.$domain.']'; + } + } + + $this->domain = $domain; + + return $this; + } + + /** + * Gets the name of the domain that will be used in HELO. + * + * If an IP address was specified, this will be returned wrapped in square + * brackets as described in RFC 5321, section 4.1.3. + */ + public function getLocalDomain(): string + { + return $this->domain; + } + + public function send(RawMessage $message, SmtpEnvelope $envelope = null): ?SentMessage + { + $this->ping(); + if (!$this->started) { + $this->start(); + } + + $message = parent::send($message, $envelope); + + $this->checkRestartThreshold(); + + return $message; + } + + /** + * Runs a command against the stream, expecting the given response codes. + * + * @param int[] $codes + * + * @return string The server response + * + * @throws TransportException when an invalid response if received + * + * @internal + */ + public function executeCommand(string $command, array $codes): string + { + $this->getLogger()->debug(sprintf('Email transport "%s" sent command "%s"', __CLASS__, trim($command))); + $this->stream->write($command); + $response = $this->getFullResponse(); + $this->assertResponseCode($response, $codes); + + return $response; + } + + protected function doSend(SentMessage $message): void + { + $envelope = $message->getEnvelope(); + $this->doMailFromCommand($envelope->getSender()->toString()); + foreach ($envelope->getRecipients() as $recipient) { + $this->doRcptToCommand($recipient->toString()); + } + + $this->executeCommand("DATA\r\n", [354]); + foreach (AbstractStream::replace("\r\n.", "\r\n..", $message->toIterable()) as $chunk) { + $this->stream->write($chunk); + } + $this->stream->flush(); + $this->executeCommand("\r\n.\r\n", [250]); + } + + protected function doHeloCommand(): void + { + $this->executeCommand(sprintf("HELO %s\r\n", $this->domain), [250]); + } + + private function doMailFromCommand($address): void + { + $this->executeCommand(sprintf("MAIL FROM:<%s>\r\n", $address), [250]); + } + + private function doRcptToCommand($address): void + { + $this->executeCommand(sprintf("RCPT TO:<%s>\r\n", $address), [250, 251, 252]); + } + + private function start(): void + { + if ($this->started) { + return; + } + + $this->getLogger()->debug(sprintf('Email transport "%s" starting', __CLASS__)); + + $this->stream->initialize(); + $this->assertResponseCode($this->getFullResponse(), [220]); + $this->doHeloCommand(); + $this->started = true; + + $this->getLogger()->debug(sprintf('Email transport "%s" started', __CLASS__)); + } + + private function stop(): void + { + if (!$this->started) { + return; + } + + $this->getLogger()->debug(sprintf('Email transport "%s" stopping', __CLASS__)); + + try { + $this->executeCommand("QUIT\r\n", [221]); + } catch (TransportExceptionInterface $e) { + } finally { + $this->stream->terminate(); + $this->started = false; + $this->getLogger()->debug(sprintf('Email transport "%s" stopped', __CLASS__)); + } + } + + private function ping(): void + { + if (!$this->started) { + return; + } + + try { + $this->executeCommand("NOOP\r\n", [250]); + } catch (TransportExceptionInterface $e) { + try { + $this->stop(); + } catch (TransportExceptionInterface $e) { + } + } + } + + /** + * @throws TransportException if a response code is incorrect + */ + private function assertResponseCode(string $response, array $codes): void + { + if (!$codes) { + throw new LogicException('You must set the expected response code.'); + } + + if (!$response) { + throw new TransportException(sprintf('Expected response code "%s" but got an empty response.', implode('/', $codes))); + } + + list($code) = sscanf($response, '%3d'); + $valid = \in_array($code, $codes); + + $this->getLogger()->debug(sprintf('Email transport "%s" received response "%s" (%s).', __CLASS__, trim($response), $valid ? 'ok' : 'error')); + + if (!$valid) { + throw new TransportException(sprintf('Expected response code "%s" but got code "%s", with message "%s".', implode('/', $codes), $code, trim($response)), $code); + } + } + + private function getFullResponse(): string + { + $response = ''; + do { + $line = $this->stream->readLine(); + $response .= $line; + } while ($line && isset($line[3]) && ' ' !== $line[3]); + + return $response; + } + + private function checkRestartThreshold(): void + { + // when using sendmail via non-interactive mode, the transport is never "started" + if (!$this->started) { + return; + } + + ++$this->restartCounter; + if ($this->restartCounter < $this->restartThreshold) { + return; + } + + $this->stop(); + if (0 < $sleep = $this->restartThresholdSleep) { + $this->getLogger()->debug(sprintf('Email transport "%s" sleeps for %d seconds after stopping', __CLASS__, $sleep)); + + sleep($sleep); + } + $this->start(); + $this->restartCounter = 0; + } + + public function __destruct() + { + $this->stop(); + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Stream/AbstractStream.php b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/AbstractStream.php new file mode 100644 index 000000000000..5d9e2715c3f3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/AbstractStream.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Stream; + +use Symfony\Component\Mailer\Exception\TransportException; + +/** + * A stream supporting remote sockets and local processes. + * + * @author Fabien Potencier + * @author Nicolas Grekas + * @author Chris Corbyn + * + * @internal + * + * @experimental in 4.3 + */ +abstract class AbstractStream +{ + protected $stream; + protected $in; + protected $out; + + public function write(string $bytes): void + { + $bytesToWrite = \strlen($bytes); + $totalBytesWritten = 0; + while ($totalBytesWritten < $bytesToWrite) { + $bytesWritten = fwrite($this->in, substr($bytes, $totalBytesWritten)); + if (false === $bytesWritten || 0 === $bytesWritten) { + throw new TransportException('Unable to write bytes on the wire.'); + } + + $totalBytesWritten += $bytesWritten; + } + } + + /** + * Flushes the contents of the stream (empty it) and set the internal pointer to the beginning. + */ + public function flush(): void + { + fflush($this->in); + } + + /** + * Performs any initialization needed. + */ + abstract public function initialize(): void; + + public function terminate(): void + { + $this->stream = $this->out = $this->in = null; + } + + public function readLine(): string + { + if (feof($this->out)) { + return ''; + } + + $line = fgets($this->out); + if (0 === \strlen($line)) { + $metas = stream_get_meta_data($this->out); + if ($metas['timed_out']) { + throw new TransportException(sprintf('Connection to "%s" timed out.', $this->getReadConnectionDescription())); + } + } + + return $line; + } + + public static function replace(string $from, string $to, iterable $chunks): \Generator + { + if ('' === $from) { + yield from $chunks; + + return; + } + + $carry = ''; + $fromLen = \strlen($from); + + foreach ($chunks as $chunk) { + if ('' === $chunk = $carry.$chunk) { + continue; + } + + if (false !== strpos($chunk, $from)) { + $chunk = explode($from, $chunk); + $carry = array_pop($chunk); + + yield implode($to, $chunk).$to; + } else { + $carry = $chunk; + } + + if (\strlen($carry) > $fromLen) { + yield substr($carry, 0, -$fromLen); + $carry = substr($carry, -$fromLen); + } + } + + if ('' !== $carry) { + yield $carry; + } + } + + abstract protected function getReadConnectionDescription(): string; +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Stream/ProcessStream.php b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/ProcessStream.php new file mode 100644 index 000000000000..dfbf930840d8 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/ProcessStream.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Stream; + +use Symfony\Component\Mailer\Exception\TransportException; + +/** + * A stream supporting local processes. + * + * @author Fabien Potencier + * @author Chris Corbyn + * + * @internal + * + * @experimental in 4.3 + */ +final class ProcessStream extends AbstractStream +{ + private $command; + + public function setCommand(string $command) + { + $this->command = $command; + } + + public function initialize(): void + { + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $pipes = []; + $this->stream = proc_open($this->command, $descriptorSpec, $pipes); + stream_set_blocking($pipes[2], false); + if ($err = stream_get_contents($pipes[2])) { + throw new TransportException(sprintf('Process could not be started: %s.', $err)); + } + $this->in = &$pipes[0]; + $this->out = &$pipes[1]; + } + + public function terminate(): void + { + if (null !== $this->stream) { + fclose($this->in); + fclose($this->out); + proc_close($this->stream); + } + + parent::terminate(); + } + + protected function getReadConnectionDescription(): string + { + return 'process '.$this->command; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/Stream/SocketStream.php b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/SocketStream.php new file mode 100644 index 000000000000..07692b11bac7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/Smtp/Stream/SocketStream.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport\Smtp\Stream; + +use Symfony\Component\Mailer\Exception\TransportException; + +/** + * A stream supporting remote sockets. + * + * @author Fabien Potencier + * @author Chris Corbyn + * + * @internal + * + * @experimental in 4.3 + */ +final class SocketStream extends AbstractStream +{ + private $url; + private $host = 'localhost'; + private $protocol = 'tcp'; + private $port = 25; + private $timeout = 15; + private $tls = false; + private $sourceIp; + private $streamContextOptions = []; + + public function setTimeout(int $timeout): self + { + $this->timeout = $timeout; + + return $this; + } + + public function getTimeout(): int + { + return $this->timeout; + } + + /** + * Literal IPv6 addresses should be wrapped in square brackets. + */ + public function setHost(string $host): self + { + $this->host = $host; + + return $this; + } + + public function getHost(): string + { + return $this->host; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function getPort(): int + { + return $this->port; + } + + /** + * Sets the encryption type (tls or ssl). + */ + public function setEncryption(string $encryption): self + { + $encryption = strtolower($encryption); + if ('tls' === $encryption) { + $this->protocol = 'tcp'; + $this->tls = true; + } else { + $this->protocol = $encryption; + $this->tls = false; + } + + return $this; + } + + public function isTLS(): bool + { + return $this->tls; + } + + public function setStreamOptions(array $options): self + { + $this->streamContextOptions = $options; + + return $this; + } + + public function getStreamOptions(): array + { + return $this->streamContextOptions; + } + + /** + * Sets the source IP. + * + * IPv6 addresses should be wrapped in square brackets. + */ + public function setSourceIp(string $ip): self + { + $this->sourceIp = $ip; + + return $this; + } + + /** + * Returns the IP used to connect to the destination. + */ + public function getSourceIp(): ?string + { + return $this->sourceIp; + } + + public function initialize(): void + { + $this->url = $this->host.':'.$this->port; + if ($this->protocol) { + $this->url = $this->protocol.'://'.$this->url; + } + $options = []; + if ($this->sourceIp) { + $options['socket']['bindto'] = $this->sourceIp.':0'; + } + if ($this->streamContextOptions) { + $options = array_merge($options, $this->streamContextOptions); + } + $streamContext = stream_context_create($options); + $this->stream = @stream_socket_client($this->url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $streamContext); + if (false === $this->stream) { + throw new TransportException(sprintf('Connection could not be established with host "%s": %s (%s)', $this->url, $errstr, $errno)); + } + stream_set_blocking($this->stream, true); + stream_set_timeout($this->stream, $this->timeout); + $this->in = &$this->stream; + $this->out = &$this->stream; + } + + public function startTLS(): bool + { + return (bool) stream_socket_enable_crypto($this->stream, true); + } + + public function terminate(): void + { + if (null !== $this->stream) { + fclose($this->stream); + } + + parent::terminate(); + } + + protected function getReadConnectionDescription(): string + { + return $this->url; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/TransportInterface.php b/src/Symfony/Component/Mailer/Transport/TransportInterface.php new file mode 100644 index 000000000000..852db42be78e --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/TransportInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\SmtpEnvelope; +use Symfony\Component\Mime\RawMessage; + +/** + * Interface for all mailer transports. + * + * When sending emails, you should prefer MailerInterface implementations + * as they allow asynchronous sending. + * + * @author Fabien Potencier + * + * @experimental in 4.3 + */ +interface TransportInterface +{ + /** + * @throws TransportExceptionInterface + */ + public function send(RawMessage $message, SmtpEnvelope $envelope = null): ?SentMessage; +} diff --git a/src/Symfony/Component/Mailer/composer.json b/src/Symfony/Component/Mailer/composer.json new file mode 100644 index 000000000000..27a5db082a73 --- /dev/null +++ b/src/Symfony/Component/Mailer/composer.json @@ -0,0 +1,45 @@ +{ + "name": "symfony/mailer", + "type": "library", + "description": "Symfony Mailer Component", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.1.3", + "psr/log": "~1.0", + "symfony/event-dispatcher": "^4.3", + "symfony/mime": "^4.3" + }, + "require-dev": { + "symfony/amazon-mailer": "^4.3", + "egulias/email-validator": "^2.0", + "symfony/google-mailer": "^4.3", + "symfony/mailgun-mailer": "^4.3", + "symfony/mailchimp-mailer": "^4.3", + "symfony/postmark-mailer": "^4.3", + "symfony/sendgrid-mailer": "^4.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/Mailer/phpunit.xml.dist b/src/Symfony/Component/Mailer/phpunit.xml.dist new file mode 100644 index 000000000000..adcc4721d47a --- /dev/null +++ b/src/Symfony/Component/Mailer/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +