Skip to content

Commit 07af49c

Browse files
Merge pull request #8 from jubbs/StatusEvents
Status events and Update to Cakephp5
2 parents 0a72827 + c9cec35 commit 07af49c

12 files changed

Lines changed: 541 additions & 42 deletions

File tree

README.md

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88

99
This plugin provides email delivery using [SendGrid](https://sendgrid.com/).
1010

11-
This branch is for use with CakePHP 4.0+. For CakePHP 3, please use cake-3.x branch.
11+
This branch is for use with CakePHP 5.0+. For CakePHP 4, please use cake-4.x branch.
1212

1313
## Requirements
1414

1515
This plugin has the following requirements:
1616

17-
* CakePHP 4.0 or greater.
18-
* PHP 7.2 or greater.
17+
* CakePHP 5.0 or greater.
18+
* PHP 8.1 or greater.
1919

2020
## Installation
2121

@@ -137,6 +137,69 @@ $email->setTo('[email protected]')
137137
->setSendAt(1649500630)
138138
->deliver();
139139
```
140+
## Webhooks
141+
You can receive status events from SendGrid. This allows you ensure that SendGrid was able to send the email recording bounces etc.
142+
143+
### Webhook Config
144+
You will require a Table in the database to record the emails sent. You can use the lorenzo/cakephp-email-queue plugin to queue the emails and in that case you would
145+
use the email_queue table. However you can create your own table/Model as long as it has at least three columns. They can be called anything but they must have the correct types.
146+
147+
When you send the email the deliver function will return an array with a 'messageId' element if it successfully connected to SendGrid. This needs to be recorded in the status_id field.
148+
149+
* status_id VARCHAR(100)
150+
* status VARCHAR(100)
151+
* status_message TEXT
152+
153+
You need to map this table and these fields in you app_local.php config
154+
155+
```php
156+
157+
'sendgridWebhook' => [
158+
'tableClass' => 'EmailQueue', // The table name that stores email data
159+
'uniqueIdField' => 'status_id', // The field name that stores the unique message ID VARCHAR(100)
160+
'statusField' => 'status', // The field name that stores the status of the email status VARCHAR(100)
161+
'statusMessageField' => 'status_message', // The field name that stores the status messages TEXT
162+
'debug' => 'true', // write incoming requests to debug log
163+
'secure' => 'true', // enable SendGrid signed webhook security. You should enable this in production
164+
'verification_key' => '<YOUR VERIFICATION KEY>', // The verification key from SendGrid
165+
],
166+
167+
```
168+
169+
You will need to login to your SendGrid Account and configure your domain and the events that you want to track
170+
171+
https://app.sendgrid.com/settings/mail_settings/webhook_settings
172+
173+
The return url needs to be set to
174+
* https://YOUR DOMAIN/send-grid/webhook
175+
176+
177+
The CSRF protection middleware needs to allow posts to the webhooks controller in Application.php
178+
Remove the current CSRF protection middleware and replace it with the following. If you already have CSRF exceptions then add the Webhooks one
179+
180+
```php
181+
$csrf = new CsrfProtectionMiddleware();
182+
183+
$csrf->skipCheckCallback(function ($request) {
184+
// Skip token check for API URLs.
185+
if ($request->getParam('controller') === 'Webhooks') {
186+
return true;
187+
}
188+
});
189+
190+
// Ensure routing middleware is added to the queue before CSRF protection middleware.
191+
$middlewareQueue->add($csrf);
192+
193+
return $middlewareQueue;
194+
195+
```
196+
197+
If the authentication plugin (https://book.cakephp.org/authentication/3/en/index.html) is used for authentication the webhook action should work OK. If you have a different authentication method then you will need to add an exception for the webhook action. /send-grid/webhooks/index
198+
199+
#### Webhook Signature Verification
200+
SendGrid allows you to sign the webhook requests. This is a good idea in production to keep the webhook secure. You will need to enable this in your SendGrid account and then set secure to true and add your verification key to your app_local.php config file.
201+
202+
https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features. Enable signed event webhook and follow the instructions to get the verification key.
140203

141204
## Reporting Issues
142205

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
"issues": "https://github.com/sprintcube/cakephp-sendgrid/issues"
3030
},
3131
"require": {
32-
"php": ">=7.2",
33-
"cakephp/cakephp": "^4.0.0"
32+
"php": ">=8.1",
33+
"cakephp/cakephp": "^5.0.0"
3434
},
3535
"require-dev": {
3636
"phpunit/phpunit": "^8.5 || ^9.3",

src/Controller/AppController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace SendGrid\Controller;
5+
6+
use App\Controller\AppController as BaseController;
7+
8+
class AppController extends BaseController
9+
{
10+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// Docs
6+
//https://docs.sendgrid.com/for-developers/tracking-events/event
7+
//Config
8+
//https://app.sendgrid.com/settings/mail_settings
9+
//Key Verification
10+
//https://docs.sendgrid.com/for-developers/tracking-events/event#security-features
11+
12+
/**
13+
* test
14+
* curl -X POST http://localhost:8765/send-grid/webhooks -H 'Content-Type: application/json' -d '[{"timestamp": 1700762652, "event": "processed", "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0"}]'
15+
*
16+
* security test
17+
* curl -X POST http://localhost:8765/send-grid/webhooks -H 'Content-Type: application/json' -H 'X-Twilio-Email-Event-Webhook-Signature: MFk..........2mg==' -d '[{"timestamp": 1700762652, "event": "processed", "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0"}]'
18+
*
19+
*
20+
*/
21+
22+
23+
namespace SendGrid\Controller;
24+
25+
use SendGrid\Controller\AppController;
26+
27+
use Cake\View\JsonView;
28+
use Cake\Core\Configure;
29+
use Cake\I18n\DateTime;
30+
use Cake\Event\EventInterface;
31+
use Cake\Log\Log;
32+
use SendGrid\Util\EllipticCurve\Ecdsa;
33+
use SendGrid\Util\EllipticCurve\PublicKey;
34+
use SendGrid\Util\EllipticCurve\Signature;
35+
36+
class WebHooksController extends AppController
37+
{
38+
39+
const SIGNATURE = "X-Twilio-Email-Event-Webhook-Signature";
40+
const TIMESTAMP = "X-Twilio-Email-Event-Webhook-Timestamp";
41+
/**
42+
* beforeFilter callback.
43+
*
44+
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
45+
* @return \Cake\Http\Response|null|void
46+
*/
47+
public function beforeFilter(EventInterface $event)
48+
{
49+
// If the authentication plugin is loaded then open up the index action
50+
if (isset($this->Authentication)) {
51+
$this->Authentication->allowUnauthenticated(['index']);
52+
}
53+
}
54+
55+
public function viewClasses(): array
56+
{
57+
return [JsonView::class];
58+
}
59+
60+
61+
public function index()
62+
{
63+
$this->request->allowMethod(['post']);
64+
$this->viewBuilder()->setClassName("Json");
65+
$result = $this->request->getData();
66+
67+
$this->set('result', $result);
68+
$config = Configure::read('sendgridWebhook');
69+
70+
if (isset($config['debug']) && $config['debug'] == 'true') {
71+
Log::debug(json_encode($result));
72+
Log::debug(json_encode($this->request->getHeaders()));
73+
}
74+
75+
if (isset($config['secure']) && $config['secure'] == 'true') {
76+
$this->request->getBody()->rewind();
77+
$payload = $this->request->getBody()->getContents();
78+
// Log::debug($payload);
79+
80+
if (!isset($config['verification_key'])) {
81+
if (isset($config['debug']) && $config['debug'] == 'true') {
82+
Log::debug("Verfication Failed: Webhook Signature verification key not set in app_local.php");
83+
}
84+
$this->set('error', "Invalid Signature");
85+
$this->viewBuilder()->setOption('serialize', "error");
86+
return;
87+
}
88+
89+
$publicKey = PublicKey::fromString($config['verification_key']);
90+
91+
$timestampedPayload = $this->request->getHeaderLine($this::TIMESTAMP) . $payload;
92+
$decodedSignature = Signature::fromBase64($this->request->getHeaderLine($this::SIGNATURE));
93+
94+
if (!Ecdsa::verify($timestampedPayload, $decodedSignature, $publicKey)) {
95+
if (isset($config['debug']) && $config['debug'] == 'true') {
96+
Log::debug("Verfication Failed: Webhook Signature does not verify against the verification key");
97+
}
98+
$this->set('error', "Invalid Signature");
99+
$this->viewBuilder()->setOption('serialize', "error");
100+
return;
101+
}
102+
}
103+
104+
$emailTable = $this->getTableLocator()->get($config['tableClass']);
105+
$count = 0;
106+
foreach ($result as $event) {
107+
$message_id = explode(".", $event['sg_message_id'])[0];
108+
$email_record = $emailTable->find('all')->select([
109+
"id",
110+
$config['uniqueIdField'],
111+
$config['statusField'],
112+
$config['statusMessageField'],
113+
])->where([$config['uniqueIdField'] => $message_id])->first();
114+
if ($email_record) {
115+
$count++;
116+
$email_record->set($config['statusMessageField'], $email_record->get($config['statusMessageField']) . (new DateTime())->format('d/m/Y H:i:s') . ": " . $event['event'] . " " .
117+
(isset($event['response']) ? $event['response'] : "") . " " .
118+
(isset($event['reason']) ? $event['reason'] : "<br>"));
119+
$email_record->set($config['statusField'], $event['event']);
120+
$emailTable->save($email_record);
121+
}
122+
}
123+
if (isset($config['debug']) && $config['debug'] == 'true') {
124+
Log::debug("Updated $count Email records");
125+
}
126+
$this->set('OK', "OK");
127+
$this->viewBuilder()->setOption('serialize', "OK");
128+
}
129+
130+
}

src/Mailer/Exception/SendGridApiException.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22
declare(strict_types=1);
33

4+
use Cake\Core\Exception\Exception;
45
/**
56
* SendGrid Plugin for CakePHP
67
* Copyright (c) SprintCube (https://www.sprintcube.com)
@@ -17,7 +18,7 @@
1718

1819
namespace SendGrid\Mailer\Exception;
1920

20-
use Cake\Core\Exception\Exception;
21+
use Exception;
2122

2223
/**
2324
* SendGrid Api exception

src/Mailer/Transport/SendGridTransport.php

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php
2+
23
declare(strict_types=1);
34

45
/**
@@ -33,9 +34,17 @@ class SendGridTransport extends AbstractTransport
3334
*
3435
* @var array
3536
*/
36-
protected $_defaultConfig = [
37+
protected array $_defaultConfig = [
3738
'apiEndpoint' => 'https://api.sendgrid.com/v3',
3839
'apiKey' => '',
40+
'enableWebhooks' => false, // If you want to use webhooks to monitor delivery ane error reports
41+
'webhookConfig' => [
42+
'domain' => 'example.com', // The domain used for Message IDs does not need to be
43+
'tableName' => 'email_queue', // The table name that stores email data
44+
'uniqueIdField' => 'id', // The field name that stores the unique message ID char(36) uid
45+
'statusField' => 'status', // The field name that stores the status of the email char(50) status
46+
'statusMessageField' => 'status_message', // The field name that stores the status message TEXT status_message
47+
]
3948
];
4049

4150
/**
@@ -110,19 +119,21 @@ public function send(Message $message): array
110119
$this->_reqParams['subject'] = $message->getSubject();
111120

112121
$emailFormat = $message->getEmailFormat();
113-
if (!empty($message->getBodyHtml())) {
114-
$this->_reqParams['content'][] = (object)[
115-
'type' => 'text/html',
116-
'value' => trim($message->getBodyHtml()),
117-
];
118-
}
122+
119123
if ($emailFormat == 'both' || $emailFormat == 'text') {
120124
$this->_reqParams['content'][] = (object)[
121125
'type' => 'text/plain',
122126
'value' => trim($message->getBodyText()),
123127
];
124128
}
125129

130+
if (!empty($message->getBodyHtml())) {
131+
$this->_reqParams['content'][] = (object)[
132+
'type' => 'text/html',
133+
'value' => trim($message->getBodyHtml()),
134+
];
135+
}
136+
126137
$this->_processHeaders($message);
127138

128139
$this->_processAttachments($message);
@@ -178,6 +189,16 @@ protected function _prepareEmailAddresses(Message $message)
178189
$this->_reqParams['from'] = (object)['email' => key($from)];
179190
}
180191

192+
$replyTo = $message->getReplyTo();
193+
if (!empty($replyTo)) {
194+
if (key($replyTo) != $replyTo[key($replyTo)]) {
195+
$this->_reqParams['reply_to'] = (object)['email' => key($replyTo), 'name' => $replyTo[key($replyTo)]];
196+
197+
} else {
198+
$this->_reqParams['reply_to'] = (object)['email' => key($replyTo)];
199+
}
200+
}
201+
181202
$emails = [];
182203
foreach ($message->getTo() as $toEmail => $toName) {
183204
$emails['to'][] = [
@@ -253,6 +274,7 @@ protected function _processAttachments(Message $message)
253274
*/
254275
protected function _sendEmail()
255276
{
277+
256278
$options = [
257279
'type' => 'json',
258280
'headers' => [
@@ -261,12 +283,12 @@ protected function _sendEmail()
261283
],
262284
];
263285

264-
$response = $this->Client
265-
->post("{$this->getConfig('apiEndpoint')}/mail/send", json_encode($this->_reqParams), $options);
286+
$response = $this->Client->post("{$this->getConfig('apiEndpoint')}/mail/send", json_encode($this->_reqParams), $options);
266287

267288
$result = [];
268289
$result['apiResponse'] = $response->getJson();
269290
$result['responseCode'] = $response->getStatusCode();
291+
$result['messageId'] = $response->getHeader('X-Message-Id') ?? '';
270292
$result['status'] = $result['responseCode'] == 202 ? 'OK' : 'ERROR';
271293
if (Configure::read('debug')) {
272294
$result['reqParams'] = $this->_reqParams;

src/Plugin.php

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)