Passed
Push — master ( ff48e5...8d8d50 )
by Thomas
02:43
created

SparkPostAdmin::DomainTab()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 46
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 6
eloc 30
c 1
b 1
f 0
nc 4
nop 0
dl 0
loc 46
rs 8.8177
1
<?php
2
3
namespace LeKoala\SparkPost;
4
5
use Exception;
6
use SilverStripe\Forms\Tab;
7
use SilverStripe\Forms\Form;
8
use SilverStripe\Forms\TabSet;
9
use SilverStripe\ORM\ArrayLib;
10
use SilverStripe\ORM\ArrayList;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\View\ArrayData;
13
use SilverStripe\Control\Session;
14
use SilverStripe\Forms\DateField;
15
use SilverStripe\Forms\FieldList;
16
use SilverStripe\Forms\FormField;
17
use SilverStripe\Forms\TextField;
18
use SilverStripe\Control\Director;
19
use SilverStripe\Core\Environment;
20
use SilverStripe\Forms\FormAction;
21
use SilverStripe\Admin\LeftAndMain;
22
use SilverStripe\Forms\HiddenField;
23
use SilverStripe\Security\Security;
24
use SilverStripe\View\ViewableData;
25
use SilverStripe\Forms\LiteralField;
26
use SilverStripe\Control\Email\Email;
27
use SilverStripe\Control\HTTPRequest;
28
use SilverStripe\Forms\DropdownField;
29
use SilverStripe\Security\Permission;
30
use LeKoala\SparkPost\SparkPostHelper;
31
use SilverStripe\Control\HTTPResponse;
32
use SilverStripe\Core\Convert;
33
use SilverStripe\Forms\CompositeField;
34
use SilverStripe\SiteConfig\SiteConfig;
35
use SilverStripe\Forms\GridField\GridField;
36
use SilverStripe\Security\PermissionProvider;
37
use SilverStripe\Security\DefaultAdminService;
38
use SilverStripe\Forms\GridField\GridFieldConfig;
39
use SilverStripe\Forms\GridField\GridFieldConfig_RecordViewer;
40
use SilverStripe\Forms\GridField\GridFieldFooter;
41
use SilverStripe\Forms\GridField\GridFieldDetailForm;
42
use SilverStripe\Forms\GridField\GridFieldDataColumns;
43
use Symbiote\GridFieldExtensions\GridFieldTitleHeader;
44
use SilverStripe\Forms\GridField\GridFieldToolbarHeader;
45
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
46
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
47
48
/**
49
 * Allow you to see messages sent through the api key used to send messages
50
 *
51
 * @author LeKoala <[email protected]>
52
 */
53
class SparkPostAdmin extends LeftAndMain implements PermissionProvider
54
{
55
    const MESSAGE_CACHE_MINUTES = 5;
56
    const WEBHOOK_CACHE_MINUTES = 1440; // 1 day
57
    const SENDINGDOMAIN_CACHE_MINUTES = 1440; // 1 day
58
59
    /**
60
     * @var string
61
     */
62
    private static $menu_title = "SparkPost";
0 ignored issues
show
introduced by
The private property $menu_title is not used, and could be removed.
Loading history...
63
64
    /**
65
     * @var string
66
     */
67
    private static $url_segment = "sparkpost";
0 ignored issues
show
introduced by
The private property $url_segment is not used, and could be removed.
Loading history...
68
69
    /**
70
     * @var string
71
     */
72
    private static $menu_icon = "sparkpost/images/sparkpost-icon.png";
0 ignored issues
show
introduced by
The private property $menu_icon is not used, and could be removed.
Loading history...
73
74
    /**
75
     * @var string
76
     */
77
    private static $url_rule = '/$Action/$ID/$OtherID';
0 ignored issues
show
introduced by
The private property $url_rule is not used, and could be removed.
Loading history...
78
79
    /**
80
     * @var array<string>
81
     */
82
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
83
        'settings',
84
        'SearchForm',
85
        'doSearch',
86
        "doInstallHook",
87
        "doUninstallHook",
88
        "doInstallDomain",
89
        "doUninstallDomain",
90
        "send_test",
91
    ];
92
93
    /**
94
     * @var boolean
95
     */
96
    private static $cache_enabled = true;
97
98
    /**
99
     * @var bool
100
     */
101
    protected $subaccountKey = false;
102
103
    /**
104
     * @var Exception|null
105
     */
106
    protected $lastException = null;
107
108
    /**
109
     * @var ViewableData|null
110
     */
111
    protected $currentMessage = null;
112
113
    /**
114
     * Inject public dependencies into the controller
115
     *
116
     * @var array<string,string>
117
     */
118
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
119
        'logger' => '%$Psr\Log\LoggerInterface',
120
        'cache' => '%$Psr\SimpleCache\CacheInterface.sparkpost', // see _config/cache.yml
121
    ];
122
123
    /**
124
     * @var \Psr\Log\LoggerInterface
125
     */
126
    public $logger;
127
128
    /**
129
     * @var \Psr\SimpleCache\CacheInterface
130
     */
131
    public $cache;
132
133
    /**
134
     * @return void
135
     */
136
    public function init()
137
    {
138
        parent::init();
139
140
        if (isset($_GET['refresh'])) {
141
            $this->getCache()->clear();
142
        }
143
    }
144
145
    /**
146
     * @param HTTPRequest $request
147
     * @return HTTPResponse
148
     */
149
    public function settings($request)
150
    {
151
        return parent::index($request);
152
    }
153
154
    /**
155
     * @param HTTPRequest $request
156
     * @return HTTPResponse|string
157
     */
158
    public function send_test($request)
159
    {
160
        if (!$this->CanConfigureApi()) {
161
            return $this->httpError(404);
162
        }
163
        $service = DefaultAdminService::create();
164
        $to = $request->getVar('to');
165
        if (!$to) {
166
            $to = $service->findOrCreateDefaultAdmin()->Email;
167
        }
168
        $email = Email::create();
169
        $email->setSubject("Test email");
170
        $email->setBody("Test " . date('Y-m-d H:i:s'));
171
        $email->setTo($to);
172
173
        $email->send();
174
        return 'Email sent';
175
    }
176
177
    /**
178
     * @return Session
179
     */
180
    public function getSession()
181
    {
182
        return $this->getRequest()->getSession();
183
    }
184
185
    /**
186
     * Returns a GridField of messages
187
     * @param mixed $id
188
     * @param mixed $fields
189
     * @return null|Form
190
     */
191
    public function getEditForm($id = null, $fields = null)
192
    {
193
        if (!$id) {
194
            $id = $this->currentPageID();
195
        }
196
197
        /** @var DataObject|null $record */
198
        $record = $this->getRecord($id);
199
200
        // Check if this record is viewable
201
        if ($record && !$record->canView()) {
202
            $response = Security::permissionFailure($this);
203
            $this->setResponse($response);
204
            return null;
205
        }
206
207
        // Build gridfield
208
        $messageListConfig = GridFieldConfig::create()->addComponents(
209
            new GridFieldSortableHeader(),
210
            new GridFieldDataColumns(),
211
            new GridFieldFooter()
212
        );
213
214
        $messages = $this->Messages();
215
        if (is_string($messages)) {
216
            // The api returned an error
217
            $messagesList = new LiteralField("MessageAlert", $this->MessageHelper($messages, 'bad'));
218
        } else {
219
            $messagesList = GridField::create(
220
                'Messages',
221
                false,
222
                $messages,
223
                $messageListConfig
224
            )->addExtraClass("messages_grid");
225
226
            /** @var GridFieldDataColumns $columns  */
227
            $columns = $messageListConfig->getComponentByType(GridFieldDataColumns::class);
228
            $columns->setDisplayFields([
229
                'transmission_id' => _t('SparkPostAdmin.EventTransmissionId', 'Id'),
230
                'timestamp' => _t('SparkPostAdmin.EventDate', 'Date'),
231
                'type' => _t('SparkPostAdmin.EventType', 'Type'),
232
                'rcpt_to' => _t('SparkPostAdmin.EventRecipient', 'Recipient'),
233
                'subject' => _t('SparkPostAdmin.EventSubject', 'Subject'),
234
                'friendly_from' => _t('SparkPostAdmin.EventSender', 'Sender'),
235
            ]);
236
237
            $columns->setFieldFormatting([
238
                'timestamp' => function ($value, &$item) {
239
                    return date('Y-m-d H:i:s', strtotime($value));
240
                },
241
            ]);
242
243
            // Validator setup
244
            $validator = null;
245
            if ($record && method_exists($record, 'getValidator')) {
246
                $validator = $record->getValidator();
247
            }
248
249
            if ($validator) {
250
                /** @var GridFieldDetailForm|null $detailForm  */
251
                $detailForm = $messageListConfig->getComponentByType(GridFieldDetailForm::class);
252
                if ($detailForm) {
253
                    $detailForm->setValidator($validator);
254
                }
255
            }
256
        }
257
258
        // Create tabs
259
        $messagesTab = new Tab(
260
            'Messages',
261
            _t('SparkPostAdmin.Messages', 'Messages'),
262
            $this->SearchFields(),
263
            $messagesList,
264
            // necessary for tree node selection in LeftAndMain.EditForm.js
265
            new HiddenField('ID', null, 0)
266
        );
267
268
        $fields = new FieldList([
269
            $root = new TabSet('Root', $messagesTab)
270
        ]);
271
272
        $suppressionsTab = new Tab('Suppressions', _t('SparkPostAdmin.Suppressions', 'Suppressions'));
273
274
        // Show the summary
275
        $summary = $this->getCachedData('suppressionSummary');
276
        if ($summary) {
277
            $callback = function ($k, $v) {
278
                return ArrayData::create([
279
                    'Title' => $k,
280
                    'Value' => $v,
281
                ]);
282
            };
283
            $summaryArray = array_map($callback, array_keys($summary), array_values($summary));
284
            $summaryGrid = GridField::create(
285
                'SuppressionSummary',
286
                _t('SparkPostAdmin.Suppressionsummary', 'Suppression summary'),
287
                new ArrayList($summaryArray)
288
            );
289
            //@link https://docs.silverstripe.org/en/5/developer_guides/forms/using_gridfield_with_arbitrary_data/
290
            $summaryGrid->getConfig()->removeComponentsByType(GridFieldFilterHeader::class);
291
            /** @var GridFieldDataColumns $columns */
292
            $columns = $summaryGrid->getConfig()->getComponentByType(GridFieldDataColumns::class);
293
            $columns->setDisplayFields([
294
                'Title' => _t('SparkPostAdmin.Title', 'Title'),
295
                'Value' => _t('SparkPostAdmin.Value', 'Value'),
296
            ]);
297
            $suppressionsTab->push($summaryGrid);
298
        }
299
300
301
        // Show recent suppressions
302
        $client = SparkPostHelper::getClient();
303
        $suppressions = $this->getCachedData('searchSuppressions', [
304
            'from' => $client->createValidDatetime('-90 days')
305
        ]);
306
        if ($suppressions) {
307
            $suppressionGrid = GridField::create(
308
                'RecentSuppressions',
309
                _t('SparkPostAdmin.RecentSuppressions', 'Recent suppressions'),
310
                new ArrayList(array_map(function ($item) {
311
                    return ArrayData::create($item);
312
                }, $suppressions))
313
            );
314
            //@link https://docs.silverstripe.org/en/5/developer_guides/forms/using_gridfield_with_arbitrary_data/
315
            $suppressionGrid->getConfig()->removeComponentsByType(GridFieldFilterHeader::class);
316
            /** @var GridFieldDataColumns $columns */
317
            $columns = $suppressionGrid->getConfig()->getComponentByType(GridFieldDataColumns::class);
318
            $columns->setDisplayFields([
319
                'recipient' => _t('SparkPostAdmin.Recipient', 'Recipient'),
320
                'type' => _t('SparkPostAdmin.Type', 'Type'),
321
                'source' => _t('SparkPostAdmin.Source', 'Source'),
322
                'description' => _t('SparkPostAdmin.Description', 'Description'),
323
                'created' => _t('SparkPostAdmin.Created', 'Created'),
324
                // 'updated' => _t('SparkPostAdmin.Updated', 'Updated'),
325
            ]);
326
            $suppressionsTab->push($suppressionGrid);
327
        }
328
329
        $fields->addFieldToTab('Root', $suppressionsTab);
330
331
        $settingsTab = new Tab('Settings', _t('SparkPostAdmin.Settings', 'Settings'));
332
        if ($this->CanConfigureApi()) {
333
            $domainTabData = $this->DomainTab();
334
            $settingsTab->push($domainTabData);
335
336
            // Show webhook options if not using a subaccount key
337
            if (!SparkPostHelper::getSubaccountId() && self::config()->show_webhook_tab) {
338
                $webhookTabData = $this->WebhookTab();
339
                $settingsTab->push($webhookTabData);
340
            }
341
342
            $toolsHtml = '<h2>Tools</h2>';
343
344
            // Show default from email
345
            $defaultEmail = SparkPostHelper::resolveDefaultFromEmail();
346
            $defaultEmailDisplayed = EmailUtils::stringify($defaultEmail);
347
            $toolsHtml .= "<p>Default sending email: " . Convert::raw2xml($defaultEmailDisplayed) . " (" . SparkPostHelper::resolveDefaultFromEmailType() . ")</p>";
348
            if (!SparkPostHelper::isEmailDomainReady($defaultEmailDisplayed)) {
349
                $toolsHtml .= '<p style="color:red">The default email is not ready to send emails</p>';
350
            }
351
352
            // Show constants
353
            if (SparkPostHelper::getEnvSendingDisabled()) {
354
                $toolsHtml .= '<p style="color:red">Sending is disabled by .env configuration</p>';
355
            }
356
            if (SparkPostHelper::getEnvEnableLogging()) {
357
                $toolsHtml .= '<p style="color:orange">Logging is enabled by .env configuration</p>';
358
            }
359
            if (SparkPostHelper::getSubaccountId()) {
360
                $toolsHtml .= '<p style="color:orange">Using subaccount id</p>';
361
            }
362
            if (SparkPostHelper::getEnvForceSender()) {
363
                $toolsHtml .= '<p style="color:orange">Sender is forced to ' . SparkPostHelper::getEnvForceSender() . '</p>';
364
            }
365
366
            // Add a refresh button
367
            $toolsHtml .= $this->ButtonHelper(
368
                $this->Link() . '?refresh=true',
369
                _t('SparkPostAdmin.REFRESH', 'Force data refresh from the API')
370
            );
371
372
            $toolsHtml = $this->FormGroupHelper($toolsHtml);
373
            $Tools = new LiteralField('Tools', $toolsHtml);
374
            $settingsTab->push($Tools);
375
376
            $fields->addFieldToTab('Root', $settingsTab);
377
        }
378
379
        // Tab nav in CMS is rendered through separate template
380
        $root->setTemplate('SilverStripe\\Forms\\CMSTabSet');
381
382
        // Manage tabs state
383
        $actionParam = $this->getRequest()->param('Action');
384
        if ($actionParam == 'setting') {
385
            $settingsTab->addExtraClass('ui-state-active');
386
        } elseif ($actionParam == 'messages') {
387
            $messagesTab->addExtraClass('ui-state-active');
388
        }
389
390
        // Build replacement form
391
        $form = Form::create(
392
            $this,
393
            'EditForm',
394
            $fields,
395
            new FieldList()
396
        )->setHTMLID('Form_EditForm');
397
        $form->addExtraClass('cms-edit-form fill-height');
398
        $form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
399
        $form->addExtraClass('ss-tabset cms-tabset ' . $this->BaseCSSClasses());
400
        $form->setAttribute('data-pjax-fragment', 'CurrentForm');
401
402
        $this->extend('updateEditForm', $form);
403
404
        return $form;
405
    }
406
407
    /**
408
     * Get logger
409
     *
410
     * @return \Psr\Log\LoggerInterface
411
     */
412
    public function getLogger()
413
    {
414
        return $this->logger;
415
    }
416
417
    /**
418
     * Get the cache
419
     *
420
     * @return \Psr\SimpleCache\CacheInterface
421
     */
422
    public function getCache()
423
    {
424
        return $this->cache;
425
    }
426
427
    /**
428
     * @return boolean
429
     */
430
    public function getCacheEnabled()
431
    {
432
        if (isset($_GET['disable_cache'])) {
433
            return false;
434
        }
435
        if (Environment::getEnv('SPARKPOST_DISABLE_CACHE')) {
436
            return false;
437
        }
438
        $v = $this->config()->cache_enabled;
439
        if ($v === null) {
440
            $v = self::$cache_enabled;
441
        }
442
        return $v;
443
    }
444
445
    /**
446
     * A simple cache helper
447
     *
448
     * @param string $method
449
     * @param array<mixed>|null|string|bool $params
450
     * @param int $expireInSeconds
451
     * @return array<mixed>|false
452
     */
453
    protected function getCachedData($method, $params = null, $expireInSeconds = 60)
454
    {
455
        $enabled = $this->getCacheEnabled();
456
        $cacheResult = false;
457
        $cache = $this->getCache();
458
        if ($enabled) {
459
            $key = $method . '_' . md5(serialize($params));
460
            $cacheResult = $cache->get($key);
461
        }
462
        if ($enabled && $cacheResult) {
463
            $data = unserialize($cacheResult);
464
        } else {
465
            try {
466
                $client = SparkPostHelper::getClient();
467
                $data = $client->$method($params);
468
            } catch (Exception $ex) {
469
                $this->lastException = $ex;
470
                $this->getLogger()->debug($ex);
471
                $data = false;
472
            }
473
474
            //5 minutes cache
475
            if ($enabled) {
476
                $cache->set($key, serialize($data), $expireInSeconds);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $key does not seem to be defined for all execution paths leading up to this point.
Loading history...
477
            }
478
        }
479
480
        return $data;
481
    }
482
483
    /**
484
     * @return array<mixed>
485
     */
486
    public function getParams()
487
    {
488
        $params = $this->config()->default_search_params;
489
        if (!$params) {
490
            $params = [];
491
        }
492
        $data = $this->getSession()->get(__class__ . '.Search');
493
        if (!$data) {
494
            $data = [];
495
        }
496
497
        $params = array_merge($params, $data);
498
499
        // Respect api formats
500
        if (!empty($params['to'])) {
501
            //@phpstan-ignore-next-line
502
            $params['to'] = date('Y-m-d', strtotime(str_replace('/', '-', $params['to']))) . 'T00:00';
503
        }
504
        if (!empty($params['from'])) {
505
            //@phpstan-ignore-next-line
506
            $params['from'] = date('Y-m-d', strtotime(str_replace('/', '-', $params['from']))) . 'T23:59';
507
        }
508
509
        $params = array_filter($params);
510
511
        return $params;
512
    }
513
514
    /**
515
     * @param string $name
516
     * @param mixed $default
517
     * @return mixed
518
     */
519
    public function getParam($name, $default = null)
520
    {
521
        $data = $this->getSession()->get(__class__ . '.Search');
522
        if (!$data) {
523
            return $default;
524
        }
525
        return (isset($data[$name]) && strlen($data[$name])) ? $data[$name] : $default;
526
    }
527
528
    /**
529
     * @return CompositeField
530
     */
531
    public function SearchFields()
532
    {
533
        $disabled_filters = $this->config()->disabled_search_filters;
534
        if (!$disabled_filters) {
535
            $disabled_filters = [];
536
        }
537
538
        $fields = new CompositeField();
539
        $fields->push($from = new DateField('params[from]', _t('SparkPostAdmin.DATEFROM', 'From'), $this->getParam('from')));
540
        // $from->setConfig('min', date('Y-m-d', strtotime('-10 days')));
541
542
        $fields->push(new DateField('params[to]', _t('SparkPostAdmin.DATETO', 'To'), $to = $this->getParam('to')));
543
544
        if (!in_array('friendly_froms', $disabled_filters)) {
545
            $fields->push($friendly_froms = new TextField('params[friendly_froms]', _t('SparkPostAdmin.FRIENDLYFROM', 'Sender'), $this->getParam('friendly_froms')));
546
            $friendly_froms->setAttribute('placeholder', '[email protected],[email protected]');
547
        }
548
549
        if (!in_array('recipients', $disabled_filters)) {
550
            $fields->push($recipients = new TextField('params[recipients]', _t('SparkPostAdmin.RECIPIENTS', 'Recipients'), $this->getParam('recipients')));
551
            $recipients->setAttribute('placeholder', '[email protected],[email protected]');
552
        }
553
554
        // Only allow filtering by subaccount if a master key is defined
555
        if (SparkPostHelper::config()->master_api_key && !in_array('subaccounts', $disabled_filters)) {
556
            $fields->push($subaccounts = new TextField('params[subaccounts]', _t('SparkPostAdmin.SUBACCOUNTS', 'Subaccounts'), $this->getParam('subaccounts')));
557
            $subaccounts->setAttribute('placeholder', '101,102');
558
        }
559
560
        $fields->push(new DropdownField('params[per_page]', _t('SparkPostAdmin.PERPAGE', 'Number of results'), array(
561
            100 => 100,
562
            500 => 500,
563
            1000 => 1000,
564
            10000 => 10000,
565
        ), $this->getParam('per_page', 100)));
566
567
        foreach ($fields->FieldList() as $field) {
568
            $field->addExtraClass('no-change-track');
569
        }
570
571
        // This is a ugly hack to allow embedding a form into another form
572
        $fields->push($doSearch = new FormAction('doSearch', _t('SparkPostAdmin.DOSEARCH', 'Search')));
573
        $doSearch->addExtraClass("btn-primary");
574
        $doSearch->setAttribute('onclick', "jQuery('#Form_SearchForm').append(jQuery('#Form_EditForm input,#Form_EditForm select').clone()).submit();");
575
576
        return $fields;
577
    }
578
579
    /**
580
     * @return Form
581
     */
582
    public function SearchForm()
583
    {
584
        $SearchForm = new Form($this, 'SearchForm', new FieldList(), new FieldList([
585
            new FormAction('doSearch')
586
        ]));
587
        $SearchForm->setAttribute('style', 'display:none');
588
        return $SearchForm;
589
    }
590
591
    /**
592
     * @param array<mixed> $data
593
     * @param Form $form
594
     * @return HTTPResponse
595
     */
596
    public function doSearch($data, Form $form)
597
    {
598
        $post = $this->getRequest()->postVar('params');
599
        if (!$post) {
600
            return $this->redirectBack();
601
        }
602
        $params = [];
603
604
        $validFields = [];
605
        foreach ($this->SearchFields()->FieldList()->dataFields() as $field) {
606
            $validFields[] = str_replace(['params[', ']'], '', $field->getName());
607
        }
608
609
        foreach ($post as $k => $v) {
610
            if (in_array($k, $validFields)) {
611
                $params[$k] = $v;
612
            }
613
        }
614
615
        $this->getSession()->set(__class__ . '.Search', $params);
616
        $this->getSession()->save($this->getRequest());
617
618
        return $this->redirectBack();
619
    }
620
621
    /**
622
     * List of messages events
623
     *
624
     * Messages are cached to avoid hammering the api
625
     *
626
     * @return ArrayList|string
627
     */
628
    public function Messages()
629
    {
630
        $params = $this->getParams();
631
632
        $messages = $this->getCachedData('searchEvents', $params, 60 * self::MESSAGE_CACHE_MINUTES);
633
        if ($messages === false || !$messages) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $messages of type array<mixed,mixed> is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
634
            if ($this->lastException) {
635
                return $this->lastException->getMessage();
636
            }
637
            return _t('SparkpostAdmin.NO_MESSAGES', 'No messages');
638
        }
639
640
        // Consolidate Subject/Sender for open and click events
641
        $transmissions = [];
642
        foreach ($messages as $message) {
643
            if (empty($message['transmission_id']) || empty($message['subject'])) {
644
                continue;
645
            }
646
            if (isset($transmissions[$message['transmission_id']])) {
647
                continue;
648
            }
649
            $transmissions[$message['transmission_id']] = $message;
650
        }
651
652
        $list = new ArrayList();
653
        foreach ($messages as $message) {
654
            // If we have a transmission id but no subject, try to find the transmission details
655
            if (isset($message['transmission_id']) && empty($message['subject']) && isset($transmissions[$message['transmission_id']])) {
656
                $message = array_merge($transmissions[$message['transmission_id']], $message);
657
            }
658
            // In some case (errors, etc) we don't have a friendly from
659
            if (empty($message['friendly_from']) && isset($message['msg_from'])) {
660
                $message['friendly_from'] = $message['msg_from'];
661
            }
662
            $m = new ArrayData($message);
663
            $list->push($m);
664
        }
665
666
        return $list;
667
    }
668
669
    /**
670
     * Provides custom permissions to the Security section
671
     *
672
     * @return array<string,mixed>
673
     */
674
    public function providePermissions()
675
    {
676
        $title = _t("SparkPostAdmin.MENUTITLE", LeftAndMain::menu_title('SparkPost'));
677
        return [
678
            "CMS_ACCESS_SparkPost" => [
679
                'name' => _t('SparkPostAdmin.ACCESS', "Access to '{title}' section", ['title' => $title]),
680
                'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access'),
681
                'help' => _t(
682
                    'SparkPostAdmin.ACCESS_HELP',
683
                    'Allow use of SparkPost admin section'
684
                )
685
            ],
686
        ];
687
    }
688
689
    /**
690
     * Message helper
691
     *
692
     * @param string $message
693
     * @param string $status
694
     * @return string
695
     */
696
    protected function MessageHelper($message, $status = 'info')
697
    {
698
        return '<div class="message ' . $status . '">' . $message . '</div>';
699
    }
700
701
    /**
702
     * Button helper
703
     *
704
     * @param string $link
705
     * @param string $text
706
     * @param boolean $confirm
707
     * @return string
708
     */
709
    protected function ButtonHelper($link, $text, $confirm = false)
710
    {
711
        $link = '<a class="btn btn-primary" href="' . $link . '"';
712
        if ($confirm) {
713
            $link .= ' onclick="return confirm(\'' . _t('SparkPostAdmin.CONFIRM_MSG', 'Are you sure?') . '\')"';
714
        }
715
        $link .= '>' . $text . '</a>';
716
        return $link;
717
    }
718
719
    /**
720
     * Wrap html in a form group
721
     *
722
     * @param string $html
723
     * @return string
724
     */
725
    protected function FormGroupHelper($html)
726
    {
727
        return '<div class="form-group"><div class="form__fieldgroup form__field-holder form__field-holder--no-label">' . $html . '</div></div>';
728
    }
729
730
    /**
731
     * A template accessor to check the ADMIN permission
732
     *
733
     * @return bool
734
     */
735
    public function IsAdmin()
736
    {
737
        return boolval(Permission::check("ADMIN"));
738
    }
739
740
    /**
741
     * Check the permission for current user
742
     *
743
     * @return bool
744
     */
745
    public function canView($member = null)
746
    {
747
        $mailer = SparkPostHelper::getMailer();
748
        $transport = SparkPostHelper::getTransportFromMailer($mailer);
749
        // Another custom mailer has been set
750
        if (!($transport instanceof SparkPostApiTransport)) {
751
            return false;
752
        }
753
        return boolval(Permission::check("CMS_ACCESS_SparkPost", 'any', $member));
754
    }
755
756
    /**
757
     * @return bool
758
     */
759
    public function CanConfigureApi()
760
    {
761
        return Permission::check('ADMIN') || Director::isDev();
762
    }
763
764
    /**
765
     * Check if webhook is installed
766
     *
767
     * @return array<mixed>|false
768
     */
769
    public function WebhookInstalled()
770
    {
771
        $list = $this->getCachedData('listAllWebhooks', null, 60 * self::WEBHOOK_CACHE_MINUTES);
772
773
        if (empty($list)) {
774
            return false;
775
        }
776
        $url = $this->WebhookUrl();
777
        foreach ($list as $el) {
778
            if (!empty($el['target']) && $el['target'] === $url) {
779
                /*
780
                ^ array:13 [▼
781
  "auth_token" => false
782
  "auth_type" => "basic"
783
  "name" => "Testing Webhook"
784
  "auth_credentials" => array:2 [▶]
785
  "events" => array:19 [▶]
786
  "target" => "https://mydomain.com/sparkpost/incoming"
787
  "custom_headers" => []
788
  "auth_request_details" => []
789
  "id" => "xxxxxx"
790
  "last_successful" => null
791
  "last_failure" => null
792
  "active" => false
793
  "links" => array:1 [▼
794
    0 => array:3 [▶]
795
  ]
796
]
797
*/
798
                return $el;
799
            }
800
        }
801
        return false;
802
    }
803
804
    /**
805
     * Hook details for template
806
     * @return ArrayData|null
807
     */
808
    public function WebhookDetails()
809
    {
810
        $el = $this->WebhookInstalled();
811
        if ($el) {
812
            return new ArrayData($el);
813
        }
814
        return null;
815
    }
816
817
    /**
818
     * Get content of the tab
819
     *
820
     * @return FormField
821
     */
822
    public function WebhookTab()
823
    {
824
        $webhook = $this->WebhookInstalled();
825
        if ($webhook) {
826
            return $this->UninstallHookForm($webhook);
827
        }
828
        return $this->InstallHookForm();
829
    }
830
831
    /**
832
     * @return string
833
     */
834
    public function WebhookUrl()
835
    {
836
        if (self::config()->webhook_base_url) {
837
            return rtrim(self::config()->webhook_base_url, '/') . '/sparkpost/incoming';
838
        }
839
        if (Director::isLive()) {
840
            $absurl = Director::absoluteURL('/sparkpost/incoming');
841
            if ($absurl && is_string($absurl)) {
842
                return $absurl;
843
            }
844
        }
845
        $protocol = Director::protocol();
846
        $domain = $this->getDomain();
847
        if (!$domain) {
848
            throw new Exception("No domain for webhook");
849
        }
850
        return $protocol . $domain . '/sparkpost/incoming';
851
    }
852
853
    /**
854
     * Install hook form
855
     *
856
     * @return FormField
857
     */
858
    public function InstallHookForm()
859
    {
860
        $fields = new CompositeField();
861
        $fields->push(new LiteralField('Info', $this->MessageHelper(
862
            _t('SparkPostAdmin.WebhookNotInstalled', 'Webhook is not installed. It should be configured using the following url {url}. This url must be publicly visible to be used as a hook.', ['url' => $this->WebhookUrl()]),
863
            'bad'
864
        )));
865
        $fields->push(new LiteralField('doInstallHook', $this->ButtonHelper(
866
            $this->Link('doInstallHook'),
867
            _t('SparkPostAdmin.DOINSTALL_WEBHOOK', 'Install webhook')
868
        )));
869
        return $fields;
870
    }
871
872
    /**
873
     * @return HTTPResponse
874
     */
875
    public function doInstallHook()
876
    {
877
        if (!$this->CanConfigureApi()) {
878
            return $this->redirectBack();
879
        }
880
881
        $client = SparkPostHelper::getClient();
882
883
        $url = $this->WebhookUrl();
884
        $description = SiteConfig::current_site_config()->Title;
885
886
        $webhookUser = Environment::getEnv('SS_DEFAULT_ADMIN_USERNAME');
887
        $webhookPassword = Environment::getEnv('SS_DEFAULT_ADMIN_PASSWORD');
888
889
        if (SparkPostHelper::getWebhookUsername()) {
890
            $webhookUser = SparkPostHelper::getWebhookUsername();
891
            $webhookPassword = SparkPostHelper::getWebhookPassword();
892
        }
893
894
        try {
895
            if ($webhookUser && $webhookPassword) {
896
                $client->createSimpleWebhook($description, $url, null, true, ['username' => $webhookUser, 'password' => $webhookPassword]);
897
            } else {
898
                $client->createSimpleWebhook($description, $url); // This will add a default credentials that is sparkpost/sparkpost
899
            }
900
            $this->getCache()->clear();
901
        } catch (Exception $ex) {
902
            $this->getLogger()->debug($ex);
903
        }
904
905
        return $this->redirectBack();
906
    }
907
908
    /**
909
     * Uninstall hook form
910
     *
911
     * @param array<mixed> $data
912
     * @return FormField
913
     */
914
    public function UninstallHookForm($data)
915
    {
916
        $fields = new CompositeField();
917
918
        if ($data['active']) {
919
            $fields->push(new LiteralField('Info', $this->MessageHelper(
920
                _t('SparkPostAdmin.WebhookInstalled', 'Webhook is installed but inactive at the following url {url}.', ['url' => $this->WebhookUrl()]),
921
                'warning'
922
            )));
923
        } else {
924
            $fields->push(new LiteralField('Info', $this->MessageHelper(
925
                _t('SparkPostAdmin.WebhookInstalled', 'Webhook is installed and accessible at the following url {url}.', ['url' => $this->WebhookUrl()]),
926
                'good'
927
            )));
928
        }
929
930
        if ($data['last_successful']) {
931
            $fields->push(new LiteralField('LastSuccess', $this->MessageHelper(
932
                _t('SparkPostAdmin.WebhookLastSuccess', 'Webhook was last called successfully at {date}.', ['date' => $data['last_successful']]),
933
                'good'
934
            )));
935
        }
936
        if ($data['last_failure']) {
937
            $fields->push(new LiteralField('LastFailure', $this->MessageHelper(
938
                _t('SparkPostAdmin.WebhookLastFailure', 'Webhook last failure was at {date}.', ['date' => $data['last_failure']]),
939
                'bad'
940
            )));
941
        }
942
943
        $fields->push(new LiteralField('doUninstallHook', $this->ButtonHelper(
944
            $this->Link('doUninstallHook'),
945
            _t('SparkPostAdmin.DOUNINSTALL_WEBHOOK', 'Uninstall webhook'),
946
            true
947
        )));
948
        return $fields;
949
    }
950
951
    /**
952
     * @param array<mixed> $data
953
     * @param Form $form
954
     * @return HTTPResponse
955
     */
956
    public function doUninstallHook($data, Form $form)
957
    {
958
        if (!$this->CanConfigureApi()) {
959
            return $this->redirectBack();
960
        }
961
962
        $client = SparkPostHelper::getClient();
963
964
        try {
965
            $el = $this->WebhookInstalled();
966
            if ($el && !empty($el['id'])) {
967
                $client->deleteWebhook($el['id']);
968
            }
969
            $this->getCache()->clear();
970
        } catch (Exception $ex) {
971
            $this->getLogger()->debug($ex);
972
        }
973
974
        return $this->redirectBack();
975
    }
976
977
    /**
978
     * Check if sending domain is installed
979
     *
980
     * @return array<mixed>|false
981
     */
982
    public function SendingDomainInstalled()
983
    {
984
        $domain = $this->getCachedData('getSendingDomain', $this->getDomain(), 60 * self::SENDINGDOMAIN_CACHE_MINUTES);
985
986
        if (empty($domain)) {
987
            return false;
988
        }
989
        return $domain;
990
    }
991
992
    /**
993
     * Trigger request to check if sending domain is verified
994
     *
995
     * @return array<mixed>|false
996
     */
997
    public function VerifySendingDomain()
998
    {
999
        $client = SparkPostHelper::getClient();
1000
1001
        $host = $this->getDomain();
1002
        if (!$host || is_bool($host)) {
1003
            return false;
1004
        }
1005
1006
        $verification = $client->verifySendingDomain($host);
1007
        if (empty($verification)) {
1008
            return false;
1009
        }
1010
        return $verification;
1011
    }
1012
1013
    /**
1014
     * Get content of the tab
1015
     *
1016
     * @return FormField
1017
     */
1018
    public function DomainTab()
1019
    {
1020
        $defaultDomain = $this->getDomain();
1021
        $defaultDomainInfos = null;
1022
1023
        $domains = $this->getCachedData('listAllSendingDomains', null, 60 * self::SENDINGDOMAIN_CACHE_MINUTES);
1024
1025
        $fields = new CompositeField();
1026
1027
        $list = new ArrayList();
1028
        if ($domains) {
1029
            foreach ($domains as $domain) {
1030
                // Sometimes the api or the cache returns invalid data...
1031
                if (!is_array($domain)) {
1032
                    continue;
1033
                }
1034
                $list->push(new ArrayData([
1035
                    'Domain' => $domain['domain'],
1036
                    'SPF' => $domain['status']['spf_status'],
1037
                    'DKIM' => $domain['status']['dkim_status'],
1038
                    'Compliance' => $domain['status']['compliance_status'],
1039
                    'Verified' => $domain['status']['ownership_verified'],
1040
                ]));
1041
1042
                if ($domain['domain'] == $defaultDomain) {
1043
                    $defaultDomainInfos = $domain;
1044
                }
1045
            }
1046
        }
1047
1048
        $config = GridFieldConfig::create();
1049
        $config->addComponent(new GridFieldToolbarHeader());
1050
        $config->addComponent(new GridFieldTitleHeader());
1051
        $config->addComponent($columns = new GridFieldDataColumns());
1052
        $columns->setDisplayFields(ArrayLib::valuekey(['Domain', 'SPF', 'DKIM', 'Compliance', 'Verified']));
1053
        $domainsList = new GridField('SendingDomains', _t('SparkPostAdmin.ALL_SENDING_DOMAINS', 'Configured sending domains'), $list, $config);
1054
        $domainsList->addExtraClass('mb-2');
1055
        $fields->push($domainsList);
1056
1057
        if (!$defaultDomainInfos) {
1058
            $this->InstallDomainForm($fields);
1059
        } else {
1060
            $this->UninstallDomainForm($fields);
1061
        }
1062
1063
        return $fields;
1064
    }
1065
1066
    /**
1067
     * @return ?string
1068
     */
1069
    public function InboundUrl()
1070
    {
1071
        $subdomain = self::config()->inbound_subdomain;
1072
        $domain = $this->getDomain();
1073
        if ($domain) {
1074
            return $subdomain . '.' . $domain;
1075
        }
1076
        return null;
1077
    }
1078
1079
    /**
1080
     * Get domain name from current host
1081
     *
1082
     * @return boolean|string
1083
     */
1084
    public function getDomainFromHost()
1085
    {
1086
        $base = Environment::getEnv('SS_BASE_URL');
1087
        if (!$base) {
1088
            $base = Director::protocolAndHost();
1089
        }
1090
        $host = parse_url($base, PHP_URL_HOST);
1091
        if (!$host) {
1092
            return false;
1093
        }
1094
        $hostParts = explode('.', $host);
1095
        $parts = count($hostParts);
1096
        if ($parts < 2) {
1097
            return false;
1098
        }
1099
        $domain = $hostParts[$parts - 2] . "." . $hostParts[$parts - 1];
1100
        return $domain;
1101
    }
1102
1103
    /**
1104
     * Get domain from admin email
1105
     *
1106
     * @return boolean|string
1107
     */
1108
    public function getDomainFromEmail()
1109
    {
1110
        $email = SparkPostHelper::resolveDefaultFromEmail(null, false);
1111
        if ($email && is_string($email)) {
1112
            $emailat = (string)strrchr($email, "@");
1113
            $domain = substr($emailat, 1);
1114
            if (!$domain) {
1115
                return false;
1116
            }
1117
            return $domain;
1118
        }
1119
        return false;
1120
    }
1121
1122
    /**
1123
     * Get domain
1124
     *
1125
     * @return boolean|string
1126
     */
1127
    public function getDomain()
1128
    {
1129
        $domain = $this->getDomainFromEmail();
1130
        if (!$domain) {
1131
            return $this->getDomainFromHost();
1132
        }
1133
        return $domain;
1134
    }
1135
1136
    /**
1137
     * Install domain form
1138
     *
1139
     * @param CompositeField $fields
1140
     * @return void
1141
     */
1142
    public function InstallDomainForm(CompositeField $fields)
1143
    {
1144
        $host = $this->getDomain();
1145
1146
        $fields->push(new LiteralField('Info', $this->MessageHelper(
1147
            _t('SparkPostAdmin.DomainNotInstalled', 'Default sending domain {domain} is not installed.', ['domain' => $host]),
1148
            "bad"
1149
        )));
1150
        $fields->push(new LiteralField('doInstallDomain', $this->ButtonHelper(
1151
            $this->Link('doInstallDomain'),
1152
            _t('SparkPostAdmin.DOINSTALLDOMAIN', 'Install domain')
1153
        )));
1154
    }
1155
1156
    /**
1157
     * @return HTTPResponse
1158
     */
1159
    public function doInstallDomain()
1160
    {
1161
        if (!$this->CanConfigureApi()) {
1162
            return $this->redirectBack();
1163
        }
1164
1165
        $client = SparkPostHelper::getClient();
1166
1167
        $domain = $this->getDomain();
1168
1169
        if (!$domain || is_bool($domain)) {
1170
            return $this->redirectBack();
1171
        }
1172
1173
        try {
1174
            $client->createSimpleSendingDomain($domain);
1175
            $this->getCache()->clear();
1176
        } catch (Exception $ex) {
1177
            $this->getLogger()->debug($ex);
1178
        }
1179
1180
        return $this->redirectBack();
1181
    }
1182
1183
    /**
1184
     * Uninstall domain form
1185
     *
1186
     * @param CompositeField $fields
1187
     * @return void
1188
     */
1189
    public function UninstallDomainForm(CompositeField $fields)
1190
    {
1191
        $domainInfos = $this->SendingDomainInstalled();
1192
1193
        $domain = $this->getDomain();
1194
1195
        if ($domainInfos && $domainInfos['status']['ownership_verified']) {
1196
            $fields->push(new LiteralField('Info', $this->MessageHelper(
1197
                _t('SparkPostAdmin.DomainInstalled', 'Default domain {domain} is installed.', ['domain' => $domain]),
1198
                'good'
1199
            )));
1200
        } else {
1201
            $fields->push(new LiteralField('Info', $this->MessageHelper(
1202
                _t('SparkPostAdmin.DomainInstalledBut', 'Default domain {domain} is installed, but is not properly configured.'),
1203
                'warning'
1204
            )));
1205
        }
1206
        $fields->push(new LiteralField('doUninstallHook', $this->ButtonHelper(
1207
            $this->Link('doUninstallHook'),
1208
            _t('SparkPostAdmin.DOUNINSTALLDOMAIN', 'Uninstall domain'),
1209
            true
1210
        )));
1211
    }
1212
1213
    /**
1214
     * @param array<mixed> $data
1215
     * @param Form $form
1216
     * @return HTTPResponse
1217
     */
1218
    public function doUninstallDomain($data, Form $form)
1219
    {
1220
        if (!$this->CanConfigureApi()) {
1221
            return $this->redirectBack();
1222
        }
1223
1224
        $client = SparkPostHelper::getClient();
1225
1226
        $domain = $this->getDomain();
1227
1228
        if (!$domain || is_bool($domain)) {
1229
            return $this->redirectBack();
1230
        }
1231
1232
        try {
1233
            $el = $this->SendingDomainInstalled();
1234
            if ($el) {
1235
                $client->deleteSendingDomain($domain);
1236
            }
1237
            $this->getCache()->clear();
1238
        } catch (Exception $ex) {
1239
            $this->getLogger()->debug($ex);
1240
        }
1241
1242
        return $this->redirectBack();
1243
    }
1244
}
1245