├── CHANGELOG.md ├── LICENSE ├── README.md ├── RemoteEvent └── MailjetPayloadConverter.php ├── Transport ├── MailjetApiTransport.php ├── MailjetSmtpTransport.php └── MailjetTransportFactory.php ├── Webhook └── MailjetRequestParser.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 6.4 5 | --- 6 | 7 | * Add `RemoteEvent` and `Webhook` support 8 | 9 | 6.3 10 | --- 11 | 12 | * Add sandbox option 13 | 14 | 5.2.0 15 | ----- 16 | 17 | * Added the bridge 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mailjet Bridge 2 | ============== 3 | 4 | Provides Mailjet integration for Symfony Mailer. 5 | 6 | Configuration example: 7 | 8 | ```env 9 | # SMTP 10 | MAILER_DSN=mailjet+smtp://ACCESS_KEY:SECRET_KEY@default 11 | 12 | # API 13 | MAILER_DSN=mailjet+api://ACCESS_KEY:SECRET_KEY@default 14 | MAILER_DSN=mailjet+api://ACCESS_KEY:SECRET_KEY@default?sandbox=true 15 | ``` 16 | 17 | where: 18 | - `ACCESS_KEY` is your Mailjet access key 19 | - `SECRET_KEY` is your Mailjet secret key 20 | 21 | Webhook 22 | ------- 23 | 24 | When you [setup your webhook URL](https://app.mailjet.com/account/triggers) on Mailjet you must not group events by unchecking the checkboxes. 25 | 26 | Resources 27 | --------- 28 | 29 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 30 | * [Report issues](https://github.com/symfony/symfony/issues) and 31 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 32 | in the [main Symfony repository](https://github.com/symfony/symfony) 33 | -------------------------------------------------------------------------------- /RemoteEvent/MailjetPayloadConverter.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\Mailjet\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 MailjetPayloadConverter implements PayloadConverterInterface 21 | { 22 | public function convert(array $payload): AbstractMailerEvent 23 | { 24 | if (\in_array($payload['event'], ['bounce', 'sent', 'blocked'], true)) { 25 | $name = match ($payload['event']) { 26 | 'bounce' => MailerDeliveryEvent::BOUNCE, 27 | 'sent' => MailerDeliveryEvent::DELIVERED, 28 | 'blocked' => MailerDeliveryEvent::DROPPED, 29 | }; 30 | 31 | $event = new MailerDeliveryEvent($name, $payload['MessageID'], $payload); 32 | $event->setReason($this->getReason($payload)); 33 | } else { 34 | $name = match ($payload['event']) { 35 | 'click' => MailerEngagementEvent::CLICK, 36 | 'open' => MailerEngagementEvent::OPEN, 37 | 'spam' => MailerEngagementEvent::SPAM, 38 | 'unsub' => MailerEngagementEvent::UNSUBSCRIBE, 39 | default => throw new ParseException(\sprintf('Unsupported event "%s".', $payload['event'])), 40 | }; 41 | $event = new MailerEngagementEvent($name, $payload['MessageID'], $payload); 42 | } 43 | 44 | if (!$date = \DateTimeImmutable::createFromFormat('U', $payload['time'])) { 45 | throw new ParseException(\sprintf('Invalid date "%s".', $payload['time'])); 46 | } 47 | 48 | $event->setDate($date); 49 | $event->setRecipientEmail($payload['email']); 50 | 51 | if (isset($payload['CustomID'])) { 52 | $event->setTags([$payload['CustomID']]); 53 | } 54 | 55 | if (isset($payload['Payload'])) { 56 | $event->setMetadata(['Payload' => $payload['Payload']]); 57 | } 58 | 59 | return $event; 60 | } 61 | 62 | private function getReason(array $payload): string 63 | { 64 | return $payload['smtp_reply'] ?? $payload['error_related_to'] ?? ''; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Transport/MailjetApiTransport.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\Mailjet\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\Exception\TransportException; 19 | use Symfony\Component\Mailer\SentMessage; 20 | use Symfony\Component\Mailer\Transport\AbstractApiTransport; 21 | use Symfony\Component\Mime\Address; 22 | use Symfony\Component\Mime\Email; 23 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 24 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 25 | use Symfony\Contracts\HttpClient\HttpClientInterface; 26 | use Symfony\Contracts\HttpClient\ResponseInterface; 27 | 28 | class MailjetApiTransport extends AbstractApiTransport 29 | { 30 | private const HOST = 'api.mailjet.com'; 31 | private const API_VERSION = '3.1'; 32 | private const FORBIDDEN_HEADERS = [ 33 | 'Date', 'X-CSA-Complaints', 'Message-Id', 'X-MJ-StatisticsContactsListID', 34 | 'DomainKey-Status', 'Received-SPF', 'Authentication-Results', 'Received', 35 | 'From', 'Sender', 'Subject', 'To', 'Cc', 'Bcc', 'Reply-To', 'Return-Path', 'Delivered-To', 'DKIM-Signature', 36 | 'X-Feedback-Id', 'X-Mailjet-Segmentation', 'List-Id', 'X-MJ-MID', 'X-MJ-ErrorMessage', 37 | 'X-Mailjet-Debug', 'User-Agent', 'X-Mailer', 'X-MJ-WorkflowID', 38 | ]; 39 | private const HEADER_TO_MESSAGE = [ 40 | 'X-MJ-TemplateLanguage' => ['TemplateLanguage', 'bool'], 41 | 'X-MJ-TemplateID' => ['TemplateID', 'int'], 42 | 'X-MJ-TemplateErrorReporting' => ['TemplateErrorReporting', 'json'], 43 | 'X-MJ-TemplateErrorDeliver' => ['TemplateErrorDeliver', 'bool'], 44 | 'X-MJ-Vars' => ['Variables', 'json'], 45 | 'X-MJ-CustomID' => ['CustomID', 'string'], 46 | 'X-MJ-EventPayload' => ['EventPayload', 'string'], 47 | 'X-Mailjet-Campaign' => ['CustomCampaign', 'string'], 48 | 'X-Mailjet-DeduplicateCampaign' => ['DeduplicateCampaign', 'bool'], 49 | 'X-Mailjet-Prio' => ['Priority', 'int'], 50 | 'X-Mailjet-TrackClick' => ['TrackClicks', 'string'], 51 | 'X-Mailjet-TrackOpen' => ['TrackOpens', 'string'], 52 | ]; 53 | 54 | public function __construct( 55 | private string $publicKey, 56 | #[\SensitiveParameter] private string $privateKey, 57 | ?HttpClientInterface $client = null, 58 | ?EventDispatcherInterface $dispatcher = null, 59 | ?LoggerInterface $logger = null, 60 | private bool $sandbox = false, 61 | ) { 62 | parent::__construct($client, $dispatcher, $logger); 63 | } 64 | 65 | public function __toString(): string 66 | { 67 | return \sprintf('mailjet+api://%s', $this->getEndpoint().($this->sandbox ? '?sandbox=true' : '')); 68 | } 69 | 70 | protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface 71 | { 72 | $response = $this->client->request('POST', \sprintf('https://%s/v%s/send', $this->getEndpoint(), self::API_VERSION), [ 73 | 'headers' => [ 74 | 'Accept' => 'application/json', 75 | ], 76 | 'auth_basic' => $this->publicKey.':'.$this->privateKey, 77 | 'json' => $this->getPayload($email, $envelope), 78 | ]); 79 | 80 | try { 81 | $statusCode = $response->getStatusCode(); 82 | $result = $response->toArray(false); 83 | } catch (DecodingExceptionInterface) { 84 | throw new HttpTransportException(\sprintf('Unable to send an email: "%s" (code %d).', $response->getContent(false), $statusCode), $response); 85 | } catch (TransportExceptionInterface $e) { 86 | throw new HttpTransportException('Could not reach the remote Mailjet server.', $response, 0, $e); 87 | } 88 | 89 | if (200 !== $statusCode) { 90 | $errorDetails = $result['Messages'][0]['Errors'][0]['ErrorMessage'] ?? $response->getContent(false); 91 | 92 | throw new HttpTransportException(\sprintf('Unable to send an email: "%s" (code %d).', $errorDetails, $statusCode), $response); 93 | } 94 | 95 | // The response needs to contains a 'Messages' key that is an array 96 | if (!\array_key_exists('Messages', $result) || !\is_array($result['Messages']) || 0 === \count($result['Messages'])) { 97 | throw new HttpTransportException(\sprintf('Unable to send an email: "%s" malformed api response.', $response->getContent(false)), $response); 98 | } 99 | 100 | $sentMessage->setMessageId($result['Messages'][0]['To'][0]['MessageID'] ?? ''); 101 | 102 | return $response; 103 | } 104 | 105 | private function getPayload(Email $email, Envelope $envelope): array 106 | { 107 | $html = $email->getHtmlBody(); 108 | if (null !== $html && \is_resource($html)) { 109 | if (stream_get_meta_data($html)['seekable'] ?? false) { 110 | rewind($html); 111 | } 112 | $html = stream_get_contents($html); 113 | } 114 | [$attachments, $inlines, $html] = $this->prepareAttachments($email, $html); 115 | 116 | $message = [ 117 | 'From' => $this->formatAddress($envelope->getSender()), 118 | 'To' => $this->formatAddresses($this->getRecipients($email, $envelope)), 119 | 'Subject' => $email->getSubject(), 120 | 'Attachments' => $attachments, 121 | 'InlinedAttachments' => $inlines, 122 | ]; 123 | if ($emails = $email->getCc()) { 124 | $message['Cc'] = $this->formatAddresses($emails); 125 | } 126 | if ($emails = $email->getBcc()) { 127 | $message['Bcc'] = $this->formatAddresses($emails); 128 | } 129 | if ($emails = $email->getReplyTo()) { 130 | if (1 < $length = \count($emails)) { 131 | throw new TransportException(\sprintf('Mailjet\'s API only supports one Reply-To email, %d given.', $length)); 132 | } 133 | $message['ReplyTo'] = $this->formatAddress($emails[0]); 134 | } 135 | if ($email->getTextBody()) { 136 | $message['TextPart'] = $email->getTextBody(); 137 | } 138 | if ($html) { 139 | $message['HTMLPart'] = $html; 140 | } 141 | 142 | foreach ($email->getHeaders()->all() as $header) { 143 | if ($convertConf = self::HEADER_TO_MESSAGE[$header->getName()] ?? false) { 144 | $message[$convertConf[0]] = $this->castCustomHeader($header->getBodyAsString(), $convertConf[1]); 145 | continue; 146 | } 147 | if (\in_array($header->getName(), self::FORBIDDEN_HEADERS, true)) { 148 | continue; 149 | } 150 | 151 | $message['Headers'][$header->getName()] = $header->getBodyAsString(); 152 | } 153 | 154 | return [ 155 | 'Messages' => [$message], 156 | 'SandBoxMode' => $this->sandbox, 157 | ]; 158 | } 159 | 160 | private function formatAddresses(array $addresses): array 161 | { 162 | return array_map($this->formatAddress(...), $addresses); 163 | } 164 | 165 | private function formatAddress(Address $address): array 166 | { 167 | return [ 168 | 'Email' => $address->getAddress(), 169 | 'Name' => $address->getName(), 170 | ]; 171 | } 172 | 173 | private function prepareAttachments(Email $email, ?string $html): array 174 | { 175 | $attachments = $inlines = []; 176 | foreach ($email->getAttachments() as $attachment) { 177 | $headers = $attachment->getPreparedHeaders(); 178 | $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); 179 | $formattedAttachment = [ 180 | 'ContentType' => $attachment->getMediaType().'/'.$attachment->getMediaSubtype(), 181 | 'Filename' => $filename, 182 | 'Base64Content' => $attachment->bodyToString(), 183 | ]; 184 | if ('inline' === $headers->getHeaderBody('Content-Disposition')) { 185 | $formattedAttachment['ContentID'] = $headers->getHeaderParameter('Content-Disposition', 'name'); 186 | $inlines[] = $formattedAttachment; 187 | } else { 188 | $attachments[] = $formattedAttachment; 189 | } 190 | } 191 | 192 | return [$attachments, $inlines, $html]; 193 | } 194 | 195 | private function getEndpoint(): ?string 196 | { 197 | return ($this->host ?: self::HOST).($this->port ? ':'.$this->port : ''); 198 | } 199 | 200 | private function castCustomHeader(string $value, string $type): mixed 201 | { 202 | return match ($type) { 203 | 'bool' => filter_var($value, \FILTER_VALIDATE_BOOL), 204 | 'int' => (int) $value, 205 | 'json' => json_decode($value, true, 512, \JSON_THROW_ON_ERROR), 206 | 'string' => $value, 207 | }; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Transport/MailjetSmtpTransport.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\Mailjet\Transport; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 17 | 18 | class MailjetSmtpTransport extends EsmtpTransport 19 | { 20 | public function __construct(string $username, #[\SensitiveParameter] string $password, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) 21 | { 22 | parent::__construct('in-v3.mailjet.com', 587, false, $dispatcher, $logger); 23 | 24 | $this->setUsername($username); 25 | $this->setPassword($password); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Transport/MailjetTransportFactory.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\Mailjet\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 | class MailjetTransportFactory extends AbstractTransportFactory 20 | { 21 | public function create(Dsn $dsn): TransportInterface 22 | { 23 | $scheme = $dsn->getScheme(); 24 | $user = $this->getUser($dsn); 25 | $password = $this->getPassword($dsn); 26 | $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); 27 | $sandbox = $dsn->getBooleanOption('sandbox'); 28 | 29 | if ('mailjet+api' === $scheme) { 30 | return (new MailjetApiTransport($user, $password, $this->client, $this->dispatcher, $this->logger, $sandbox))->setHost($host); 31 | } 32 | 33 | if (\in_array($scheme, ['mailjet+smtp', 'mailjet+smtps', 'mailjet'])) { 34 | return new MailjetSmtpTransport($user, $password, $this->dispatcher, $this->logger); 35 | } 36 | 37 | throw new UnsupportedSchemeException($dsn, 'mailjet', $this->getSupportedSchemes()); 38 | } 39 | 40 | protected function getSupportedSchemes(): array 41 | { 42 | return ['mailjet', 'mailjet+api', 'mailjet+smtp', 'mailjet+smtps']; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Webhook/MailjetRequestParser.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\Mailjet\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\Mailjet\RemoteEvent\MailjetPayloadConverter; 20 | use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; 21 | use Symfony\Component\RemoteEvent\Exception\ParseException; 22 | use Symfony\Component\Webhook\Client\AbstractRequestParser; 23 | use Symfony\Component\Webhook\Exception\RejectWebhookException; 24 | 25 | final class MailjetRequestParser extends AbstractRequestParser 26 | { 27 | public function __construct( 28 | private readonly MailjetPayloadConverter $converter, 29 | ) { 30 | } 31 | 32 | protected function getRequestMatcher(): RequestMatcherInterface 33 | { 34 | return new ChainRequestMatcher([ 35 | new MethodRequestMatcher('POST'), 36 | new IsJsonRequestMatcher(), 37 | ]); 38 | } 39 | 40 | protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent 41 | { 42 | try { 43 | return $this->converter->convert($request->toArray()); 44 | } catch (ParseException $e) { 45 | throw new RejectWebhookException(406, $e->getMessage(), $e); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/mailjet-mailer", 3 | "type": "symfony-mailer-bridge", 4 | "description": "Symfony Mailjet 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.3" 21 | }, 22 | "require-dev": { 23 | "symfony/http-client": "^6.4|^7.0", 24 | "symfony/webhook": "^6.4|^7.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Mailjet\\": "" }, 28 | "exclude-from-classmap": [ 29 | "/Tests/" 30 | ] 31 | }, 32 | "minimum-stability": "dev" 33 | } 34 | --------------------------------------------------------------------------------