Skip to content

Commit

Permalink
[Webhook][RemoteEvent] Add Sendgrid #50704
Browse files Browse the repository at this point in the history
  • Loading branch information
WoutervanderLoopNL committed Jun 21, 2023
1 parent 52a9292 commit 1728999
Show file tree
Hide file tree
Showing 11 changed files with 283 additions and 2 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
"symfony/runtime": "self.version",
"symfony/security-acl": "~2.8|~3.0",
"starkbank/ecdsa": "^2.0",
"twig/cssinliner-extra": "^2.12|^3",
"twig/inky-extra": "^2.12|^3",
"twig/markdown-extra": "^2.12|^3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2615,6 +2615,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co
$webhookRequestParsers = [
MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun',
MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark',
MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid',
];

foreach ($webhookRequestParsers as $class => $service) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Symfony\Component\Mailer\Bridge\Mailgun\Webhook\MailgunRequestParser;
use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter;
use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser;
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;

return static function (ContainerConfigurator $container) {
$container->services()
Expand All @@ -27,5 +29,10 @@
->set('mailer.webhook.request_parser.postmark', PostmarkRequestParser::class)
->args([service('mailer.payload_converter.postmark')])
->alias(PostmarkRequestParser::class, 'mailer.webhook.request_parser.postmark')

->set('mailer.payload_converter.sendgrid', SendgridPayloadConverter::class)
->set('mailer.webhook.request_parser.sendgrid', SendgridRequestParser::class)
->args([service('mailer.payload_converter.sendgrid')])
->alias(SendgridRequestParser::class, 'mailer.webhook.request_parser.sendgrid')
;
};
5 changes: 5 additions & 0 deletions src/Symfony/Component/Mailer/Bridge/Sendgrid/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

6.4
---

* Add support for webhooks

5.4
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent;

use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent;
use Symfony\Component\RemoteEvent\Exception\ParseException;
use Symfony\Component\RemoteEvent\PayloadConverterInterface;

/**
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
*/
final class SendgridPayloadConverter implements PayloadConverterInterface
{
public function convert(array $payload): AbstractMailerEvent
{
if (\in_array($payload['event'], ['processed', 'delivered', 'bounce', 'dropped', 'deferred'], true)) {
$name = match ($payload['event']) {
'processed', 'delivered' => MailerDeliveryEvent::DELIVERED,
'dropped' => MailerDeliveryEvent::DROPPED,
'deferred' => MailerDeliveryEvent::DEFERRED,
'bounce' => MailerDeliveryEvent::BOUNCE,
};
$event = new MailerDeliveryEvent($name, $payload['sg_message_id'], $payload);
$event->setReason($payload['reason'] ?? '');
} else {
$name = match ($payload['event']) {
'click' => MailerEngagementEvent::CLICK,
'unsubscribe' => MailerEngagementEvent::UNSUBSCRIBE,
'open' => MailerEngagementEvent::OPEN,
'spamreport' => MailerEngagementEvent::SPAM,
default => throw new ParseException(sprintf('Unsupported event "%s".', $payload['unsubscribe'])),
};
$event = new MailerEngagementEvent($name, $payload['sg_message_id'], $payload);
}

if (!$date = \DateTimeImmutable::createFromFormat('U', $payload['timestamp'])) {
throw new ParseException(sprintf('Invalid date "%s".', $payload['timestamp']));
}

$event->setDate($date);
$event->setRecipientEmail($payload['email']);
$event->setMetadata([]);
$event->setTags($payload['category'] ?? []);

return $event;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +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}]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;

$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]);
$wh->setRecipientEmail('hello@world.com');
$wh->setTags([]);
$wh->setMetadata([]);
$wh->setReason('Bounced Address');
$wh->setDate(\DateTimeImmutable::createFromFormat('U', 1600112492));

return $wh;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
use Symfony\Component\Webhook\Client\RequestParserInterface;
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;

/**
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
*/
class SendgridSignedRequestParserTest extends AbstractRequestParserTestCase
{
protected function createRequestParser(): RequestParserInterface
{
return new SendgridRequestParser(new SendgridPayloadConverter());
}

/**
* @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20
*/
protected function createRequest(string $payload): Request
{
return Request::create('/', 'POST', [], [], [], [
'Content-Type' => 'application/json',
'HTTP_X-Twilio-Email-Event-Webhook-Signature' => 'MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=',
'HTTP_X-Twilio-Email-Event-Webhook-Timestamp' => '1600112502',
], str_replace("\n", "\r\n", $payload));
}

protected function getSecret(): string
{
return 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g==';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Sendgrid\Tests\Webhook;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
use Symfony\Component\Webhook\Client\RequestParserInterface;
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;

/**
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
*/
class SendgridUnsignedRequestParserTest extends AbstractRequestParserTestCase
{
protected function createRequestParser(): RequestParserInterface
{
return new SendgridRequestParser(new SendgridPayloadConverter());
}

/**
* @see https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/test/unit/EventWebhookTest.php#L20
*/
protected function createRequest(string $payload): Request
{
return Request::create('/', 'POST', [], [], [], [
'Content-Type' => 'application/json',
], str_replace("\n", "\r\n", $payload));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Sendgrid\Webhook;

use EllipticCurve\Ecdsa;
use EllipticCurve\PublicKey;
use EllipticCurve\Signature;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
use Symfony\Component\RemoteEvent\Exception\ParseException;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

/**
* @author WoutervanderLoop.nl <info@woutervanderloop.nl>
*/
final class SendgridRequestParser extends AbstractRequestParser
{
public function __construct(
private readonly SendgridPayloadConverter $converter,
) {
}

protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new MethodRequestMatcher('POST'),
new IsJsonRequestMatcher(),
]);
}

protected function doParse(Request $request, string $secret): ?AbstractMailerEvent
{
$content = $request->toArray();
if (
!isset($content[0]['email'])
|| !isset($content[0]['timestamp'])
|| !isset($content[0]['event'])
|| !isset($content[0]['sg_message_id'])
) {
throw new RejectWebhookException(406, 'Payload is malformed.');
}

if ($request->headers->get('X-Twilio-Email-Event-Webhook-Signature')
&& $request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp')
) {
if (!class_exists(Ecdsa::class)) {
throw new LogicException('Package "starkbank/ecdsa" is required to use the "event-webhook-security" feature. Try running "composer require starkbank/ecdsa".');
}

$this->validateSignature(
$request->headers->get('X-Twilio-Email-Event-Webhook-Signature'),
$request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp'),
$request->getContent(),
PublicKey::fromDer(base64_decode($secret)),
);
}

try {
return $this->converter->convert($content[0]);
} catch (ParseException $e) {
throw new RejectWebhookException(406, $e->getMessage(), $e);
}
}

/**
* Verify signed event webhook requests.
*
* @param string $signature value obtained from the
* 'X-Twilio-Email-Event-Webhook-Signature' header
* @param string $timestamp value obtained from the
* 'X-Twilio-Email-Event-Webhook-Timestamp' header
* @param string $payload event payload in the request body
* @param PublicKey $publicKey elliptic curve public key
*
* @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features
*/
private function validateSignature(
string $signature,
string $timestamp,
string $payload,
PublicKey $publicKey,
): void {
$timestampedPayload = $timestamp.$payload;

$decodedSignature = Signature::fromBase64($signature);

if (!Ecdsa::verify($timestampedPayload, $decodedSignature, $publicKey)) {
throw new RejectWebhookException(406, 'Signature is wrong.');
}
}
}
8 changes: 6 additions & 2 deletions src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@
],
"require": {
"php": ">=8.1",
"psr/event-dispatcher": "^1",
"symfony/mailer": "^5.4.21|^6.2.7|^7.0"
},
"require-dev": {
"symfony/http-client": "^5.4|^6.0|^7.0"
"symfony/http-client": "^5.4|^6.0|^7.0",
"symfony/webhook": "^6.3|^7.0",
"starkbank/ecdsa": "^2.0"
},
"conflict": {
"symfony/mime": "<6.2"
"symfony/mime": "<6.2",
"symfony/http-foundation": "<6.2"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Sendgrid\\": "" },
Expand Down

0 comments on commit 1728999

Please sign in to comment.