Issues (39)

src/SparkPostController.php (6 issues)

1
<?php
2
3
namespace LeKoala\SparkPost;
4
5
use Exception;
6
use Psr\Log\LoggerInterface;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Core\Environment;
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Control\HTTPRequest;
11
use SilverStripe\Security\Permission;
12
use SilverStripe\Control\HTTPResponse;
13
use LeKoala\SparkPost\Api\SparkPostApiClient;
14
use SilverStripe\ORM\ArrayList;
15
16
/**
17
 * Provide extensions points for handling the webhook
18
 *
19
 * @author LeKoala <[email protected]>
20
 */
21
class SparkPostController extends Controller
22
{
23
    private static $url_segment = '__sparkpost';
0 ignored issues
show
The private property $url_segment is not used, and could be removed.
Loading history...
24
25
    /**
26
     * @var int
27
     */
28
    protected $eventsCount = 0;
29
30
    /**
31
     * @var int
32
     */
33
    protected $skipCount = 0;
34
35
    /**
36
     * @var array<string>
37
     */
38
    private static $allowed_actions = [
0 ignored issues
show
The private property $allowed_actions is not used, and could be removed.
Loading history...
39
        'incoming',
40
        'test',
41
        'configure_inbound_emails',
42
        'sent_emails',
43
    ];
44
45
    /**
46
     * Inject public dependencies into the controller
47
     *
48
     * @var array<string,string>
49
     */
50
    private static $dependencies = [
0 ignored issues
show
The private property $dependencies is not used, and could be removed.
Loading history...
51
        'logger' => '%$Psr\Log\LoggerInterface',
52
    ];
53
54
    /**
55
     * @var \Psr\Log\LoggerInterface
56
     */
57
    public $logger;
58
59
    /**
60
     * @param HTTPRequest $req
61
     * @return HTTPResponse|string
62
     */
63
    public function index(HTTPRequest $req)
0 ignored issues
show
The parameter $req is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

63
    public function index(/** @scrutinizer ignore-unused */ HTTPRequest $req)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
64
    {
65
        return $this->render([
66
            'Title' => 'SparkPost',
67
            'Content' => 'Please use a dedicated action'
68
        ]);
69
    }
70
71
    public function sent_emails(HTTPRequest $req)
72
    {
73
        if (!Director::isDev() && !Permission::check('ADMIN')) {
74
            return $this->httpError(404);
75
        }
76
77
        $logFolder = SparkPostHelper::getLogFolder();
78
        $view = $req->getVar('view');
79
        $download = $req->getVar('download');
80
        $iframe = $req->getVar('iframe');
81
        $base = Director::baseFolder();
82
83
        if ($download) {
84
            $file = $logFolder . '/' . $download;
85
            if (!is_file($file) || dirname($file) != $logFolder) {
86
                return $this->httpError(404);
87
            }
88
89
            $fileData = file_get_contents($file);
90
            $fileName = $download;
91
            return HTTPRequest::send_file($fileData, $fileName);
92
        }
93
94
        if ($iframe) {
95
            $file = $logFolder . '/' . $view;
96
            if (!is_file($file) || dirname($file) != $logFolder) {
97
                return $this->httpError(404);
98
            }
99
            $content = file_get_contents($file);
100
            return $content;
101
        }
102
103
        if ($view) {
104
            $file = $logFolder . '/' . $view;
105
            if (!is_file($file) || dirname($file) != $logFolder) {
106
                return $this->httpError(404);
107
            }
108
109
            $content = file_get_contents($file);
110
            $content = str_replace($logFolder . '/', '/__sparkpost/sent_emails?download=', $content);
111
            $content = str_replace($base, '', $content);
112
113
            $customFields = [
114
                'Email' => $content,
115
                'Name' => $view,
116
            ];
117
        } else {
118
            $emails = new ArrayList();
0 ignored issues
show
Deprecated Code introduced by
The class SilverStripe\ORM\ArrayList has been deprecated: 5.4.0 Will be renamed to SilverStripe\Model\List\ArrayList ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

118
            $emails = /** @scrutinizer ignore-deprecated */ new ArrayList();
Loading history...
119
            $items = glob("$logFolder/*.html", GLOB_NOSORT);
120
            usort($items, function ($a, $b) {
121
                return filemtime($b) - filemtime($a);
122
            });
123
124
            foreach ($items as $email) {
125
                $emails->push([
126
                    'File' => $email,
127
                    'Name' => basename($email),
128
                    'Date' => date('Y-m-d H:i:s', filemtime($email)),
129
                ]);
130
            }
131
132
            $customFields = [
133
                'Emails' => $emails
134
            ];
135
        }
136
137
        return $this->renderWith('LeKoala/SparkPost/SparkPostController_sent_emails', $customFields);
138
    }
139
140
    /**
141
     * You can also see /resources/sample.json
142
     *
143
     * @param HTTPRequest $req
144
     * @return string
145
     */
146
    public function test(HTTPRequest $req)
147
    {
148
        if (!Director::isDev()) {
149
            return $this->httpError(404);
150
        }
151
152
        $file = $this->getRequest()->getVar('file');
153
        if ($file) {
154
            $data = file_get_contents(Director::baseFolder() . '/' . rtrim($file, '/'));
155
        } else {
156
            $data = file_get_contents(dirname(__DIR__) . '/resources/sample.json');
157
        }
158
        if (!$data) {
159
            throw new Exception("Failed to get data");
160
        }
161
        $payload = json_decode($data, true);
162
        $payload['@headers'] = $req->getHeaders();
163
164
        $this->processPayload($payload, 'TEST');
165
166
        return 'TEST OK - ' . $this->eventsCount . ' events processed / ' . $this->skipCount . ' events skipped';
167
    }
168
169
    /**
170
     * @link https://support.sparkpost.com/customer/portal/articles/2039614-enabling-inbound-email-relaying-relay-webhooks
171
     * @param HTTPRequest $req
172
     * @return string
173
     */
174
    public function configure_inbound_emails(HTTPRequest $req)
175
    {
176
        if (!Director::isDev() && !Permission::check('ADMIN')) {
177
            return 'You must be in dev mode or be logged as an admin';
178
        }
179
180
        $clearExisting = $req->getVar('clear_existing');
181
        $clearWebhooks = $req->getVar('clear_webhooks');
182
        $clearInbound = $req->getVar('clear_inbound');
183
        if ($clearExisting) {
184
            echo '<strong>Existing inbounddomains and relay webhooks will be cleared</strong><br/>';
185
        } else {
186
            echo 'You can clear existing inbound domains and relay webhooks by passing ?clear_existing=1&clear_webhooks=1&clear_inbound=1<br/>';
187
        }
188
189
        $client = SparkPostHelper::getMasterClient();
190
191
        $inbound_domain = Environment::getEnv('SPARKPOST_INBOUND_DOMAIN');
192
        if (!$inbound_domain) {
193
            die('You must define a key SPARKPOST_INBOUND_DOMAIN');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
194
        }
195
196
        /*
197
         *  "name": "Replies Webhook",
198
         *  "target": "https://webhooks.customer.example/replies",
199
         * "auth_token": "5ebe2294ecd0e0f08eab7690d2a6ee69",
200
         *  "match": {
201
         *  "protocol": "SMTP",
202
         * "domain": "email.example.com"
203
         */
204
205
        $listWebhooks = $client->listRelayWebhooks();
206
        $listInboundDomains = $client->listInboundDomains();
207
208
        if ($clearExisting) {
209
            // we need to delete relay webhooks first!
210
            if ($clearWebhooks) {
211
                foreach ($listWebhooks as $wh) {
212
                    $client->deleteRelayWebhook($wh['id']);
213
                    echo 'Delete relay webhook ' . $wh['id'] . '<br/>';
214
                }
215
            }
216
            if ($clearInbound) {
217
                foreach ($listInboundDomains as $id) {
218
                    $client->deleteInboundDomain($id['domain']);
219
                    echo 'Delete domain ' . $id['domain'] . '<br/>';
220
                }
221
            }
222
223
            $listWebhooks = $client->listRelayWebhooks();
224
            $listInboundDomains = $client->listInboundDomains();
225
        }
226
227
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
228
        echo 'List Inbounds Domains:<br/>';
229
        print_r($listInboundDomains);
230
        echo '</pre>';
231
232
        $found = false;
233
234
        foreach ($listInboundDomains as $id) {
235
            if ($id['domain'] == $inbound_domain) {
236
                $found = true;
237
            }
238
        }
239
240
        if (!$found) {
241
            echo "Domain is not found, we create it<br/>";
242
243
            // This is the domain that users will send email to.
244
            $result = $client->createInboundDomain($inbound_domain);
245
246
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
247
            echo 'Create Inbound Domain:<br/>';
248
            print_r($result);
249
            echo '</pre>';
250
        } else {
251
            echo "Domain is already configured<br/>";
252
        }
253
254
        // Now that you have your InboundDomain set up, you can create your Relay Webhook by sending a POST request to
255
        // https://api.sparkpost.com/api/v1/relay-webhooks. This step links your consumer with the Inbound Domain.
256
257
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
258
        echo 'List Webhooks:<br/>';
259
        print_r($listWebhooks);
260
        echo '</pre>';
261
262
        $found = false;
263
264
        foreach ($listWebhooks as $wh) {
265
            if ($wh['match']['domain'] == $inbound_domain) {
266
                $found = true;
267
            }
268
        }
269
270
        if (!$found) {
271
            //  The match.domain property should be the same as the Inbound Domain you set up in the previous step
272
            $webhookResult = $client->createRelayWebhook([
273
                'name' => 'Inbound Webhook',
274
                'target' => Director::absoluteURL('sparkpost/incoming'),
275
                'match' => [
276
                    'domain' => $inbound_domain
277
                ]
278
            ]);
279
280
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
281
            echo 'Webhook result:<br/>';
282
            print_r($webhookResult);
283
            echo '</pre>';
284
285
            if ($webhookResult['id']) {
286
                echo "New webhook created with id " . $webhookResult['id'];
287
            }
288
        } else {
289
            echo "Webhook already configured";
290
        }
291
        return '';
292
    }
293
294
    /**
295
     * Handle incoming webhook
296
     *
297
     * @link https://developers.sparkpost.com/api/#/reference/webhooks/create-a-webhook
298
     * @link https://www.sparkpost.com/blog/webhooks-beyond-the-basics/
299
     * @link https://support.sparkpost.com/customer/portal/articles/1976204-webhook-event-reference
300
     * @param HTTPRequest $req
301
     * @return HTTPResponse
302
     */
303
    public function incoming(HTTPRequest $req)
304
    {
305
        // Each webhook batch contains the header X-Messagesystems-Batch-Id,
306
        // which is useful for auditing and prevention of processing duplicate batches.
307
        $batchId = $req->getHeader('X-Messagesystems-Batch-Id');
308
        if (!$batchId) {
309
            $batchId = uniqid();
310
        }
311
312
        $json = file_get_contents('php://input');
313
314
        // By default, return a valid response
315
        $response = $this->getResponse();
316
        $response->setStatusCode(200);
317
        $response->setBody('NO DATA');
318
319
        if (!$json) {
320
            return $response;
321
        }
322
323
        $payload = json_decode($json, true);
324
325
        // Check credentials if defined
326
        $isAuthenticated = true;
327
        $authError = null;
328
        if (SparkPostHelper::getWebhookUsername()) {
329
            try {
330
                $this->authRequest($req);
331
            } catch (Exception $e) {
332
                $isAuthenticated = false;
333
                $authError = $e->getMessage();
334
            }
335
        }
336
337
        $webhookLogDir = Environment::getEnv('SPARKPOST_WEBHOOK_LOG_DIR');
338
        if ($webhookLogDir) {
339
            $dir = rtrim(Director::baseFolder(), '/') . '/' . rtrim($webhookLogDir, '/');
340
341
            if (!is_dir($dir) && Director::isDev()) {
342
                mkdir($dir, 0755, true);
343
            }
344
345
            if (is_dir($dir)) {
346
                $storedPayload = array_merge([], $payload);
347
                $storedPayload['@headers'] = $req->getHeaders();
348
                $storedPayload['@isAuthenticated'] = $isAuthenticated;
349
                $storedPayload['@authError'] = $authError;
350
                $prettyPayload = json_encode($storedPayload, JSON_PRETTY_PRINT);
351
                $time = date('Ymd-His');
352
                file_put_contents($dir . '/' . $time . '_' . $batchId . '.json', $prettyPayload);
353
            } else {
354
                $this->getLogger()->debug("Directory $dir does not exist");
355
            }
356
        }
357
358
        if (!$isAuthenticated) {
359
            return $response;
360
        }
361
362
        try {
363
            $this->processPayload($payload, $batchId);
364
        } catch (Exception $ex) {
365
            // Maybe processing payload will create exceptions, but we
366
            // catch them to send a proper response to the API
367
            $logLevel = self::config()->log_level ? self::config()->log_level : 7;
368
            $this->getLogger()->log($ex->getMessage(), $logLevel);
369
        }
370
371
        $response->setBody('OK');
372
        return $response;
373
    }
374
375
    /**
376
     * @param HTTPRequest $req
377
     * @return void
378
     */
379
    protected function authRequest(HTTPRequest $req)
380
    {
381
        $requestUser = $req->getHeader('php_auth_user');
382
        $requestPassword = $req->getHeader('php_auth_pw');
383
        if (!$requestUser) {
384
            $requestUser = $_SERVER['PHP_AUTH_USER'] ?? null;
385
        }
386
        if (!$requestPassword) {
387
            $requestPassword = $_SERVER['PHP_AUTH_PW'] ?? null;
388
        }
389
390
        $authError = null;
391
        $hasSuppliedCredentials = $requestUser && $requestPassword;
392
        if ($hasSuppliedCredentials) {
393
            $user = SparkPostHelper::getWebhookUsername();
394
            $password = SparkPostHelper::getWebhookPassword();
395
            if ($user != $requestUser) {
396
                $authError = "User $requestUser doesn't match";
397
            } elseif ($password != $requestPassword) {
398
                $authError = "Password $requestPassword don't match";
399
            }
400
        } else {
401
            $authError = "No credentials";
402
        }
403
        if ($authError) {
404
            throw new Exception($authError);
405
        }
406
    }
407
408
    /**
409
     * Process data
410
     *
411
     * @param array<mixed> $payload
412
     * @param string $batchId
413
     * @return void
414
     */
415
    protected function processPayload(array $payload, $batchId = null)
416
    {
417
        $this->extend('beforeProcessPayload', $payload, $batchId);
418
419
        $subaccount = SparkPostHelper::getClient()->getSubaccount();
420
421
        foreach ($payload as $idx => $r) {
422
            // This is a test payload
423
            if (empty($r)) {
424
                continue;
425
            }
426
            // This is a custom entry
427
            if (!is_numeric($idx)) {
428
                continue;
429
            }
430
431
            $ev = $r['msys'] ?? null;
432
433
            // Invalid payload: it should always be an object with a msys key containing the event
434
            if ($ev === null) {
435
                $this->getLogger()->warning("Invalid payload: " . substr((string)json_encode($r), 0, 100) . '...');
436
                continue;
437
            }
438
439
            // Check type: it should be an object with the type as key
440
            $type = key($ev);
441
            if (!isset($ev[$type])) {
442
                $this->getLogger()->warning("Invalid type $type in SparkPost payload");
443
                continue;
444
            }
445
            $data = $ev[$type];
446
447
            // Ignore events not related to the subaccount we are managing
448
            if (!empty($data['subaccount_id']) && $subaccount && $subaccount != $data['subaccount_id']) {
449
                $this->skipCount++;
450
                continue;
451
            }
452
453
            $this->eventsCount++;
454
            $this->extend('onAnyEvent', $data, $type);
455
456
            switch ($type) {
457
                //Click, Open
458
                case SparkPostApiClient::TYPE_ENGAGEMENT:
459
                    $this->extend('onEngagementEvent', $data, $type);
460
                    break;
461
                //Generation Failure, Generation Rejection
462
                case SparkPostApiClient::TYPE_GENERATION:
463
                    $this->extend('onGenerationEvent', $data, $type);
464
                    break;
465
                //Bounce, Delivery, Injection, SMS Status, Spam Complaint, Out of Band, Policy Rejection, Delay
466
                case SparkPostApiClient::TYPE_MESSAGE:
467
                    $this->extend('onMessageEvent', $data, $type);
468
                    break;
469
                //Relay Injection, Relay Rejection, Relay Delivery, Relay Temporary Failure, Relay Permanent Failure
470
                case SparkPostApiClient::TYPE_RELAY:
471
                    $this->extend('onRelayEvent', $data, $type);
472
                    break;
473
                //List Unsubscribe, Link Unsubscribe
474
                case SparkPostApiClient::TYPE_UNSUBSCRIBE:
475
                    $this->extend('onUnsubscribeEvent', $data, $type);
476
                    break;
477
            }
478
        }
479
480
        $this->extend('afterProcessPayload', $payload, $batchId);
481
    }
482
483
484
    /**
485
     * Get logger
486
     *
487
     * @return \Psr\Log\LoggerInterface
488
     */
489
    public function getLogger()
490
    {
491
        return $this->logger;
492
    }
493
}
494