├── CHANGELOG.md ├── LICENSE ├── README.md ├── RemoteEvent └── SendgridPayloadConverter.php ├── Transport ├── SendgridApiTransport.php ├── SendgridSmtpTransport.php └── SendgridTransportFactory.php ├── Webhook └── SendgridRequestParser.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.2 5 | --- 6 | 7 | * Add support for region in DSN 8 | 9 | 6.4 10 | --- 11 | 12 | * Add support for webhooks 13 | 14 | 5.4 15 | --- 16 | 17 | * Add support for `TagHeader` and `MetadataHeader` to the Sendgrid API transport 18 | 19 | 4.4.0 20 | ----- 21 | 22 | * [BC BREAK] Renamed and moved `Symfony\Component\Mailer\Bridge\Sendgrid\Http\Api\SendgridTransport` 23 | to `Symfony\Component\Mailer\Bridge\Sendgrid\Transport\SendgridApiTransport`, `Symfony\Component\Mailer\Bridge\Sendgrid\Smtp\SendgridTransport` 24 | to `Symfony\Component\Mailer\Bridge\Sendgrid\Transport\SendgridSmtpTransport`. 25 | 26 | 4.3.0 27 | ----- 28 | 29 | * Added the bridge 30 | -------------------------------------------------------------------------------- /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 | Sendgrid Bridge 2 | =============== 3 | 4 | Provides Sendgrid integration for Symfony Mailer. 5 | 6 | Configuration example: 7 | 8 | ```env 9 | # SMTP 10 | MAILER_DSN=sendgrid+smtp://KEY@default?region=REGION 11 | 12 | # API 13 | MAILER_DSN=sendgrid+api://KEY@default?region=REGION 14 | ``` 15 | 16 | where: 17 | - `KEY` is your Sendgrid API Key 18 | - `REGION` is Sendgrid selected region (default to global) 19 | 20 | Webhook 21 | ------- 22 | 23 | Create a route: 24 | 25 | ```yaml 26 | framework: 27 | webhook: 28 | routing: 29 | sendgrid: 30 | service: mailer.webhook.request_parser.sendgrid 31 | secret: '!SENDGRID_VALIDATION_SECRET!' # Leave blank if you dont want to use the signature validation 32 | ``` 33 | 34 | And a consume: 35 | 36 | ```php 37 | #[\Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer(name: 'sendgrid')] 38 | class SendGridConsumer implements ConsumerInterface 39 | { 40 | public function consume(RemoteEvent|MailerDeliveryEvent $event): void 41 | { 42 | // your code 43 | } 44 | } 45 | ``` 46 | 47 | Resources 48 | --------- 49 | 50 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 51 | * [Report issues](https://github.com/symfony/symfony/issues) and 52 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 53 | in the [main Symfony repository](https://github.com/symfony/symfony) 54 | -------------------------------------------------------------------------------- /RemoteEvent/SendgridPayloadConverter.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\Sendgrid\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 | /** 21 | * @author WoutervanderLoop.nl 22 | */ 23 | final class SendgridPayloadConverter implements PayloadConverterInterface 24 | { 25 | public function convert(array $payload): AbstractMailerEvent 26 | { 27 | if (\in_array($payload['event'], ['processed', 'delivered', 'bounce', 'dropped', 'deferred'], true)) { 28 | $name = match ($payload['event']) { 29 | 'processed', 'delivered' => MailerDeliveryEvent::DELIVERED, 30 | 'dropped' => MailerDeliveryEvent::DROPPED, 31 | 'deferred' => MailerDeliveryEvent::DEFERRED, 32 | 'bounce' => MailerDeliveryEvent::BOUNCE, 33 | }; 34 | $event = new MailerDeliveryEvent($name, $payload['sg_message_id'] ?? $payload['sg_event_id'], $payload); 35 | $event->setReason($payload['reason'] ?? ''); 36 | } else { 37 | $name = match ($payload['event']) { 38 | 'click' => MailerEngagementEvent::CLICK, 39 | 'unsubscribe' => MailerEngagementEvent::UNSUBSCRIBE, 40 | 'open' => MailerEngagementEvent::OPEN, 41 | 'spamreport' => MailerEngagementEvent::SPAM, 42 | default => throw new ParseException(\sprintf('Unsupported event "%s".', $payload['event'])), 43 | }; 44 | $event = new MailerEngagementEvent($name, $payload['sg_message_id'] ?? $payload['sg_event_id'], $payload); 45 | } 46 | 47 | if (!$date = \DateTimeImmutable::createFromFormat('U', $payload['timestamp'])) { 48 | throw new ParseException(\sprintf('Invalid date "%s".', $payload['timestamp'])); 49 | } 50 | 51 | $event->setDate($date); 52 | $event->setRecipientEmail($payload['email']); 53 | $event->setMetadata([]); 54 | $event->setTags((array) ($payload['category'] ?? [])); 55 | 56 | return $event; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Transport/SendgridApiTransport.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\Sendgrid\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\Header\MetadataHeader; 20 | use Symfony\Component\Mailer\Header\TagHeader; 21 | use Symfony\Component\Mailer\SentMessage; 22 | use Symfony\Component\Mailer\Transport\AbstractApiTransport; 23 | use Symfony\Component\Mime\Address; 24 | use Symfony\Component\Mime\Email; 25 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 26 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 27 | use Symfony\Contracts\HttpClient\HttpClientInterface; 28 | use Symfony\Contracts\HttpClient\ResponseInterface; 29 | 30 | /** 31 | * @author Kevin Verschaeve 32 | */ 33 | class SendgridApiTransport extends AbstractApiTransport 34 | { 35 | private const HOST = 'api.%region_dot%sendgrid.com'; 36 | 37 | public function __construct( 38 | #[\SensitiveParameter] private string $key, 39 | ?HttpClientInterface $client = null, 40 | ?EventDispatcherInterface $dispatcher = null, 41 | ?LoggerInterface $logger = null, 42 | private ?string $region = null, 43 | ) { 44 | parent::__construct($client, $dispatcher, $logger); 45 | } 46 | 47 | public function __toString(): string 48 | { 49 | return \sprintf('sendgrid+api://%s', $this->getEndpoint()); 50 | } 51 | 52 | protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface 53 | { 54 | $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/v3/mail/send', [ 55 | 'json' => $this->getPayload($email, $envelope), 56 | 'auth_bearer' => $this->key, 57 | ]); 58 | 59 | try { 60 | $statusCode = $response->getStatusCode(); 61 | } catch (TransportExceptionInterface $e) { 62 | throw new HttpTransportException('Could not reach the remote Sendgrid server.', $response, 0, $e); 63 | } 64 | 65 | if (202 !== $statusCode) { 66 | try { 67 | $result = $response->toArray(false); 68 | 69 | throw new HttpTransportException('Unable to send an email: '.implode('; ', array_column($result['errors'], 'message')).\sprintf(' (code %d).', $statusCode), $response); 70 | } catch (DecodingExceptionInterface $e) { 71 | throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).\sprintf(' (code %d).', $statusCode), $response, 0, $e); 72 | } 73 | } 74 | 75 | $sentMessage->setMessageId($response->getHeaders(false)['x-message-id'][0]); 76 | 77 | return $response; 78 | } 79 | 80 | private function getPayload(Email $email, Envelope $envelope): array 81 | { 82 | $addressStringifier = function (Address $address) { 83 | $stringified = ['email' => $address->getAddress()]; 84 | 85 | if ($address->getName()) { 86 | $stringified['name'] = $address->getName(); 87 | } 88 | 89 | return $stringified; 90 | }; 91 | 92 | $payload = [ 93 | 'personalizations' => [], 94 | 'from' => $addressStringifier($envelope->getSender()), 95 | 'content' => $this->getContent($email), 96 | ]; 97 | 98 | if ($email->getAttachments()) { 99 | $payload['attachments'] = $this->getAttachments($email); 100 | } 101 | 102 | $personalization = [ 103 | 'to' => array_map($addressStringifier, $this->getRecipients($email, $envelope)), 104 | 'subject' => $email->getSubject(), 105 | ]; 106 | if ($emails = array_map($addressStringifier, $email->getCc())) { 107 | $personalization['cc'] = $emails; 108 | } 109 | if ($emails = array_map($addressStringifier, $email->getBcc())) { 110 | $personalization['bcc'] = $emails; 111 | } 112 | if ($emails = array_map($addressStringifier, $email->getReplyTo())) { 113 | // Email class supports an array of reply-to addresses, 114 | // but SendGrid only supports a single address 115 | $payload['reply_to'] = $emails[0]; 116 | } 117 | 118 | $customArguments = []; 119 | $categories = []; 120 | 121 | // these headers can't be overwritten according to Sendgrid docs 122 | // see https://sendgrid.api-docs.io/v3.0/mail-send/mail-send-errors#-Headers-Errors 123 | $headersToBypass = ['x-sg-id', 'x-sg-eid', 'received', 'dkim-signature', 'content-transfer-encoding', 'from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'reply-to']; 124 | foreach ($email->getHeaders()->all() as $name => $header) { 125 | if (\in_array($name, $headersToBypass, true)) { 126 | continue; 127 | } 128 | 129 | if ($header instanceof TagHeader) { 130 | if (10 === \count($categories)) { 131 | throw new TransportException(\sprintf('Too many "%s" instances present in the email headers. Sendgrid does not accept more than 10 categories on an email.', TagHeader::class)); 132 | } 133 | $categories[] = mb_substr($header->getValue(), 0, 255); 134 | } elseif ($header instanceof MetadataHeader) { 135 | $customArguments[$header->getKey()] = $header->getValue(); 136 | } else { 137 | $payload['headers'][$header->getName()] = $header->getBodyAsString(); 138 | } 139 | } 140 | 141 | if (\count($categories) > 0) { 142 | $payload['categories'] = $categories; 143 | } 144 | 145 | if (\count($customArguments) > 0) { 146 | $personalization['custom_args'] = $customArguments; 147 | } 148 | 149 | $payload['personalizations'][] = $personalization; 150 | 151 | return $payload; 152 | } 153 | 154 | private function getContent(Email $email): array 155 | { 156 | $content = []; 157 | if (null !== $text = $email->getTextBody()) { 158 | $content[] = ['type' => 'text/plain', 'value' => $text]; 159 | } 160 | if (null !== $html = $email->getHtmlBody()) { 161 | $content[] = ['type' => 'text/html', 'value' => $html]; 162 | } 163 | 164 | return $content; 165 | } 166 | 167 | private function getAttachments(Email $email): array 168 | { 169 | $attachments = []; 170 | foreach ($email->getAttachments() as $attachment) { 171 | $headers = $attachment->getPreparedHeaders(); 172 | $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); 173 | $disposition = $headers->getHeaderBody('Content-Disposition'); 174 | 175 | $att = [ 176 | 'content' => str_replace("\r\n", '', $attachment->bodyToString()), 177 | 'type' => $headers->get('Content-Type')->getBody(), 178 | 'filename' => $filename, 179 | 'disposition' => $disposition, 180 | ]; 181 | 182 | if ('inline' === $disposition) { 183 | $att['content_id'] = $attachment->hasContentId() ? $attachment->getContentId() : $filename; 184 | } 185 | 186 | $attachments[] = $att; 187 | } 188 | 189 | return $attachments; 190 | } 191 | 192 | private function getEndpoint(): ?string 193 | { 194 | $host = $this->host ?: str_replace('%region_dot%', '', self::HOST); 195 | if (null !== $this->region && null === $this->host) { 196 | $host = str_replace('%region_dot%', $this->region.'.', self::HOST); 197 | } 198 | 199 | return $host.($this->port ? ':'.$this->port : ''); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Transport/SendgridSmtpTransport.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\Sendgrid\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 SendgridSmtpTransport extends EsmtpTransport 22 | { 23 | public function __construct(#[\SensitiveParameter] string $key, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, private ?string $region = null) 24 | { 25 | parent::__construct(null !== $region ? \sprintf('smtp.%s.sendgrid.net', $region) : 'smtp.sendgrid.net', 465, true, $dispatcher, $logger); 26 | 27 | $this->setUsername('apikey'); 28 | $this->setPassword($key); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Transport/SendgridTransportFactory.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\Sendgrid\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 SendgridTransportFactory extends AbstractTransportFactory 23 | { 24 | public function create(Dsn $dsn): TransportInterface 25 | { 26 | $scheme = $dsn->getScheme(); 27 | $key = $this->getUser($dsn); 28 | $region = $dsn->getOption('region'); 29 | 30 | if ('sendgrid+api' === $scheme) { 31 | $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); 32 | $port = $dsn->getPort(); 33 | 34 | return (new SendgridApiTransport($key, $this->client, $this->dispatcher, $this->logger, $region))->setHost($host)->setPort($port); 35 | } 36 | 37 | if ('sendgrid+smtp' === $scheme || 'sendgrid+smtps' === $scheme || 'sendgrid' === $scheme) { 38 | return new SendgridSmtpTransport($key, $this->dispatcher, $this->logger, $region); 39 | } 40 | 41 | throw new UnsupportedSchemeException($dsn, 'sendgrid', $this->getSupportedSchemes()); 42 | } 43 | 44 | protected function getSupportedSchemes(): array 45 | { 46 | return ['sendgrid', 'sendgrid+api', 'sendgrid+smtp', 'sendgrid+smtps']; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Webhook/SendgridRequestParser.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\Sendgrid\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\Sendgrid\RemoteEvent\SendgridPayloadConverter; 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 | /** 27 | * @author WoutervanderLoop.nl 28 | */ 29 | final class SendgridRequestParser extends AbstractRequestParser 30 | { 31 | public function __construct( 32 | private readonly SendgridPayloadConverter $converter, 33 | ) { 34 | } 35 | 36 | protected function getRequestMatcher(): RequestMatcherInterface 37 | { 38 | return new ChainRequestMatcher([ 39 | new MethodRequestMatcher('POST'), 40 | new IsJsonRequestMatcher(), 41 | ]); 42 | } 43 | 44 | /** 45 | * @return AbstractMailerEvent[] 46 | */ 47 | protected function doParse(Request $request, string $secret): array 48 | { 49 | $content = $request->toArray(); 50 | if ( 51 | !isset($content[0]['email']) 52 | || !isset($content[0]['timestamp']) 53 | || !isset($content[0]['event']) 54 | || !isset($content[0]['sg_event_id']) 55 | ) { 56 | throw new RejectWebhookException(406, 'Payload is malformed.'); 57 | } 58 | 59 | if ($secret) { 60 | if (!$request->headers->get('X-Twilio-Email-Event-Webhook-Signature') 61 | || !$request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp') 62 | ) { 63 | throw new RejectWebhookException(406, 'Signature is required.'); 64 | } 65 | 66 | $this->validateSignature( 67 | $request->headers->get('X-Twilio-Email-Event-Webhook-Signature'), 68 | $request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp'), 69 | $request->getContent(), 70 | $secret, 71 | ); 72 | } 73 | 74 | try { 75 | return array_map($this->converter->convert(...), $content); 76 | } catch (ParseException $e) { 77 | throw new RejectWebhookException(406, $e->getMessage(), $e); 78 | } 79 | } 80 | 81 | /** 82 | * Verify signed event webhook requests. 83 | * 84 | * @param string $signature value obtained from the 85 | * 'X-Twilio-Email-Event-Webhook-Signature' header 86 | * @param string $timestamp value obtained from the 87 | * 'X-Twilio-Email-Event-Webhook-Timestamp' header 88 | * @param string $payload event payload in the request body 89 | * @param string $secret base64-encoded DER public key 90 | * 91 | * @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features 92 | */ 93 | private function validateSignature(string $signature, string $timestamp, string $payload, #[\SensitiveParameter] string $secret): void 94 | { 95 | if (!$secret) { 96 | throw new InvalidArgumentException('A non-empty secret is required.'); 97 | } 98 | 99 | $timestampedPayload = $timestamp.$payload; 100 | 101 | // Sendgrid provides the verification key as base64-encoded DER data. Openssl wants a PEM format, which is a multiline version of the base64 data. 102 | $pemKey = "-----BEGIN PUBLIC KEY-----\n".chunk_split($secret, 64, "\n")."-----END PUBLIC KEY-----\n"; 103 | 104 | if (!$publicKey = openssl_pkey_get_public($pemKey)) { 105 | throw new RejectWebhookException(406, 'Public key is wrong.'); 106 | } 107 | 108 | if (1 !== openssl_verify($timestampedPayload, base64_decode($signature), $publicKey, \OPENSSL_ALGO_SHA256)) { 109 | throw new RejectWebhookException(406, 'Signature is wrong.'); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/sendgrid-mailer", 3 | "type": "symfony-mailer-bridge", 4 | "description": "Symfony Sendgrid 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": "^7.2" 25 | }, 26 | "conflict": { 27 | "symfony/mime": "<6.4", 28 | "symfony/http-foundation": "<6.4", 29 | "symfony/webhook": "<7.2" 30 | }, 31 | "autoload": { 32 | "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Sendgrid\\": "" }, 33 | "exclude-from-classmap": [ 34 | "/Tests/" 35 | ] 36 | }, 37 | "minimum-stability": "dev" 38 | } 39 | --------------------------------------------------------------------------------