├── CHANGELOG.md ├── README.md ├── composer.json ├── LICENSE ├── Transport ├── MailgunSmtpTransport.php ├── MailgunHeadersTrait.php ├── MailgunTransportFactory.php ├── MailgunHttpTransport.php └── MailgunApiTransport.php ├── Webhook └── MailgunRequestParser.php └── RemoteEvent └── MailgunPayloadConverter.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 6.1 5 | --- 6 | 7 | * Allow multiple `TagHeaders` with `MailgunApiTransport` 8 | 9 | 5.2 10 | --- 11 | 12 | * Not prefixing headers with "h:" is no more deprecated 13 | 14 | 5.1.0 15 | ----- 16 | 17 | * Not prefixing headers with "h:" is deprecated. 18 | 19 | 4.4.0 20 | ----- 21 | 22 | * [BC BREAK] Renamed and moved `Symfony\Component\Mailer\Bridge\Mailgun\Http\Api\MailgunTransport` 23 | to `Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunApiTransport`, `Symfony\Component\Mailer\Bridge\Mailgun\Http\MailgunTransport` 24 | to `Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunHttpTransport`, `Symfony\Component\Mailer\Bridge\Mailgun\Smtp\MailgunTransport` 25 | to `Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunSmtpTransport`. 26 | 27 | 4.3.0 28 | ----- 29 | 30 | * Added the bridge 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mailgun Mailer 2 | ============== 3 | 4 | Provides Mailgun integration for Symfony Mailer. 5 | 6 | Configuration example: 7 | 8 | ```env 9 | # SMTP 10 | MAILER_DSN=mailgun+smtp://USERNAME:PASSWORD@default?region=REGION 11 | 12 | # HTTP 13 | MAILER_DSN=mailgun+https://KEY:DOMAIN@default?region=REGION 14 | 15 | # API 16 | MAILER_DSN=mailgun+api://KEY:DOMAIN@default?region=REGION 17 | ``` 18 | 19 | where: 20 | - `KEY` is your Mailgun API key 21 | - `DOMAIN` is your Mailgun sending domain 22 | - `REGION` is Mailgun selected region (optional) 23 | 24 | Resources 25 | --------- 26 | 27 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 28 | * [Report issues](https://github.com/symfony/symfony/issues) and 29 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 30 | in the [main Symfony repository](https://github.com/symfony/symfony) 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/mailgun-mailer", 3 | "type": "symfony-mailer-bridge", 4 | "description": "Symfony Mailgun Mailer Bridge", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/mailer": "^7.2" 21 | }, 22 | "require-dev": { 23 | "symfony/http-client": "^6.4|^7.0", 24 | "symfony/webhook": "^6.4|^7.0" 25 | }, 26 | "conflict": { 27 | "symfony/http-foundation": "<6.4" 28 | }, 29 | "autoload": { 30 | "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Mailgun\\": "" }, 31 | "exclude-from-classmap": [ 32 | "/Tests/" 33 | ] 34 | }, 35 | "minimum-stability": "dev" 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Transport/MailgunSmtpTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 17 | 18 | /** 19 | * @author Kevin Verschaeve 20 | */ 21 | class MailgunSmtpTransport extends EsmtpTransport 22 | { 23 | use MailgunHeadersTrait; 24 | 25 | public function __construct(string $username, #[\SensitiveParameter] string $password, ?string $region = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) 26 | { 27 | parent::__construct('us' !== ($region ?: 'us') ? \sprintf('smtp.%s.mailgun.org', $region) : 'smtp.mailgun.org', 587, false, $dispatcher, $logger); 28 | 29 | $this->setUsername($username); 30 | $this->setPassword($password); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Transport/MailgunHeadersTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Transport; 13 | 14 | use Symfony\Component\Mailer\Envelope; 15 | use Symfony\Component\Mailer\Header\MetadataHeader; 16 | use Symfony\Component\Mailer\Header\TagHeader; 17 | use Symfony\Component\Mailer\SentMessage; 18 | use Symfony\Component\Mime\Message; 19 | use Symfony\Component\Mime\RawMessage; 20 | 21 | /** 22 | * @author Kevin Bond 23 | */ 24 | trait MailgunHeadersTrait 25 | { 26 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage 27 | { 28 | if ($message instanceof Message) { 29 | $this->addMailgunHeaders($message); 30 | } 31 | 32 | return parent::send($message, $envelope); 33 | } 34 | 35 | private function addMailgunHeaders(Message $message): void 36 | { 37 | $headers = $message->getHeaders(); 38 | $metadata = []; 39 | 40 | foreach ($headers->all() as $name => $header) { 41 | if ($header instanceof TagHeader) { 42 | $headers->addTextHeader('X-Mailgun-Tag', $header->getValue()); 43 | $headers->remove($name); 44 | } elseif ($header instanceof MetadataHeader) { 45 | $metadata[$header->getKey()] = $header->getValue(); 46 | $headers->remove($name); 47 | } 48 | } 49 | 50 | if ($metadata) { 51 | $headers->addTextHeader('X-Mailgun-Variables', json_encode($metadata)); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Transport/MailgunTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Transport; 13 | 14 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; 15 | use Symfony\Component\Mailer\Transport\AbstractTransportFactory; 16 | use Symfony\Component\Mailer\Transport\Dsn; 17 | use Symfony\Component\Mailer\Transport\TransportInterface; 18 | 19 | /** 20 | * @author Konstantin Myakshin 21 | */ 22 | final class MailgunTransportFactory extends AbstractTransportFactory 23 | { 24 | public function create(Dsn $dsn): TransportInterface 25 | { 26 | $scheme = $dsn->getScheme(); 27 | $user = $this->getUser($dsn); 28 | $password = $this->getPassword($dsn); 29 | $region = $dsn->getOption('region'); 30 | $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); 31 | $port = $dsn->getPort(); 32 | 33 | if ('mailgun+api' === $scheme) { 34 | return (new MailgunApiTransport($user, $password, $region, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port); 35 | } 36 | 37 | if ('mailgun+https' === $scheme || 'mailgun' === $scheme) { 38 | return (new MailgunHttpTransport($user, $password, $region, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port); 39 | } 40 | 41 | if ('mailgun+smtp' === $scheme || 'mailgun+smtps' === $scheme) { 42 | return new MailgunSmtpTransport($user, $password, $region, $this->dispatcher, $this->logger); 43 | } 44 | 45 | throw new UnsupportedSchemeException($dsn, 'mailgun', $this->getSupportedSchemes()); 46 | } 47 | 48 | protected function getSupportedSchemes(): array 49 | { 50 | return ['mailgun', 'mailgun+api', 'mailgun+https', 'mailgun+smtp', 'mailgun+smtps']; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Webhook/MailgunRequestParser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Webhook; 13 | 14 | use Symfony\Component\HttpFoundation\ChainRequestMatcher; 15 | use Symfony\Component\HttpFoundation\Request; 16 | use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; 17 | use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; 18 | use Symfony\Component\HttpFoundation\RequestMatcherInterface; 19 | use Symfony\Component\Mailer\Bridge\Mailgun\RemoteEvent\MailgunPayloadConverter; 20 | use Symfony\Component\Mailer\Exception\InvalidArgumentException; 21 | use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; 22 | use Symfony\Component\RemoteEvent\Exception\ParseException; 23 | use Symfony\Component\Webhook\Client\AbstractRequestParser; 24 | use Symfony\Component\Webhook\Exception\RejectWebhookException; 25 | 26 | final class MailgunRequestParser extends AbstractRequestParser 27 | { 28 | public function __construct( 29 | private readonly MailgunPayloadConverter $converter, 30 | ) { 31 | } 32 | 33 | protected function getRequestMatcher(): RequestMatcherInterface 34 | { 35 | return new ChainRequestMatcher([ 36 | new MethodRequestMatcher('POST'), 37 | new IsJsonRequestMatcher(), 38 | ]); 39 | } 40 | 41 | protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent 42 | { 43 | if (!$secret) { 44 | throw new InvalidArgumentException('A non-empty secret is required.'); 45 | } 46 | 47 | $content = $request->toArray(); 48 | if ( 49 | !isset($content['signature']['timestamp']) 50 | || !isset($content['signature']['token']) 51 | || !isset($content['signature']['signature']) 52 | || !isset($content['event-data']['event']) 53 | ) { 54 | throw new RejectWebhookException(406, 'Payload is malformed.'); 55 | } 56 | 57 | $this->validateSignature($content['signature'], $secret); 58 | 59 | try { 60 | return $this->converter->convert($content['event-data']); 61 | } catch (ParseException $e) { 62 | throw new RejectWebhookException(406, $e->getMessage(), $e); 63 | } 64 | } 65 | 66 | private function validateSignature(array $signature, #[\SensitiveParameter] string $secret): void 67 | { 68 | // see https://documentation.mailgun.com/en/latest/user_manual.html#webhooks-1 69 | if (!hash_equals($signature['signature'], hash_hmac('sha256', $signature['timestamp'].$signature['token'], $secret))) { 70 | throw new RejectWebhookException(406, 'Signature is wrong.'); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Transport/MailgunHttpTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Exception\HttpTransportException; 17 | use Symfony\Component\Mailer\SentMessage; 18 | use Symfony\Component\Mailer\Transport\AbstractHttpTransport; 19 | use Symfony\Component\Mime\Part\DataPart; 20 | use Symfony\Component\Mime\Part\Multipart\FormDataPart; 21 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 22 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 23 | use Symfony\Contracts\HttpClient\HttpClientInterface; 24 | use Symfony\Contracts\HttpClient\ResponseInterface; 25 | 26 | /** 27 | * @author Kevin Verschaeve 28 | */ 29 | class MailgunHttpTransport extends AbstractHttpTransport 30 | { 31 | use MailgunHeadersTrait; 32 | 33 | private const HOST = 'api.%region_dot%mailgun.net'; 34 | 35 | public function __construct( 36 | #[\SensitiveParameter] private string $key, 37 | private string $domain, 38 | private ?string $region = null, 39 | ?HttpClientInterface $client = null, 40 | ?EventDispatcherInterface $dispatcher = null, 41 | ?LoggerInterface $logger = null, 42 | ) { 43 | parent::__construct($client, $dispatcher, $logger); 44 | } 45 | 46 | public function __toString(): string 47 | { 48 | return \sprintf('mailgun+https://%s?domain=%s', $this->getEndpoint(), $this->domain); 49 | } 50 | 51 | protected function doSendHttp(SentMessage $message): ResponseInterface 52 | { 53 | $body = new FormDataPart([ 54 | 'to' => implode(',', $this->stringifyAddresses($message->getEnvelope()->getRecipients())), 55 | 'message' => new DataPart($message->toString(), 'message.mime'), 56 | ]); 57 | $headers = []; 58 | foreach ($body->getPreparedHeaders()->all() as $header) { 59 | $headers[] = $header->toString(); 60 | } 61 | 62 | $endpoint = \sprintf('%s/v3/%s/messages.mime', $this->getEndpoint(), urlencode($this->domain)); 63 | $response = $this->client->request('POST', 'https://'.$endpoint, [ 64 | 'http_version' => '1.1', 65 | 'auth_basic' => 'api:'.$this->key, 66 | 'headers' => $headers, 67 | 'body' => $body->bodyToIterable(), 68 | ]); 69 | 70 | try { 71 | $statusCode = $response->getStatusCode(); 72 | $result = $response->toArray(false); 73 | } catch (DecodingExceptionInterface) { 74 | throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).\sprintf(' (code %d).', $statusCode), $response); 75 | } catch (TransportExceptionInterface $e) { 76 | throw new HttpTransportException('Could not reach the remote Mailgun server.', $response, 0, $e); 77 | } 78 | 79 | if (200 !== $statusCode) { 80 | throw new HttpTransportException('Unable to send an email: '.$result['message'].\sprintf(' (code %d).', $statusCode), $response); 81 | } 82 | 83 | $message->setMessageId($result['id']); 84 | 85 | return $response; 86 | } 87 | 88 | private function getEndpoint(): ?string 89 | { 90 | $host = $this->host ?: str_replace('%region_dot%', 'us' !== ($this->region ?: 'us') ? $this->region.'.' : '', self::HOST); 91 | 92 | return $host.($this->port ? ':'.$this->port : ''); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /RemoteEvent/MailgunPayloadConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\RemoteEvent; 13 | 14 | use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; 15 | use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent; 16 | use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent; 17 | use Symfony\Component\RemoteEvent\Exception\ParseException; 18 | use Symfony\Component\RemoteEvent\PayloadConverterInterface; 19 | 20 | final class MailgunPayloadConverter implements PayloadConverterInterface 21 | { 22 | private const MAILGUN_SPECIFIC_DROPPED_CODES = [ 23 | 605, // Not delivering to previously bounced address 24 | 606, // Not delivering to unsubscribed address 25 | 607, // Not delivering to a user who marked your messages as spam 26 | 625, // Poor mailing list quality 27 | ]; 28 | 29 | public function convert(array $payload): AbstractMailerEvent 30 | { 31 | if (\in_array($payload['event'], ['accepted', 'rejected', 'delivered', 'failed', 'blocked'], true)) { 32 | $name = match ($payload['event']) { 33 | 'accepted' => MailerDeliveryEvent::RECEIVED, 34 | 'rejected' => MailerDeliveryEvent::DROPPED, 35 | 'delivered' => MailerDeliveryEvent::DELIVERED, 36 | 'blocked' => MailerDeliveryEvent::DROPPED, 37 | 'failed' => $this->matchFailedEvent($payload), 38 | }; 39 | 40 | $event = new MailerDeliveryEvent($name, $payload['id'], $payload); 41 | // reason is only available on failed messages 42 | $event->setReason($this->getReason($payload)); 43 | } else { 44 | $name = match ($payload['event']) { 45 | 'clicked' => MailerEngagementEvent::CLICK, 46 | 'unsubscribed' => MailerEngagementEvent::UNSUBSCRIBE, 47 | 'opened' => MailerEngagementEvent::OPEN, 48 | 'complained' => MailerEngagementEvent::SPAM, 49 | default => throw new ParseException(\sprintf('Unsupported event "%s".', $payload['event'])), 50 | }; 51 | $event = new MailerEngagementEvent($name, $payload['id'], $payload); 52 | } 53 | if (!$date = \DateTimeImmutable::createFromFormat('U.u', \sprintf('%.6F', $payload['timestamp']))) { 54 | throw new ParseException(\sprintf('Invalid date "%s".', \sprintf('%.6F', $payload['timestamp']))); 55 | } 56 | $event->setDate($date); 57 | $event->setRecipientEmail($payload['recipient']); 58 | $event->setMetadata($payload['user-variables'] ?? []); 59 | $event->setTags($payload['tags'] ?? []); 60 | 61 | return $event; 62 | } 63 | 64 | private function matchFailedEvent(array $payload): string 65 | { 66 | if ('temporary' === $payload['severity']) { 67 | return MailerDeliveryEvent::DEFERRED; 68 | } 69 | if (\in_array($payload['delivery-status']['code'], self::MAILGUN_SPECIFIC_DROPPED_CODES, true)) { 70 | return MailerDeliveryEvent::DROPPED; 71 | } 72 | 73 | return MailerDeliveryEvent::BOUNCE; 74 | } 75 | 76 | private function getReason(array $payload): string 77 | { 78 | if ('' !== ($payload['delivery-status']['description'] ?? '')) { 79 | return $payload['delivery-status']['description']; 80 | } 81 | if ('' !== ($payload['delivery-status']['message'] ?? '')) { 82 | return $payload['delivery-status']['message']; 83 | } 84 | if ('' !== ($payload['reason'] ?? '')) { 85 | return $payload['reason']; 86 | } 87 | 88 | return ''; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Transport/MailgunApiTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Mailer\Bridge\Mailgun\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Envelope; 17 | use Symfony\Component\Mailer\Exception\HttpTransportException; 18 | use Symfony\Component\Mailer\Header\MetadataHeader; 19 | use Symfony\Component\Mailer\Header\TagHeader; 20 | use Symfony\Component\Mailer\SentMessage; 21 | use Symfony\Component\Mailer\Transport\AbstractApiTransport; 22 | use Symfony\Component\Mime\Email; 23 | use Symfony\Component\Mime\Part\Multipart\FormDataPart; 24 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 25 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 26 | use Symfony\Contracts\HttpClient\HttpClientInterface; 27 | use Symfony\Contracts\HttpClient\ResponseInterface; 28 | 29 | /** 30 | * @author Kevin Verschaeve 31 | */ 32 | class MailgunApiTransport extends AbstractApiTransport 33 | { 34 | private const HOST = 'api.%region_dot%mailgun.net'; 35 | 36 | public function __construct( 37 | #[\SensitiveParameter] private string $key, 38 | private string $domain, 39 | private ?string $region = null, 40 | ?HttpClientInterface $client = null, 41 | ?EventDispatcherInterface $dispatcher = null, 42 | ?LoggerInterface $logger = null, 43 | ) { 44 | parent::__construct($client, $dispatcher, $logger); 45 | } 46 | 47 | public function __toString(): string 48 | { 49 | return \sprintf('mailgun+api://%s?domain=%s', $this->getEndpoint(), $this->domain); 50 | } 51 | 52 | protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface 53 | { 54 | $body = new FormDataPart($this->getPayload($email, $envelope)); 55 | $headers = []; 56 | foreach ($body->getPreparedHeaders()->all() as $header) { 57 | $headers[] = $header->toString(); 58 | } 59 | 60 | $endpoint = \sprintf('%s/v3/%s/messages', $this->getEndpoint(), urlencode($this->domain)); 61 | $response = $this->client->request('POST', 'https://'.$endpoint, [ 62 | 'http_version' => '1.1', 63 | 'auth_basic' => 'api:'.$this->key, 64 | 'headers' => $headers, 65 | 'body' => $body->bodyToIterable(), 66 | ]); 67 | 68 | try { 69 | $statusCode = $response->getStatusCode(); 70 | $result = $response->toArray(false); 71 | } catch (DecodingExceptionInterface) { 72 | throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).\sprintf(' (code %d).', $statusCode), $response); 73 | } catch (TransportExceptionInterface $e) { 74 | throw new HttpTransportException('Could not reach the remote Mailgun server.', $response, 0, $e); 75 | } 76 | 77 | if (200 !== $statusCode) { 78 | throw new HttpTransportException('Unable to send an email: '.$result['message'].\sprintf(' (code %d).', $statusCode), $response); 79 | } 80 | 81 | $sentMessage->setMessageId($result['id']); 82 | 83 | return $response; 84 | } 85 | 86 | private function getPayload(Email $email, Envelope $envelope): array 87 | { 88 | $headers = $email->getHeaders(); 89 | $headers->addMailboxHeader('h:Sender', $envelope->getSender()); 90 | $html = $email->getHtmlBody(); 91 | if (null !== $html && \is_resource($html)) { 92 | if (stream_get_meta_data($html)['seekable'] ?? false) { 93 | rewind($html); 94 | } 95 | $html = stream_get_contents($html); 96 | } 97 | [$attachments, $inlines, $html] = $this->prepareAttachments($email, $html); 98 | 99 | $payload = [ 100 | 'from' => $envelope->getSender()->toString(), 101 | 'to' => implode(',', $this->stringifyAddresses($this->getRecipients($email, $envelope))), 102 | 'subject' => $email->getSubject(), 103 | 'attachment' => $attachments, 104 | 'inline' => $inlines, 105 | ]; 106 | if ($emails = $email->getCc()) { 107 | $payload['cc'] = implode(',', $this->stringifyAddresses($emails)); 108 | } 109 | if ($emails = $email->getBcc()) { 110 | $payload['bcc'] = implode(',', $this->stringifyAddresses($emails)); 111 | } 112 | if ($email->getTextBody()) { 113 | $payload['text'] = $email->getTextBody(); 114 | } 115 | if ($html) { 116 | $payload['html'] = $html; 117 | } 118 | 119 | $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type']; 120 | foreach ($headers->all() as $name => $header) { 121 | if (\in_array($name, $headersToBypass, true)) { 122 | continue; 123 | } 124 | 125 | if ($header instanceof TagHeader) { 126 | $payload[] = ['o:tag' => $header->getValue()]; 127 | 128 | continue; 129 | } 130 | 131 | if ($header instanceof MetadataHeader) { 132 | $payload['v:'.$header->getKey()] = $header->getValue(); 133 | 134 | continue; 135 | } 136 | 137 | // Check if it is a valid prefix or header name according to Mailgun API 138 | $prefix = substr($name, 0, 2); 139 | if (\in_array($prefix, ['h:', 't:', 'o:', 'v:']) || \in_array($name, ['recipient-variables', 'template', 'amp-html'])) { 140 | $headerName = $header->getName(); 141 | } else { 142 | $headerName = 'h:'.$header->getName(); 143 | } 144 | 145 | $payload[$headerName] = $header->getBodyAsString(); 146 | } 147 | 148 | return $payload; 149 | } 150 | 151 | private function prepareAttachments(Email $email, ?string $html): array 152 | { 153 | $attachments = $inlines = []; 154 | foreach ($email->getAttachments() as $attachment) { 155 | $headers = $attachment->getPreparedHeaders(); 156 | if ('inline' === $headers->getHeaderBody('Content-Disposition')) { 157 | // replace the cid with just a file name (the only supported way by Mailgun) 158 | if ($html) { 159 | $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); 160 | $new = basename($filename); 161 | $html = str_replace('cid:'.$filename, 'cid:'.$new, $html); 162 | $p = new \ReflectionProperty($attachment, 'filename'); 163 | $p->setValue($attachment, $new); 164 | } 165 | $inlines[] = $attachment; 166 | } else { 167 | $attachments[] = $attachment; 168 | } 169 | } 170 | 171 | return [$attachments, $inlines, $html]; 172 | } 173 | 174 | private function getEndpoint(): ?string 175 | { 176 | $host = $this->host ?: str_replace('%region_dot%', 'us' !== ($this->region ?: 'us') ? $this->region.'.' : '', self::HOST); 177 | 178 | return $host.($this->port ? ':'.$this->port : ''); 179 | } 180 | } 181 | --------------------------------------------------------------------------------