Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 8ceee75

Browse filesBrowse files
[Webhook][RemoteEvent] Add Sendgrid #50704
1 parent 52a9292 commit 8ceee75
Copy full SHA for 8ceee75

File tree

11 files changed

+284
-2
lines changed
Filter options

11 files changed

+284
-2
lines changed

‎composer.json

Copy file name to clipboardExpand all lines: composer.json
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
154154
"symfony/runtime": "self.version",
155155
"symfony/security-acl": "~2.8|~3.0",
156+
"starkbank/ecdsa": "^2.0",
156157
"twig/cssinliner-extra": "^2.12|^3",
157158
"twig/inky-extra": "^2.12|^3",
158159
"twig/markdown-extra": "^2.12|^3",

‎src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2615,6 +2615,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co
26152615
$webhookRequestParsers = [
26162616
MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun',
26172617
MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark',
2618+
MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid',
26182619
];
26192620

26202621
foreach ($webhookRequestParsers as $class => $service) {

‎src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php
+7Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Symfony\Component\Mailer\Bridge\Mailgun\Webhook\MailgunRequestParser;
1616
use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter;
1717
use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser;
18+
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
19+
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
1820

1921
return static function (ContainerConfigurator $container) {
2022
$container->services()
@@ -27,5 +29,10 @@
2729
->set('mailer.webhook.request_parser.postmark', PostmarkRequestParser::class)
2830
->args([service('mailer.payload_converter.postmark')])
2931
->alias(PostmarkRequestParser::class, 'mailer.webhook.request_parser.postmark')
32+
33+
->set('mailer.payload_converter.sendgrid', SendgridPayloadConverter::class)
34+
->set('mailer.webhook.request_parser.sendgrid', SendgridRequestParser::class)
35+
->args([service('mailer.payload_converter.sendgrid')])
36+
->alias(SendgridRequestParser::class, 'mailer.webhook.request_parser.sendgrid')
3037
;
3138
};

‎src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.4
5+
---
6+
7+
* Add support for webhooks
8+
49
5.4
510
---
611

+58Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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 <info@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);
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['unsubscribe'])),
43+
};
44+
$event = new MailerEngagementEvent($name, $payload['sg_message_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($payload['category'] ?? []);
55+
56+
return $event;
57+
}
58+
}
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"email":"hello@world.com","event":"dropped","reason":"Bounced Address","sg_event_id":"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA","sg_message_id":"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0","smtp-id":"<LRzXl_NHStOGhQ4kofSm_A@ismtpd0039p1iad1.sendgrid.net>","timestamp":1600112492}]
+12Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
4+
5+
$wh = new MailerDeliveryEvent(MailerDeliveryEvent::DROPPED, 'LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0', json_decode(file_get_contents(str_replace('.php', '.json', __FILE__)), true)[0]);
6+
$wh->setRecipientEmail('hello@world.com');
7+
$wh->setTags([]);
8+
$wh->setMetadata([]);
9+
$wh->setReason('Bounced Address');
10+
$wh->setDate(\DateTimeImmutable::createFromFormat('U', 1600112492));
11+
12+
return $wh;
+46Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Tests\Webhook;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
16+
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
17+
use Symfony\Component\Webhook\Client\RequestParserInterface;
18+
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
19+
20+
/**
21+
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
22+
*/
23+
class SendgridSignedRequestParserTest extends AbstractRequestParserTestCase
24+
{
25+
protected function createRequestParser(): RequestParserInterface
26+
{
27+
return new SendgridRequestParser(new SendgridPayloadConverter());
28+
}
29+
30+
/**
31+
* @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20
32+
*/
33+
protected function createRequest(string $payload): Request
34+
{
35+
return Request::create('/', 'POST', [], [], [], [
36+
'Content-Type' => 'application/json',
37+
'HTTP_X-Twilio-Email-Event-Webhook-Signature' => 'MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=',
38+
'HTTP_X-Twilio-Email-Event-Webhook-Timestamp' => '1600112502',
39+
], str_replace("\n", "\r\n", $payload));
40+
}
41+
42+
protected function getSecret(): string
43+
{
44+
return 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g==';
45+
}
46+
}
+39Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Tests\Webhook;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
16+
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
17+
use Symfony\Component\Webhook\Client\RequestParserInterface;
18+
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
19+
20+
/**
21+
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
22+
*/
23+
class SendgridUnsignedRequestParserTest extends AbstractRequestParserTestCase
24+
{
25+
protected function createRequestParser(): RequestParserInterface
26+
{
27+
return new SendgridRequestParser(new SendgridPayloadConverter());
28+
}
29+
30+
/**
31+
* @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20
32+
*/
33+
protected function createRequest(string $payload): Request
34+
{
35+
return Request::create('/', 'POST', [], [], [], [
36+
'Content-Type' => 'application/json',
37+
], str_replace("\n", "\r\n", $payload));
38+
}
39+
}
+108Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
15+
use EllipticCurve\Ecdsa;
16+
use EllipticCurve\PublicKey;
17+
use EllipticCurve\Signature;
18+
use Symfony\Component\DependencyInjection\Exception\LogicException;
19+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
20+
use Symfony\Component\HttpFoundation\Request;
21+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
22+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
23+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
24+
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
25+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
26+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
27+
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
28+
use Symfony\Component\RemoteEvent\Exception\ParseException;
29+
30+
/**
31+
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
32+
*/
33+
final class SendgridRequestParser extends AbstractRequestParser
34+
{
35+
public function __construct(
36+
private readonly SendgridPayloadConverter $converter,
37+
) {
38+
}
39+
40+
protected function getRequestMatcher(): RequestMatcherInterface
41+
{
42+
return new ChainRequestMatcher([
43+
new MethodRequestMatcher('POST'),
44+
new IsJsonRequestMatcher(),
45+
]);
46+
}
47+
48+
protected function doParse(Request $request, string $secret): ?AbstractMailerEvent
49+
{
50+
$content = $request->toArray();
51+
if (
52+
!isset($content[0]['email'])
53+
|| !isset($content[0]['timestamp'])
54+
|| !isset($content[0]['event'])
55+
|| !isset($content[0]['sg_message_id'])
56+
) {
57+
throw new RejectWebhookException(406, 'Payload is malformed.');
58+
}
59+
60+
if ($request->headers->get('X-Twilio-Email-Event-Webhook-Signature')
61+
&& $request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp')
62+
) {
63+
if (!class_exists(Ecdsa::class)) {
64+
throw new LogicException('Package "starkbank/ecdsa" is required to use the "event-webhook-security" feature. Try running "composer require starkbank/ecdsa".');
65+
}
66+
67+
$this->validateSignature(
68+
$request->headers->get('X-Twilio-Email-Event-Webhook-Signature'),
69+
$request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp'),
70+
$request->getContent(),
71+
PublicKey::fromDer(base64_decode($secret)),
72+
);
73+
}
74+
75+
try {
76+
return $this->converter->convert($content[0]);
77+
} catch (ParseException $e) {
78+
throw new RejectWebhookException(406, $e->getMessage(), $e);
79+
}
80+
}
81+
82+
/**
83+
* Verify signed event webhook requests.
84+
*
85+
* @param string $signature value obtained from the
86+
* 'X-Twilio-Email-Event-Webhook-Signature' header
87+
* @param string $timestamp value obtained from the
88+
* 'X-Twilio-Email-Event-Webhook-Timestamp' header
89+
* @param string $payload event payload in the request body
90+
* @param PublicKey $publicKey elliptic curve public key
91+
*
92+
* @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features
93+
*/
94+
private function validateSignature(
95+
string $signature,
96+
string $timestamp,
97+
string $payload,
98+
PublicKey $publicKey,
99+
): void {
100+
$timestampedPayload = $timestamp . $payload;
101+
102+
$decodedSignature = Signature::fromBase64($signature);
103+
104+
if (!Ecdsa::verify($timestampedPayload, $decodedSignature, $publicKey)) {
105+
throw new RejectWebhookException(406, 'Signature is wrong.');
106+
}
107+
}
108+
}

‎src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json

Copy file name to clipboardExpand all lines: src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json
+6-2Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
],
1818
"require": {
1919
"php": ">=8.1",
20+
"psr/event-dispatcher": "^1",
2021
"symfony/mailer": "^5.4.21|^6.2.7|^7.0"
2122
},
2223
"require-dev": {
23-
"symfony/http-client": "^5.4|^6.0|^7.0"
24+
"symfony/http-client": "^5.4|^6.0|^7.0",
25+
"symfony/webhook": "^6.3|^7.0",
26+
"starkbank/ecdsa": "^2.0"
2427
},
2528
"conflict": {
26-
"symfony/mime": "<6.2"
29+
"symfony/mime": "<6.2",
30+
"symfony/http-foundation": "<6.2"
2731
},
2832
"autoload": {
2933
"psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Sendgrid\\": "" },

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.