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
introduced
by
![]() |
|||||
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
|
|||||
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
|
|||||
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
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||
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
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
![]() |
|||||
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
|
|||||
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 |