Emailing   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 424
Duplicated Lines 0 %

Importance

Changes 12
Bugs 0 Features 1
Metric Value
eloc 213
c 12
b 0
f 1
dl 0
loc 424
rs 5.04
wmc 57

18 Methods

Rating   Name   Duplication   Size   Complexity  
A previewTab() 0 24 2
A canEdit() 0 3 1
A listRecipientsWithInvalidEmails() 0 13 4
A renderTemplate() 0 8 1
A getTitle() 0 3 1
C getEmailsByLocales() 0 72 13
A canCreate() 0 3 1
A getCMSActions() 0 14 1
B getCMSFields() 0 43 7
A collectMergeVars() 0 21 3
A getEmail() 0 20 4
A getNormalizedRecipientsList() 0 26 6
A canDelete() 0 3 1
A getMembersLocales() 0 3 1
A listRecipients() 0 11 2
B getAllRecipients() 0 29 7
A canView() 0 3 1
A getMergeVarsHeader() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Emailing often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Emailing, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\EmailTemplates\Models;
4
5
use Egulias\EmailValidator\EmailValidator;
6
use Egulias\EmailValidator\Validation\RFCValidation;
7
use Exception;
8
use SilverStripe\ORM\DB;
9
use SilverStripe\Forms\Tab;
10
use SilverStripe\ORM\DataList;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\Forms\TextField;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Control\Director;
15
use SilverStripe\Core\Config\Config;
16
use SilverStripe\Forms\LiteralField;
17
use SilverStripe\Control\Email\Email;
18
use SilverStripe\Forms\DropdownField;
19
use SilverStripe\Forms\ReadonlyField;
20
use SilverStripe\Forms\TextareaField;
21
use SilverStripe\Security\Permission;
22
use SilverStripe\Admin\AdminRootController;
23
use LeKoala\EmailTemplates\Email\BetterEmail;
24
use LeKoala\EmailTemplates\Helpers\EmailUtils;
25
use LeKoala\EmailTemplates\Helpers\FluentHelper;
26
use LeKoala\EmailTemplates\Admin\EmailTemplatesAdmin;
27
use SilverStripe\Forms\HTMLEditor\HTMLEditorField;
28
use SilverStripe\ORM\FieldType\DBHTMLText;
29
30
/**
31
 * Send emails to a group of members
32
 *
33
 * @property string $Subject
34
 * @property string $Recipients
35
 * @property string $RecipientsList
36
 * @property string $Sender
37
 * @property string $LastSent
38
 * @property int $LastSentCount
39
 * @property string $LastError
40
 * @property string $Content
41
 * @property string $Callout
42
 * @author lekoala
43
 */
44
class Emailing extends DataObject
45
{
46
    private static $table_name = 'Emailing';
47
48
    private static $db = [
49
        'Subject' => 'Varchar(255)',
50
        'Recipients' => 'Varchar(255)',
51
        'RecipientsList' => 'Text',
52
        'Sender' => 'Varchar(255)',
53
        'LastSent' => 'Datetime',
54
        'LastSentCount' => 'Int',
55
        'LastError' => 'Text',
56
        // Content
57
        'Content' => 'HTMLText',
58
        'Callout' => 'HTMLText',
59
    ];
60
    private static $summary_fields = [
61
        'Subject', 'LastSent'
62
    ];
63
    private static $searchable_fields = [
64
        'Subject',
65
    ];
66
    private static $translate = [
67
        'Subject', 'Content', 'Callout'
68
    ];
69
70
    public function getTitle()
71
    {
72
        return $this->Subject;
73
    }
74
75
    public function getCMSActions()
76
    {
77
        $actions = parent::getCMSActions();
78
        $label = _t('Emailing.SEND', 'Send');
79
        $sure = _t('Emailing.SURE', 'Are you sure?');
80
        $onclick = 'return confirm(\'' . $sure . '\');';
81
82
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
83
        $adminSegment = EmailTemplatesAdmin::config()->get('url_segment');
84
        $link = '/admin/' . $adminSegment . '/' . $sanitisedModel . '/SendEmailing/?id=' . $this->ID;
85
        $btnContent = '<a href="' . $link . '" id="action_doSend" onclick="' . $onclick . '" class="btn action btn-info font-icon-angle-double-right">';
86
        $btnContent .= '<span class="btn__title">' . $label . '</span></a>';
87
        $actions->push(new LiteralField('doSend', $btnContent));
88
        return $actions;
89
    }
90
91
    public function getCMSFields()
92
    {
93
        $fields = parent::getCMSFields();
94
95
        // Do not allow changing subsite
96
        $fields->removeByName('SubsiteID');
97
98
        // Recipients
99
        $recipientsList = $this->listRecipients();
100
        $fields->replaceField('Recipients', $Recipients = new DropdownField('Recipients', null, $recipientsList));
101
        $Recipients->setDescription(_t('Emailing.EMAIL_COUNT', "Email will be sent to {count} members", ['count' => $this->getAllRecipients()->count()]));
102
103
        /** @var HTMLEditorField */
104
        $fCallout = $fields->dataFieldByName('Callout');
105
        $fCallout->setRows(5);
106
107
        if ($this->ID) {
108
            $fields->addFieldToTab('Root.Preview', $this->previewTab());
109
        }
110
111
        $fields->addFieldToTab('Root.Settings', new TextField('Sender'));
112
        $fields->addFieldToTab('Root.Settings', new ReadonlyField('LastSent'));
113
        $fields->addFieldToTab('Root.Settings', $RecipientsList = new TextareaField('RecipientsList'));
114
        $RecipientsList->setDescription(_t('Emailing.RECIPIENTSLISTHELP', 'A list of IDs or emails on each line or separated by commas. Select "Selected members" to use this list'));
115
116
        if ($this->ID) {
117
            $invalidRecipients = $this->listRecipientsWithInvalidEmails();
118
            if (!empty($invalidRecipients)) {
119
                $invalidRecipientsContent = '';
120
                foreach ($invalidRecipients as $ir) {
121
                    $email = $ir->Email;
122
                    if (!$email && strlen($email) <= 0) {
123
                        $email = '(no email)';
124
                    }
125
                    $invalidRecipientsContent .= "<h3>" . $ir->FirstName . ' ' . $ir->Surname . ' (#' . $ir->ID . ')</h3>';
126
                    $invalidRecipientsContent .= "<p>" . $email . "</p>";
127
                    $invalidRecipientsContent .= "<hr/>";
128
                }
129
                $fields->addFieldToTab('Root.InvalidRecipients', new LiteralField("InvalidRecipients", $invalidRecipientsContent));
130
            }
131
        }
132
133
        return $fields;
134
    }
135
136
    /**
137
     * @return Member[]|DataList
138
     */
139
    public function getAllRecipients()
140
    {
141
        $list = null;
142
        $locales = self::getMembersLocales();
143
        foreach ($locales as $locale) {
144
            if ($this->Recipients == $locale . '_MEMBERS') {
145
                $list = Member::get()->filter('Locale', $locale);
146
            }
147
        }
148
        $recipients = $this->Recipients;
149
        if (!$list) {
150
            switch ($recipients) {
151
                case 'ALL_MEMBERS':
152
                    $list = Member::get()->exclude('Email', ['', null]);
153
                    break;
154
                case 'SELECTED_MEMBERS':
155
                    $IDs =  $this->getNormalizedRecipientsList();
156
                    if (empty($IDs)) {
157
                        $IDs = 0;
158
                    }
159
                    $list = Member::get()->filter('ID', $IDs);
160
                    break;
161
                default:
162
                    $list = Member::get()->filter('ID', 0);
163
                    break;
164
            }
165
        }
166
        $this->extend('updateGetAllRecipients', $list, $locales, $recipients);
167
        return $list;
168
    }
169
170
    /**
171
     * List all invalid recipients
172
     *
173
     * @return array
174
     */
175
    public function listRecipientsWithInvalidEmails()
176
    {
177
        $list = [];
178
        $validator = new EmailValidator();
179
        $validation =  new RFCValidation();
180
        foreach ($this->getAllRecipients() as $r) {
181
            /** @var Member $r */
182
            $res = $validator->isValid($r->Email, $validation); //true
183
            if (!$res || !$r->Email) {
184
                $list[] = $r;
185
            }
186
        }
187
        return $list;
188
    }
189
190
    /**
191
     * List of ids
192
     *
193
     * @return array
194
     */
195
    public function getNormalizedRecipientsList()
196
    {
197
        $list = $this->RecipientsList;
198
199
        $perLine = explode("\n", $list);
200
201
        $arr = [];
202
        foreach ($perLine as $line) {
203
            $items = explode(',', $line);
204
            foreach ($items as $item) {
205
                // Prevent whitespaces from messing up our queries
206
                $item = trim($item);
207
208
                if (!$item) {
209
                    continue;
210
                }
211
                if (is_numeric($item)) {
212
                    $arr[] = $item;
213
                } elseif (strpos($item, '@') !== false) {
214
                    $arr[] = DB::prepared_query("SELECT ID FROM Member WHERE Email = ?", [$item])->value();
215
                } else {
216
                    throw new Exception("Unprocessable item $item");
217
                }
218
            }
219
        }
220
        return $arr;
221
    }
222
223
    /**
224
     * @return array
225
     */
226
    public static function getMembersLocales()
227
    {
228
        return DB::query("SELECT DISTINCT Locale FROM Member")->column();
229
    }
230
231
    /**
232
     * @return array
233
     */
234
    public function listRecipients()
235
    {
236
        $arr = [];
237
        $arr['ALL_MEMBERS'] = _t('Emailing.ALL_MEMBERS', 'All members');
238
        $arr['SELECTED_MEMBERS'] = _t('Emailing.SELECTED_MEMBERS', 'Selected members');
239
        $locales = self::getMembersLocales();
240
        foreach ($locales as $locale) {
241
            $arr[$locale . '_MEMBERS'] = _t('Emailing.LOCALE_MEMBERS', '{locale} members', ['locale' => $locale]);
242
        }
243
        $this->extend("updateListRecipients", $arr, $locales);
244
        return $arr;
245
    }
246
247
    /**
248
     * Provide content for the Preview tab
249
     *
250
     * @return Tab
251
     */
252
    protected function previewTab()
253
    {
254
        $tab = new Tab('Preview');
255
256
        // Preview iframe
257
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
258
        $adminSegment = EmailTemplatesAdmin::config()->get('url_segment');
259
        $adminBaseSegment = AdminRootController::config()->get('url_base');
260
        $iframeSrc = Director::baseURL() . $adminBaseSegment . '/' . $adminSegment . '/' . $sanitisedModel . '/PreviewEmailing/?id=' . $this->ID;
261
        $iframe = new LiteralField('iframe', '<iframe src="' . $iframeSrc . '" style="width:800px;background:#fff;border:1px solid #ccc;min-height:500px;vertical-align:top"></iframe>');
262
        $tab->push($iframe);
263
264
        // Merge var helper
265
        $vars = $this->collectMergeVars();
266
        $syntax = self::config()->get('mail_merge_syntax');
267
        if (empty($vars)) {
268
            $varsHelperContent = "You can use $syntax notation to use mail merge variable for the recipients";
269
        } else {
270
            $varsHelperContent = "The following mail merge variables are used : " . implode(", ", $vars);
271
        }
272
        $varsHelper = new LiteralField("varsHelpers", '<div><br/><br/>' . $varsHelperContent . '</div>');
273
        $tab->push($varsHelper);
274
275
        return $tab;
276
    }
277
278
    public function canView($member = null)
279
    {
280
        return true;
281
    }
282
283
    public function canEdit($member = null)
284
    {
285
        return Permission::check('CMS_ACCESS', 'any', $member);
286
    }
287
288
    public function canCreate($member = null, $context = [])
289
    {
290
        return Permission::check('CMS_ACCESS', 'any', $member);
291
    }
292
293
    public function canDelete($member = null)
294
    {
295
        return Permission::check('CMS_ACCESS', 'any', $member);
296
    }
297
298
    /**
299
     * Get rendered body
300
     *
301
     * @return string
302
     */
303
    public function renderTemplate()
304
    {
305
        // Disable debug bar in the iframe
306
        Config::modify()->set('LeKoala\\DebugBar\\DebugBar', 'auto_inject', false);
307
308
        $email = $this->getEmail();
309
        $html = $email->getRenderedBody();
310
        return $html;
311
    }
312
313
    /**
314
     * Collect all merge vars
315
     *
316
     * @return array
317
     */
318
    public function collectMergeVars()
319
    {
320
        $fields = ['Subject', 'Content', 'Callout'];
321
322
        $syntax = self::config()->get('mail_merge_syntax');
323
324
        $regex = $syntax;
325
        $regex = preg_quote($regex);
326
        $regex = str_replace("MERGETAG", "([\w\.]+)", $regex);
327
328
        $allMatches = [];
329
        foreach ($fields as $field) {
330
            $content = $this->$field;
331
            $matches = [];
332
            preg_match_all('/' . $regex . '/', $content, $matches);
333
            if (!empty($matches[1])) {
334
                $allMatches = array_merge($allMatches, $matches[1]);
335
            }
336
        }
337
338
        return $allMatches;
339
    }
340
341
342
    /**
343
     * Returns an instance of an Email with the content of the emailing
344
     *
345
     * @return BetterEmail
346
     */
347
    public function getEmail()
348
    {
349
        $email = Email::create();
350
        if (!$email instanceof BetterEmail) {
351
            throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
352
        }
353
        if ($this->Sender) {
354
            $senderEmail = EmailUtils::get_email_from_rfc_email($this->Sender);
355
            $senderName = EmailUtils::get_displayname_from_rfc_email($this->Sender);
356
            $email->setFrom($senderEmail, $senderName);
357
        }
358
        foreach ($this->getAllRecipients() as $r) {
359
            /** @var Member $r */
360
            $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
361
        }
362
        $email->setSubject($this->Subject);
363
        $email->addData('EmailContent', $this->Content);
364
        $email->addData('Callout', $this->Callout);
365
        $email->addData('IsEmailing', true);
366
        return $email;
367
    }
368
369
    /**
370
     * Various email providers use various types of mail merge headers
371
     * By default, we use mandrill that is expected to work for other platforms through compat layer
372
     *
373
     * X-Mailgun-Recipient-Variables: {"[email protected]": {"first":"Bob", "id":1}, "[email protected]": {"first":"Alice", "id": 2}}
374
     * Template syntax: %recipient.first%
375
     * @link https://documentation.mailgun.com/en/latest/user_manual.html#batch-sending
376
     *
377
     * X-MC-MergeVars [{"rcpt":"[email protected]","vars":[{"name":"merge2","content":"merge2 content"}]}]
378
     * Template syntax: *|MERGETAG|*
379
     * @link https://mandrill.zendesk.com/hc/en-us/articles/205582117-How-to-Use-SMTP-Headers-to-Customize-Your-Messages
380
     *
381
     * @link https://developers.sparkpost.com/api/smtp/#header-using-the-x-msys-api-custom-header
382
     *
383
     * @return string
384
     */
385
    public function getMergeVarsHeader()
386
    {
387
        return self::config()->get('mail_merge_header');
388
    }
389
390
    /**
391
     * Returns an array of emails with members by locale, grouped by a given number of recipients
392
     * Some apis prevent sending too many emails at the same time
393
     *
394
     * @return array
395
     */
396
    public function getEmailsByLocales()
397
    {
398
        $batchCount = self::config()->batch_count ?? 100;
399
        $sendBcc = self::config()->get('send_bcc');
400
401
        $membersByLocale = [];
402
        foreach ($this->getAllRecipients() as $r) {
403
            /** @var Member $r */
404
            if (!isset($membersByLocale[$r->Locale])) {
405
                $membersByLocale[$r->Locale] = [];
406
            }
407
            $membersByLocale[$r->Locale][] = $r;
408
        }
409
410
        $mergeVars = $this->collectMergeVars();
411
        $mergeVarHeader = $this->getMergeVarsHeader();
412
413
        $emails = [];
414
        foreach ($membersByLocale as $locale => $membersList) {
415
            $emails[$locale] = [];
416
            $chunks = array_chunk($membersList, $batchCount);
417
            foreach ($chunks as $chunk) {
418
                $email = Email::create();
419
                if (!$email instanceof BetterEmail) {
420
                    throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
421
                }
422
                if ($this->Sender) {
423
                    $senderEmail = EmailUtils::get_email_from_rfc_email($this->Sender);
424
                    $senderName = EmailUtils::get_displayname_from_rfc_email($this->Sender);
425
                    $email->setFrom($senderEmail, $senderName);
426
                }
427
                $mergeVarsData = [];
428
                foreach ($chunk as $r) {
429
                    if ($sendBcc) {
430
                        $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
431
                    } else {
432
                        $email->addTo($r->Email, $r->FirstName . ' ' . $r->Surname);
433
                    }
434
                    if (!empty($mergeVars)) {
435
                        $vars = [];
436
                        foreach ($mergeVars as $mergeVar) {
437
                            $v = null;
438
                            if ($r->hasMethod($mergeVar)) {
439
                                $v = $r->$mergeVar();
440
                            } else {
441
                                $v = $r->$mergeVar;
442
                            }
443
                            $vars[$mergeVar] = $v;
444
                        }
445
                        $mergeVarsData[] = [
446
                            'rcpt' => $r->Email,
447
                            'vars' => $vars
448
                        ];
449
                    }
450
                }
451
                // Merge vars
452
                if (!empty($mergeVars)) {
453
                    $email->getHeaders()->addTextHeader($mergeVarHeader, json_encode($mergeVarsData));
454
                }
455
                // Localize
456
                $EmailingID = $this->ID;
457
                FluentHelper::withLocale($locale, function () use ($EmailingID, $email) {
458
                    $Emailing = Emailing::get()->byID($EmailingID);
459
                    $email->setSubject($Emailing->Subject);
460
                    $email->addData('EmailContent', $Emailing->Content);
461
                    $email->addData('Callout', $Emailing->Callout);
462
                    $email->addData('IsEmailing', true);
463
                });
464
                $emails[$locale][] = $email;
465
            }
466
        }
467
        return $emails;
468
    }
469
}
470