diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Mailtrap/CHANGELOG.md index 00149ea5ac6f..a831f59eeb2e 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailtrap/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/CHANGELOG.md @@ -1,7 +1,12 @@ CHANGELOG ========= +7.4 +--- + + * Add compatibility for Mailtrap's sandbox with new DSN scheme + 7.2 --- - * Add the bridge + * Add the bridge \ No newline at end of file diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/README.md b/src/Symfony/Component/Mailer/Bridge/Mailtrap/README.md index e55a0baf8b9b..07186eccd12e 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailtrap/README.md +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/README.md @@ -9,13 +9,17 @@ Configuration example: # SMTP MAILER_DSN=mailtrap+smtp://PASSWORD@default -# API +# API (Live) MAILER_DSN=mailtrap+api://TOKEN@default + +# API (Sandbox) +MAILER_DSN=mailtrap+sandbox://TOKEN@default?inboxId=INBOX_ID ``` where: - `PASSWORD` is your Mailtrap SMTP Password - `TOKEN` is your Mailtrap Server Token + - `INBOX_ID` is your Mailtrap sandbox inbox's ID Resources --------- diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapApiSandboxTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapApiSandboxTransportTest.php new file mode 100644 index 000000000000..5e8a1d761ab8 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapApiSandboxTransportTest.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailtrap\Tests\Transport; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapApiSandboxTransport; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Header\MetadataHeader; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class MailtrapApiSandboxTransportTest extends TestCase +{ + #[DataProvider('getTransportData')] + public function testToString(MailtrapApiSandboxTransport $transport, string $expected) + { + $this->assertSame($expected, (string) $transport); + } + + public static function getTransportData(): array + { + return [ + [ + (new MailtrapApiSandboxTransport('KEY'))->setInboxId(123456), + 'mailtrap+sandbox://sandbox.api.mailtrap.io/?inboxId=123456', + ], + [ + (new MailtrapApiSandboxTransport('KEY'))->setInboxId(123456)->setHost('example.com'), + 'mailtrap+sandbox://example.com/?inboxId=123456', + ], + [ + (new MailtrapApiSandboxTransport('KEY'))->setInboxId(123456)->setHost('example.com')->setPort(99), + 'mailtrap+sandbox://example.com:99/?inboxId=123456', + ], + [ + (new MailtrapApiSandboxTransport('KEY'))->setInboxId(123456), + 'mailtrap+sandbox://sandbox.api.mailtrap.io/?inboxId=123456', + ], + ]; + } + + public function testSend() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://sandbox.api.mailtrap.io/api/send/123456', $url); + + $body = json_decode($options['body'], true); + $this->assertSame(['email' => 'fabpot@symfony.com', 'name' => 'Fabien'], $body['from']); + $this->assertSame([['email' => 'kevin@symfony.com', 'name' => 'Kevin']], $body['to']); + $this->assertSame('Hello!', $body['subject']); + $this->assertSame('Hello There!', $body['text']); + + return new JsonMockResponse([], [ + 'http_code' => 200, + ]); + }); + + $transport = (new MailtrapApiSandboxTransport('KEY', $client))->setInboxId(123456); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('kevin@symfony.com', 'Kevin')) + ->from(new Address('fabpot@symfony.com', 'Fabien')) + ->text('Hello There!'); + + $transport->send($mail); + } + + public function testSendThrowsForErrorResponse() + { + $client = new MockHttpClient(static fn (string $method, string $url, array $options): ResponseInterface => new JsonMockResponse(['errors' => ['i\'m a teapot']], [ + 'http_code' => 418, + ])); + $transport = (new MailtrapApiSandboxTransport('KEY', $client))->setInboxId(123456); + $transport->setPort(8984); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('kevin@symfony.com', 'Kevin')) + ->from(new Address('fabpot@symfony.com', 'Fabien')) + ->text('Hello There!'); + + $this->expectException(HttpTransportException::class); + $this->expectExceptionMessage('Unable to send email: "i\'m a teapot" (status code 418).'); + $transport->send($mail); + } + + public function testTagAndMetadataHeaders() + { + $email = new Email(); + $email->getHeaders()->add(new TagHeader('password-reset')); + $email->getHeaders()->add(new MetadataHeader('Color', 'blue')); + $email->getHeaders()->add(new MetadataHeader('Client-ID', '12345')); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = (new MailtrapApiSandboxTransport('ACCESS_KEY'))->setInboxId(123456); + $method = new \ReflectionMethod(MailtrapApiSandboxTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayNotHasKey('Headers', $payload); + $this->assertArrayHasKey('category', $payload); + $this->assertArrayHasKey('custom_variables', $payload); + + $this->assertSame('password-reset', $payload['category']); + $this->assertSame(['Color' => 'blue', 'Client-ID' => '12345'], $payload['custom_variables']); + } + + public function testMultipleTagsAreNotAllowed() + { + $email = new Email(); + $email->getHeaders()->add(new TagHeader('tag1')); + $email->getHeaders()->add(new TagHeader('tag2')); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = (new MailtrapApiSandboxTransport('ACCESS_KEY'))->setInboxId(123456); + $method = new \ReflectionMethod(MailtrapApiSandboxTransport::class, 'getPayload'); + + $this->expectException(TransportException::class); + + $method->invoke($transport, $email, $envelope); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapTransportFactoryTest.php index 9b3c71d351e0..acfdc765a5b7 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Tests/Transport/MailtrapTransportFactoryTest.php @@ -13,6 +13,7 @@ use Psr\Log\NullLogger; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapApiSandboxTransport; use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapApiTransport; use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapSmtpTransport; use Symfony\Component\Mailer\Bridge\Mailtrap\Transport\MailtrapTransportFactory; @@ -37,6 +38,11 @@ public static function supportsProvider(): iterable true, ]; + yield [ + new Dsn('mailtrap+sandbox', 'default'), + true, + ]; + yield [ new Dsn('mailtrap', 'default'), true, @@ -72,6 +78,16 @@ public static function createProvider(): iterable (new MailtrapApiTransport(self::USER, new MockHttpClient(), null, $logger))->setHost('example.com')->setPort(8080), ]; + yield [ + new Dsn('mailtrap+sandbox', 'default', self::USER, null, null, ['inboxId' => '123456']), + (new MailtrapApiSandboxTransport(self::USER, new MockHttpClient(), null, $logger))->setInboxId(123456), + ]; + + yield [ + new Dsn('mailtrap+sandbox', 'example.com', self::USER, null, 8080, ['inboxId' => '123456']), + (new MailtrapApiSandboxTransport(self::USER, new MockHttpClient(), null, $logger))->setHost('example.com')->setPort(8080)->setInboxId(123456), + ]; + yield [ new Dsn('mailtrap', 'default', self::USER), new MailtrapSmtpTransport(self::USER, null, $logger), @@ -92,7 +108,7 @@ public static function unsupportedSchemeProvider(): iterable { yield [ new Dsn('mailtrap+foo', 'default', self::USER), - 'The "mailtrap+foo" scheme is not supported; supported schemes for mailer "mailtrap" are: "mailtrap", "mailtrap+api", "mailtrap+smtp", "mailtrap+smtps".', + 'The "mailtrap+foo" scheme is not supported; supported schemes for mailer "mailtrap" are: "mailtrap", "mailtrap+api", "mailtrap+sandbox", "mailtrap+smtp", "mailtrap+smtps".', ]; } diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiSandboxTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiSandboxTransport.php new file mode 100644 index 000000000000..a7e1b092ea4b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiSandboxTransport.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\Bridge\Mailtrap\Transport; + +/** + * @author Kieran Cross + */ +final class MailtrapApiSandboxTransport extends MailtrapApiTransport +{ + protected const HOST = 'sandbox.api.mailtrap.io'; + private ?int $inboxId = null; + + public function __toString(): string + { + return \sprintf('mailtrap+sandbox://%s', $this->getEndpoint().'/?inboxId='.$this->inboxId); + } + + /** + * @return $this + */ + public function setInboxId(?int $inboxId): static + { + $this->inboxId = $inboxId; + + return $this; + } + + protected function getEndpointPath(): string + { + return self::ENDPOINT_PATH.'/'.$this->inboxId; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiTransport.php index 09e7c2f24564..485076b23c46 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapApiTransport.php @@ -30,10 +30,11 @@ /** * @author Kevin Bond */ -final class MailtrapApiTransport extends AbstractApiTransport +class MailtrapApiTransport extends AbstractApiTransport { - private const HOST = 'send.api.mailtrap.io'; - private const HEADERS_TO_BYPASS = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'sender']; + protected const HOST = 'send.api.mailtrap.io'; + protected const ENDPOINT_PATH = '/api/send'; + protected const HEADERS_TO_BYPASS = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'sender']; public function __construct( #[\SensitiveParameter] private string $token, @@ -51,7 +52,7 @@ public function __toString(): string protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface { - $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/api/send', [ + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().$this->getEndpointPath(), [ 'json' => $this->getPayload($email, $envelope), 'auth_bearer' => $this->token, ]); @@ -72,7 +73,7 @@ protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $e return $response; } - private function getPayload(Email $email, Envelope $envelope): array + protected function getPayload(Email $email, Envelope $envelope): array { $payload = [ 'from' => self::encodeEmail($envelope->getSender()), @@ -112,7 +113,7 @@ private function getPayload(Email $email, Envelope $envelope): array return $payload; } - private function getAttachments(Email $email): array + protected function getAttachments(Email $email): array { $attachments = []; @@ -138,13 +139,23 @@ private function getAttachments(Email $email): array return $attachments; } - private static function encodeEmail(Address $address): array + protected static function encodeEmail(Address $address): array { return array_filter(['email' => $address->getEncodedAddress(), 'name' => $address->getName()]); } - private function getEndpoint(): ?string + protected function getHost(): string { - return ($this->host ?: self::HOST).($this->port ? ':'.$this->port : ''); + return static::HOST; + } + + protected function getEndpoint(): ?string + { + return ($this->host ?: $this->getHost()).($this->port ? ':'.$this->port : ''); + } + + protected function getEndpointPath(): string + { + return self::ENDPOINT_PATH; } } diff --git a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapTransportFactory.php index 0abdf901d070..04a1ec7b8736 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailtrap/Transport/MailtrapTransportFactory.php @@ -26,11 +26,17 @@ public function create(Dsn $dsn): TransportInterface $scheme = $dsn->getScheme(); $user = $this->getUser($dsn); - if ('mailtrap+api' === $scheme) { + if ('mailtrap+api' === $scheme || 'mailtrap+sandbox' === $scheme) { $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); - return (new MailtrapApiTransport($user, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port); + if ('mailtrap+api' === $scheme) { + return (new MailtrapApiTransport($user, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port); + } else { + $inboxId = $dsn->getOption('inboxId'); + + return (new MailtrapApiSandboxTransport($user, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port)->setInboxId($inboxId); + } } if ('mailtrap+smtp' === $scheme || 'mailtrap+smtps' === $scheme || 'mailtrap' === $scheme) { @@ -42,6 +48,6 @@ public function create(Dsn $dsn): TransportInterface protected function getSupportedSchemes(): array { - return ['mailtrap', 'mailtrap+api', 'mailtrap+smtp', 'mailtrap+smtps']; + return ['mailtrap', 'mailtrap+api', 'mailtrap+sandbox', 'mailtrap+smtp', 'mailtrap+smtps']; } }