8000 [Webhook][RemoteEvent] Add Sendgrid symfony/symfony#50704 · symfony/symfony@8ceee75 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8ceee75

Browse files
[Webhook][RemoteEvent] Add Sendgrid #50704
1 parent 52a9292 commit 8ceee75

File tree

11 files changed

+284
-2
lines changed

11 files changed

+284
-2
lines changed

composer.json

Lines 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

Lines 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

Lines 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

Lines 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+
4 F438 9
5.4
510
---
611

Lines 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+
}
Lines 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}]
Lines 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;
Lines 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+
}
Lines 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+
}
Lines 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

Lines 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