diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 998f5606da771..2dd72a900b41b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2431,6 +2431,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co $classToServices = [ GmailTransportFactory::class => 'mailer.transport_factory.gmail', + InfobipTransportFactory::class => 'mailer.transport_factory.infobip', MailgunTransportFactory::class => 'mailer.transport_factory.mailgun', MailjetTransportFactory::class => 'mailer.transport_factory.mailjet', MandrillTransportFactory::class => 'mailer.transport_factory.mailchimp', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index 7bddfa7567cee..7e799bd1ee262 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -13,6 +13,7 @@ use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; use Symfony\Component\Mailer\Bridge\Mailchimp\Transport\MandrillTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; @@ -45,6 +46,10 @@ ->parent('mailer.transport_factory.abstract') ->tag('mailer.transport_factory') + ->set('mailer.transport_factory.infobip', InfobipTransportFactory::class) + ->parent('mailer.transport_factory.abstract') + ->tag('mailer.transport_factory') + ->set('mailer.transport_factory.mailchimp', MandrillTransportFactory::class) ->parent('mailer.transport_factory.abstract') ->tag('mailer.transport_factory') @@ -74,8 +79,8 @@ ->tag('mailer.transport_factory') ->set('mailer.transport_factory.sendinblue', SendinblueTransportFactory::class) - ->parent('mailer.transport_factory.abstract') - ->tag('mailer.transport_factory') + ->parent('mailer.transport_factory.abstract') + ->tag('mailer.transport_factory') ->set('mailer.transport_factory.ohmysmtp', OhMySmtpTransportFactory::class) ->parent('mailer.transport_factory.abstract') diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/.gitattributes b/src/Symfony/Component/Mailer/Bridge/Infobip/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/.gitignore b/src/Symfony/Component/Mailer/Bridge/Infobip/.gitignore new file mode 100644 index 0000000000000..4fbb073c49aee --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/composer.lock diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Infobip/CHANGELOG.md new file mode 100644 index 0000000000000..7174cd7fdeedd --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.2 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/LICENSE b/src/Symfony/Component/Mailer/Bridge/Infobip/LICENSE new file mode 100644 index 0000000000000..9c907a46a6218 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019-2022 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/Infobip/README.md b/src/Symfony/Component/Mailer/Bridge/Infobip/README.md new file mode 100644 index 0000000000000..c86458c3991f9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/README.md @@ -0,0 +1,21 @@ +Infobip Bridge +============== + +Provides Infobip integration for Symfony Mailer. + +Configuration examples: + +```dotenv +# API +MAILER_DSN=infobip+api://KEY@BASE_URL +# SMTP +MAILER_DSN=infobip+smtp://KEY@default +``` + +Resources +--------- +* [Infobip Api Docs](https://www.infobip.com/docs/api#channels/email) +* [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/Infobip/Tests/Transport/InfobipApiTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Infobip/Tests/Transport/InfobipApiTransportFactoryTest.php new file mode 100644 index 0000000000000..a6df9eab3c119 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/Tests/Transport/InfobipApiTransportFactoryTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Infobip\Tests\Transport; + +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipApiTransport; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipSmtpTransport; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; +use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class InfobipApiTransportFactoryTest extends TransportFactoryTestCase +{ + public function getFactory(): TransportFactoryInterface + { + return new InfobipTransportFactory($this->getDispatcher(), $this->getClient(), $this->getLogger()); + } + + public function supportsProvider(): iterable + { + yield [ + new Dsn('infobip+api', 'default'), + true, + ]; + + yield [ + new Dsn('infobip', 'default'), + true, + ]; + + yield [ + new Dsn('infobip+smtp', 'default'), + true, + ]; + + yield [ + new Dsn('infobip+smtps', 'default'), + true, + ]; + + yield [ + new Dsn('infobip+smtp', 'example.com'), + true, + ]; + } + + public function createProvider(): iterable + { + $dispatcher = $this->getDispatcher(); + $logger = $this->getLogger(); + + yield [ + new Dsn('infobip+api', 'example.com', self::PASSWORD), + (new InfobipApiTransport(self::PASSWORD, $this->getClient(), $dispatcher, $logger))->setHost('example.com'), + ]; + + yield [ + new Dsn('infobip', 'default', self::PASSWORD), + new InfobipSmtpTransport(self::PASSWORD, $dispatcher, $logger), + ]; + + yield [ + new Dsn('infobip+smtp', 'default', self::PASSWORD), + new InfobipSmtpTransport(self::PASSWORD, $dispatcher, $logger), + ]; + + yield [ + new Dsn('infobip+smtps', 'default', self::PASSWORD), + new InfobipSmtpTransport(self::PASSWORD, $dispatcher, $logger), + ]; + } + + public function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('infobip+foo', 'infobip', self::USER, self::PASSWORD), + 'The "infobip+foo" scheme is not supported; supported schemes for mailer "infobip" are: "infobip", "infobip+api", "infobip+smtp", "infobip+smtps".', + ]; + } + + public function incompleteDsnProvider(): iterable + { + yield [new Dsn('infobip+smtp', 'default')]; + yield [new Dsn('infobip+api', 'default')]; + yield [new Dsn('infobip+api', 'default', self::PASSWORD)]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/Tests/Transport/InfobipApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Infobip/Tests/Transport/InfobipApiTransportTest.php new file mode 100644 index 0000000000000..7e01b673ebafe --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/Tests/Transport/InfobipApiTransportTest.php @@ -0,0 +1,409 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Infobip\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipApiTransport; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class InfobipApiTransportTest extends TestCase +{ + protected const KEY = 'k3y'; + + private MockResponse $response; + private MockHttpClient $httpClient; + private InfobipApiTransport $transport; + + protected function setUp(): void + { + $this->response = new MockResponse('{}'); + $this->httpClient = new class(fn () => $this->response) extends MockHttpClient { + public function request(string $method, string $url, array $options = []): ResponseInterface + { + // The only purpose of this method override is to record the request body as a string + // It's impossible to get the generated body when using a generator as a body + if (isset($options['body']) && $options['body'] instanceof \Generator) { + $body = ''; + foreach ($options['body'] as $data) { + $body .= $data; + } + $options['body'] = $body; + } + + return parent::request($method, $url, $options); + } + }; + $this->transport = new InfobipApiTransport(self::KEY, $this->httpClient); + $this->transport->setHost('99999.api.infobip.com'); + } + + protected function tearDown(): void + { + unset($this->response, $this->httpClient, $this->transport); + } + + public function testToString() + { + $this->assertSame('infobip+api://99999.api.infobip.com', (string) $this->transport); + } + + public function testInfobipShouldBeCalledWithTheRightMethodAndUrlAndHeaders() + { + $email = $this->basicValidEmail(); + + $this->transport->send($email); + + $this->assertSame('POST', $this->response->getRequestMethod()); + $this->assertSame('https://99999.api.infobip.com/email/2/send', $this->response->getRequestUrl()); + $options = $this->response->getRequestOptions(); + $this->arrayHasKey('headers'); + $this->assertCount(4, $options['headers']); + $this->assertStringMatchesFormat('Content-Type: multipart/form-data; boundary=%s', $options['headers'][0]); + $this->assertSame('Authorization: App k3y', $options['headers'][1]); + $this->assertSame('Accept: application/json', $options['headers'][2]); + $this->assertStringMatchesFormat('Content-Length: %d', $options['headers'][3]); + } + + public function testSendMinimalEmailShouldCalledInfobipWithTheRightParameters() + { + $email = (new Email()) + ->subject('Subject of the email') + ->from('from@example.com') + ->to('to@example.com') + ->text('Some text') + ; + + $this->transport->send($email); + + $options = $this->response->getRequestOptions(); + $this->arrayHasKey('body'); + $this->assertStringMatchesFormat(<<<'TXT' + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="from" + + from@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="subject" + + Subject of the email + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="to" + + to@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="text" + + Some text + --%s-- + TXT, + $options['body'] + ); + } + + public function testSendFullEmailShouldCalledInfobipWithTheRightParameters() + { + $email = (new Email()) + ->subject('Subject of the email') + ->from('From ') + ->to('to1@example.com', 'to2@example.com') + ->text('Some text') + ->html('

Hello!

') + ->bcc('bcc@example.com') + ->cc('cc@example.com') + ->date(new \DateTime('2022-04-28 14:00.00', new \DateTimeZone('UTC'))) + ->replyTo('replyTo@example.com') + ; + + $this->transport->send($email); + + $options = $this->response->getRequestOptions(); + $this->arrayHasKey('body'); + $this->assertStringMatchesFormat(<<<'TXT' + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="from" + + "From" + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="subject" + + Subject of the email + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="to" + + to1@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="to" + + to2@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="cc" + + cc@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="bcc" + + bcc@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="replyto" + + replyTo@example.com + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="text" + + Some text + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="HTML" + +

Hello!

+ --%s-- + TXT, + $options['body'] + ); + } + + public function testSendEmailWithAttachmentsShouldCalledInfobipWithTheRightParameters() + { + $email = $this->basicValidEmail() + ->text('foobar') + ->attach('some attachment', 'attachment.txt', 'text/plain') + ->embed('some inline attachment', 'inline.txt', 'text/plain') + ; + + $this->transport->send($email); + + $options = $this->response->getRequestOptions(); + $this->arrayHasKey('body'); + $this->assertStringMatchesFormat(<<<'TXT' + %a + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="text" + + foobar + --%s + Content-Type: text/plain + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="attachment"; filename="attachment.txt" + + some attachment + --%s + Content-Type: text/plain + Content-Transfer-Encoding: 8bit + Content-Disposition: form-data; name="inlineImage"; filename="inline.txt" + + some inline attachment + --%s-- + TXT, + $options['body'] + ); + } + + public function testSendMinimalEmailWithSuccess() + { + $email = (new Email()) + ->subject('Subject of the email') + ->from('from@example.com') + ->to('to@example.com') + ->text('Some text') + ; + + $sentMessage = $this->transport->send($email); + + $this->assertInstanceOf(SentMessage::class, $sentMessage); + $this->assertStringMatchesFormat( + <<<'TXT' + Subject: Subject of the email + From: from@example.com + To: to@example.com + Message-ID: <%x@example.com> + MIME-Version: %f + Date: %s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Some text + TXT, + $sentMessage->toString() + ); + } + + public function testSendFullEmailWithSuccess() + { + $email = (new Email()) + ->subject('Subject of the email') + ->from('From ') + ->to('to1@example.com', 'to2@example.com') + ->text('Some text') + ->html('

Hello!

') + ->bcc('bcc@example.com') + ->cc('cc@example.com') + ->date(new \DateTime('2022-04-28 14:00.00', new \DateTimeZone('UTC'))) + ->replyTo('replyTo@example.com') + ; + + $sentMessage = $this->transport->send($email); + + $this->assertInstanceOf(SentMessage::class, $sentMessage); + $this->assertStringMatchesFormat( + <<<'TXT' + Subject: Subject of the email + From: From + To: to1@example.com, to2@example.com + Cc: cc@example.com + Date: Thu, 28 Apr 2022 14:00:00 +0000 + Reply-To: replyTo@example.com + Message-ID: <%x@example.com> + MIME-Version: 1.0 + Content-Type: multipart/alternative; boundary=%s + + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Some text + --%s + Content-Type: text/html; charset=utf-8 + Content-Transfer-Encoding: quoted-printable + +

Hello!

+ --%s-- + TXT, + $sentMessage->toString() + ); + $this->assertInstanceOf(Email::class, $sentMessage->getOriginalMessage()); + $this->assertEquals([new Address('bcc@example.com')], $sentMessage->getOriginalMessage()->getBcc()); + } + + public function testSendEmailWithAttachmentsWithSuccess() + { + $email = $this->basicValidEmail() + ->text('foobar') + ->attach('some attachment', 'attachment.txt', 'text/plain') + ->embed('some inline attachment', 'inline.txt', 'text/plain') + ; + + $sentMessage = $this->transport->send($email); + + $this->assertInstanceOf(SentMessage::class, $sentMessage); + $this->assertStringMatchesFormat( + <<<'TXT' + %a + Content-Type: multipart/mixed; boundary=%s + + --%s + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + foobar + --%s + Content-Type: text/plain; name=attachment.txt + Content-Transfer-Encoding: base64 + Content-Disposition: attachment; name=attachment.txt; + filename=attachment.txt + + c29tZSBhdHRhY2htZW50 + --%s + Content-Type: text/plain; name=inline.txt + Content-Transfer-Encoding: base64 + Content-Disposition: inline; name=inline.txt; filename=inline.txt + + c29tZSBpbmxpbmUgYXR0YWNobWVudA== + --%s-- + TXT, + $sentMessage->toString() + ); + } + + public function testSentMessageShouldCaptureInfobipMessageId() + { + $this->response = new MockResponse('{"messages": [{"messageId": "somexternalMessageId0"}]}'); + $email = $this->basicValidEmail(); + + $sentMessage = $this->transport->send($email); + + $this->assertSame('somexternalMessageId0', $sentMessage->getMessageId()); + } + + public function testInfobipResponseShouldNotBeEmpty() + { + $this->response = new MockResponse(); + $email = $this->basicValidEmail(); + + $this->expectException(HttpTransportException::class); + $this->expectDeprecationMessage('Unable to send an email: ""'); + + $this->transport->send($email); + } + + public function testInfobipResponseShouldBeStatusCode200() + { + $this->response = new MockResponse('{"requestError": {"serviceException": {"messageId": "string","text": "string"}}}', ['http_code' => 400]); + $email = $this->basicValidEmail(); + + $this->expectException(HttpTransportException::class); + $this->expectDeprecationMessage('Unable to send an email: "{"requestError": {"serviceException": {"messageId": "string","text": "string"}}}" (code 400)'); + + $this->transport->send($email); + } + + public function testInfobipHttpConnectionFailed() + { + $this->response = new MockResponse('', ['error' => 'Test error']); + $email = $this->basicValidEmail(); + + $this->expectException(HttpTransportException::class); + $this->expectDeprecationMessage('Could not reach the remote Infobip server.'); + $this->transport->send($email); + } + + private function basicValidEmail(): Email + { + return (new Email()) + ->subject('Email sent') + ->from('foo@example.com') + ->to('bar@example.com') + ->text('foobar'); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipApiTransport.php new file mode 100644 index 0000000000000..51cf0841362f4 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipApiTransport.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Infobip\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @see https://www.infobip.com/docs/api#channels/email/send-email + */ +final class InfobipApiTransport extends AbstractApiTransport +{ + private const API_VERSION = '2'; + + private string $key; + + public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + $this->key = $key; + + parent::__construct($client, $dispatcher, $logger); + } + + public function __toString(): string + { + return sprintf('infobip+api://%s', $this->getEndpoint()); + } + + protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface + { + $formData = $this->formDataPart($email, $envelope); + + $headers = $formData->getPreparedHeaders()->toArray(); + $headers[] = 'Authorization: App '.$this->key; + $headers[] = 'Accept: application/json'; + + $response = $this->client->request( + 'POST', + sprintf('https://%s/email/%s/send', $this->getEndpoint(), self::API_VERSION), + [ + 'headers' => $headers, + 'body' => $formData->bodyToIterable(), + ] + ); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new HttpTransportException('Could not reach the remote Infobip server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + throw new HttpTransportException(sprintf('Unable to send an email: "%s" (code %d).', $response->getContent(false), $statusCode), $response); + } + + try { + $result = $response->toArray(); + } catch (DecodingExceptionInterface $e) { + throw new HttpTransportException(sprintf('Unable to send an email: "%s" (code %d).', $response->getContent(false), $statusCode), $response, 0, $e); + } + + if (isset($result['messages'][0]['messageId'])) { + $sentMessage->setMessageId($result['messages'][0]['messageId']); + } + + return $response; + } + + private function getEndpoint(): ?string + { + return $this->host.($this->port ? ':'.$this->port : ''); + } + + private function formDataPart(Email $email, Envelope $envelope): FormDataPart + { + $fields = [ + 'from' => $envelope->getSender()->toString(), + 'subject' => $email->getSubject(), + ]; + + $this->addressesFormData($fields, 'to', $this->getRecipients($email, $envelope)); + + if ($email->getCc()) { + $this->addressesFormData($fields, 'cc', $email->getCc()); + } + + if ($email->getBcc()) { + $this->addressesFormData($fields, 'bcc', $email->getBcc()); + } + + if ($email->getReplyTo()) { + $this->addressesFormData($fields, 'replyto', $email->getReplyTo()); + } + + if ($email->getTextBody()) { + $fields['text'] = $email->getTextBody(); + } + + if ($email->getHtmlBody()) { + $fields['HTML'] = $email->getHtmlBody(); + } + + $this->attachmentsFormData($fields, $email); + + return new FormDataPart($fields); + } + + private function attachmentsFormData(array &$message, Email $email): void + { + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + + $dataPart = new DataPart($attachment->getBody(), $filename, $attachment->getMediaType().'/'.$attachment->getMediaSubtype()); + + if ('inline' === $headers->getHeaderBody('Content-Disposition')) { + $message[] = ['inlineImage' => $dataPart]; + } else { + $message[] = ['attachment' => $dataPart]; + } + } + } + + /** + * @param Address[] $addresses + */ + private function addressesFormData(array &$message, string $property, array $addresses): void + { + foreach ($addresses as $address) { + $message[] = [$property => $address->toString()]; + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipSmtpTransport.php b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipSmtpTransport.php new file mode 100644 index 0000000000000..7925e0965668e --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipSmtpTransport.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Infobip\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +final class InfobipSmtpTransport extends EsmtpTransport +{ + public function __construct(string $key, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp-api.infobip.com', 587, false, $dispatcher, $logger); + + $this->setUsername('App'); + $this->setPassword($key); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipTransportFactory.php new file mode 100644 index 0000000000000..e07258fcb0c1a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/Transport/InfobipTransportFactory.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\Bridge\Infobip\Transport; + +use Symfony\Component\Mailer\Exception\IncompleteDsnException; +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; + +final class InfobipTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $schema = $dsn->getScheme(); + $apiKey = $this->getUser($dsn); + + if ('infobip+api' === $schema) { + $host = $dsn->getHost(); + if ('default' === $host) { + throw new IncompleteDsnException('Infobip mailer for API DSN must contain a host.'); + } + + return (new InfobipApiTransport($apiKey, $this->client, $this->dispatcher, $this->logger)) + ->setHost($host) + ; + } + + if (\in_array($schema, ['infobip+smtp', 'infobip+smtps', 'infobip'], true)) { + return new InfobipSmtpTransport($apiKey, $this->dispatcher, $this->logger); + } + + throw new UnsupportedSchemeException($dsn, 'infobip', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['infobip', 'infobip+api', 'infobip+smtp', 'infobip+smtps']; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/composer.json b/src/Symfony/Component/Mailer/Bridge/Infobip/composer.json new file mode 100644 index 0000000000000..2fad8a9ddf3e1 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/composer.json @@ -0,0 +1,38 @@ +{ + "name": "symfony/infobip-mailer", + "description": "Symfony Infobip Mailer Bridge", + "type": "symfony-mailer-bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Jean-Baptiste Delhommeau", + "email": "jeanbadel@gmail.com" + }, + { + "name": "Benoit Galati", + "email": "benoit.galati@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/mailer": "^6.1" + }, + "require-dev": { + "symfony/http-client": "^6.1" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\Bridge\\Infobip\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Mailer/Bridge/Infobip/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Infobip/phpunit.xml.dist new file mode 100644 index 0000000000000..c149a080d9427 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Infobip/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php index e47a129dc7e90..1a86a0701d990 100644 --- a/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php @@ -24,6 +24,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Google\Transport\GmailTransportFactory::class, 'package' => 'symfony/google-mailer', ], + 'infobip' => [ + 'class' => Bridge\Infobip\Transport\InfobipTransportFactory::class, + 'package' => 'symfony/infobip-mailer', + ], 'mailgun' => [ 'class' => Bridge\Mailgun\Transport\MailgunTransportFactory::class, 'package' => 'symfony/mailgun-mailer', diff --git a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php index 54ce6dbe5a38c..56226dcd6864d 100644 --- a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -15,6 +15,7 @@ use Symfony\Bridge\PhpUnit\ClassExistsMock; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; use Symfony\Component\Mailer\Bridge\Mailchimp\Transport\MandrillTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; @@ -35,6 +36,7 @@ public static function setUpBeforeClass(): void ClassExistsMock::register(__CLASS__); ClassExistsMock::withMockedClasses([ GmailTransportFactory::class => false, + InfobipTransportFactory::class => false, MailgunTransportFactory::class => false, MailjetTransportFactory::class => false, MandrillTransportFactory::class => false, @@ -62,6 +64,7 @@ public function testMessageWhereSchemeIsPartOfSchemeToPackageMap(string $scheme, public function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \Generator { yield ['gmail', 'symfony/google-mailer']; + yield ['infobip', 'symfony/infobip-mailer']; yield ['mailgun', 'symfony/mailgun-mailer']; yield ['mailjet', 'symfony/mailjet-mailer']; yield ['mandrill', 'symfony/mailchimp-mailer']; diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index ee65681cede1c..f1d7c1d77798f 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -15,6 +15,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory; +use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; use Symfony\Component\Mailer\Bridge\Mailchimp\Transport\MandrillTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; @@ -44,6 +45,7 @@ final class Transport { private const FACTORY_CLASSES = [ GmailTransportFactory::class, + InfobipTransportFactory::class, MailgunTransportFactory::class, MailjetTransportFactory::class, MandrillTransportFactory::class,