Passed
Push — master ( 4709b0...e0b6ce )
by Thomas
09:25
created

Emailing::listRecipientsWithInvalidEmails()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 10
rs 10
1
<?php
2
3
namespace LeKoala\EmailTemplates\Models;
4
5
use Exception;
6
use SilverStripe\ORM\DB;
7
use SilverStripe\Forms\Tab;
8
use SilverStripe\i18n\i18n;
9
use SilverStripe\ORM\DataList;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\Forms\TextField;
12
use SilverStripe\Security\Member;
13
use SilverStripe\Core\Config\Config;
14
use SilverStripe\Forms\LiteralField;
15
use SilverStripe\Control\Email\Email;
16
use SilverStripe\Forms\DropdownField;
17
use SilverStripe\Forms\ReadonlyField;
18
use SilverStripe\Forms\TextareaField;
19
use SilverStripe\Security\Permission;
20
use SilverStripe\SiteConfig\SiteConfig;
21
use LeKoala\EmailTemplates\Email\BetterEmail;
22
use LeKoala\EmailTemplates\Helpers\FluentHelper;
23
use LeKoala\EmailTemplates\Admin\EmailTemplatesAdmin;
24
use LeKoala\EmailTemplates\Helpers\EmailUtils;
25
use SilverStripe\Forms\FormAction;
26
use Swift_Validate;
27
28
/**
29
 * Send emails to a group of members
30
 *
31
 * @property string $Subject
32
 * @property string $Recipients
33
 * @property string $RecipientsList
34
 * @property string $Sender
35
 * @property string $Content
36
 * @property string $Callout
37
 * @author lekoala
38
 */
39
class Emailing extends DataObject
40
{
41
    private static $table_name = 'Emailing';
42
43
    private static $db = array(
44
        'Subject' => 'Varchar(255)',
45
        'Recipients' => 'Varchar(255)',
46
        'RecipientsList' => 'Text',
47
        'Sender' => 'Varchar(255)',
48
        'LastSent' => 'Datetime',
49
        // Content
50
        'Content' => 'HTMLText',
51
        'Callout' => 'HTMLText',
52
    );
53
    private static $summary_fields = array(
54
        'Subject', 'LastSent'
55
    );
56
    private static $searchable_fields = array(
57
        'Subject',
58
    );
59
    private static $translate = array(
60
        'Subject', 'Content', 'Callout'
61
    );
62
63
    public function getTitle()
64
    {
65
        return $this->Subject;
66
    }
67
68
    public function getCMSActions()
69
    {
70
        $actions = parent::getCMSActions();
71
        $label = _t('Emailing.SEND', 'Send');
72
        $sure = _t('Emailing.SURE', 'Are you sure?');
73
        $onclick = 'return confirm(\'' . $sure . '\');';
74
75
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
76
        $adminSegment = EmailTemplatesAdmin::config()->url_segment;
77
        $link = '/admin/' . $adminSegment . '/' . $sanitisedModel . '/SendEmailing/?id=' . $this->ID;
78
        $btnContent = '<a href="' . $link . '" id="action_doSend" onclick="' . $onclick . '" class="btn action btn-info font-icon-angle-double-right">';
79
        $btnContent .= '<span class="btn__title">' . $label . '</span></a>';
80
        $actions->push(new LiteralField('doSend', $btnContent));
81
        return $actions;
82
    }
83
84
    public function getCMSFields()
85
    {
86
        $fields = parent::getCMSFields();
87
88
        // Do not allow changing subsite
89
        $fields->removeByName('SubsiteID');
90
91
        // Recipients
92
        $recipientsList = $this->listRecipients();
93
        $fields->replaceField('Recipients', $Recipients = new DropdownField('Recipients', null, $recipientsList));
94
        $Recipients->setDescription(_t('Emailing.EMAIL_COUNT', "Email will be sent to {count} members", ['count' => $this->getAllRecipients()->count()]));
95
96
        $fields->dataFieldByName('Callout')->setRows(5);
97
98
        if ($this->ID) {
99
            $fields->addFieldToTab('Root.Preview', $this->previewTab());
100
        }
101
102
        $fields->addFieldsToTab('Root.Settings', new TextField('Sender'));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\TextField('Sender') of type SilverStripe\Forms\TextField is incompatible with the type array expected by parameter $fields of SilverStripe\Forms\FieldList::addFieldsToTab(). ( Ignorable by Annotation )

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

102
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new TextField('Sender'));
Loading history...
103
        $fields->addFieldsToTab('Root.Settings', new ReadonlyField('LastSent'));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\ReadonlyField('LastSent') of type SilverStripe\Forms\ReadonlyField is incompatible with the type array expected by parameter $fields of SilverStripe\Forms\FieldList::addFieldsToTab(). ( Ignorable by Annotation )

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

103
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new ReadonlyField('LastSent'));
Loading history...
104
        $fields->addFieldsToTab('Root.Settings', $RecipientsList = new TextareaField('RecipientsList'));
0 ignored issues
show
Bug introduced by
$RecipientsList = new Si...Field('RecipientsList') of type SilverStripe\Forms\TextareaField is incompatible with the type array expected by parameter $fields of SilverStripe\Forms\FieldList::addFieldsToTab(). ( Ignorable by Annotation )

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

104
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ $RecipientsList = new TextareaField('RecipientsList'));
Loading history...
105
        $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'));
106
107
        if ($this->ID) {
108
            $invalidRecipients = $this->listRecipientsWithInvalidEmails();
109
            if (!empty($invalidRecipients)) {
110
                $invalidRecipientsContent = '';
111
                foreach ($invalidRecipients as $ir) {
112
                    $invalidRecipientsContent .= "<h3>" . $ir->FirstName . ' ' . $ir->Surname . '</h3>';
113
                    $invalidRecipientsContent .= "<p>" . $ir->Email . "</p>";
114
                    $invalidRecipientsContent .= "<hr/>";
115
                }
116
                $fields->addFieldsToTab('Root.InvalidRecipients', new LiteralField("InvalidRecipients", $invalidRecipientsContent));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\L...validRecipientsContent) of type SilverStripe\Forms\LiteralField is incompatible with the type array expected by parameter $fields of SilverStripe\Forms\FieldList::addFieldsToTab(). ( Ignorable by Annotation )

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

116
                $fields->addFieldsToTab('Root.InvalidRecipients', /** @scrutinizer ignore-type */ new LiteralField("InvalidRecipients", $invalidRecipientsContent));
Loading history...
117
            }
118
        }
119
120
        return $fields;
121
    }
122
123
    /**
124
     * @return DataList
125
     */
126
    public function getAllRecipients()
127
    {
128
        $list = null;
129
        $locales = self::getMembersLocales();
130
        foreach ($locales as $locale) {
131
            if ($this->Recipients == $locale . '_MEMBERS') {
132
                $list = Member::get()->filter('Locale', $locale);
133
            }
134
        }
135
        $recipients = $this->Recipients;
136
        if (!$list) {
137
            switch ($recipients) {
138
                case 'ALL_MEMBERS':
139
                    $list = Member::get()->exclude('Email', '');
140
                    break;
141
                case 'SELECTED_MEMBERS':
142
                    $IDs =  $this->getNormalizedRecipientsList();
143
                    if (empty($IDs)) {
144
                        $IDs = 0;
145
                    }
146
                    $list = Member::get()->filter('ID', $IDs);
147
                    break;
148
                default:
149
                    $list = Member::get()->filter('ID', 0);
150
                    break;
151
            }
152
        }
153
        $this->extend('updateGetAllRecipients', $list, $locales, $recipients);
154
        return $list;
155
    }
156
157
    /**
158
     * List all invalid recipients
159
     *
160
     * @return array
161
     */
162
    public function listRecipientsWithInvalidEmails()
163
    {
164
        $list = [];
165
        foreach ($this->getAllRecipients() as $r) {
166
            $res = Swift_Validate::email($r->Email);
167
            if (!$res) {
168
                $list[] = $r;
169
            }
170
        }
171
        return $list;
172
    }
173
174
    /**
175
     * List of ids
176
     *
177
     * @return array
178
     */
179
    public function getNormalizedRecipientsList()
180
    {
181
        $list = $this->RecipientsList;
182
183
        $perLine = explode("\n", $list);
184
185
        $arr = [];
186
        foreach ($perLine as $line) {
187
            $items = explode(',', $line);
188
            foreach ($items as $item) {
189
                // Prevent whitespaces from messing up our queries
190
                $item = trim($item);
191
192
                if (!$item) {
193
                    continue;
194
                }
195
                if (is_numeric($item)) {
196
                    $arr[] = $item;
197
                } elseif (strpos($item, '@') !== false) {
198
                    $arr[] = DB::prepared_query("SELECT ID FROM Member WHERE Email = ?", [$item])->value();
199
                } else {
200
                    throw new Exception("Unprocessable item $item");
201
                }
202
            }
203
        }
204
        return $arr;
205
    }
206
207
    /**
208
     * @return array
209
     */
210
    public static function getMembersLocales()
211
    {
212
        return DB::query("SELECT DISTINCT Locale FROM Member")->column();
213
    }
214
215
    /**
216
     * @return array
217
     */
218
    public function listRecipients()
219
    {
220
        $arr = [];
221
        $arr['ALL_MEMBERS'] = _t('Emailing.ALL_MEMBERS', 'All members');
222
        $arr['SELECTED_MEMBERS'] = _t('Emailing.SELECTED_MEMBERS', 'Selected members');
223
        $locales = self::getMembersLocales();
224
        foreach ($locales as $locale) {
225
            $arr[$locale . '_MEMBERS'] = _t('Emailing.LOCALE_MEMBERS', '{locale} members', ['locale' => $locale]);
226
        }
227
        $this->extend("updateListRecipients", $arr, $locales);
228
        return $arr;
229
    }
230
231
    /**
232
     * Provide content for the Preview tab
233
     *
234
     * @return Tab
235
     */
236
    protected function previewTab()
237
    {
238
        $tab = new Tab('Preview');
239
240
        // Preview iframe
241
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
242
        $adminSegment = EmailTemplatesAdmin::config()->url_segment;
243
        $iframeSrc = '/admin/' . $adminSegment . '/' . $sanitisedModel . '/PreviewEmailing/?id=' . $this->ID;
244
        $iframe = new LiteralField('iframe', '<iframe src="' . $iframeSrc . '" style="width:800px;background:#fff;border:1px solid #ccc;min-height:500px;vertical-align:top"></iframe>');
245
        $tab->push($iframe);
246
247
        // Merge var helper
248
        $vars = $this->collectMergeVars();
249
        $syntax = self::config()->mail_merge_syntax;
250
        if (empty($vars)) {
251
            $varsHelperContent = "You can use $syntax notation to use mail merge variable for the recipients";
252
        } else {
253
            $varsHelperContent = "The following mail merge variables are used : " . implode(", ", $vars);
254
        }
255
        $varsHelper = new LiteralField("varsHelpers", '<div><br/><br/>' . $varsHelperContent . '</div>');
256
        $tab->push($varsHelper);
257
258
        return $tab;
259
    }
260
261
    public function canView($member = null)
262
    {
263
        return true;
264
    }
265
266
    public function canEdit($member = null)
267
    {
268
        return Permission::check('CMS_ACCESS', 'any', $member);
269
    }
270
271
    public function canCreate($member = null, $context = [])
272
    {
273
        return Permission::check('CMS_ACCESS', 'any', $member);
274
    }
275
276
    public function canDelete($member = null)
277
    {
278
        return Permission::check('CMS_ACCESS', 'any', $member);
279
    }
280
281
    /**
282
     * Get rendered body
283
     *
284
     * @return string
285
     */
286
    public function renderTemplate()
287
    {
288
        // Disable debug bar in the iframe
289
        Config::modify()->set('LeKoala\\DebugBar\\DebugBar', 'auto_inject', false);
290
291
        $email = $this->getEmail();
292
        $html = $email->getRenderedBody();
293
        return $html;
294
    }
295
296
    /**
297
     * Collect all merge vars
298
     *
299
     * @return array
300
     */
301
    public function collectMergeVars()
302
    {
303
        $fields = ['Subject', 'Content', 'Callout'];
304
305
        $syntax = self::config()->mail_merge_syntax;
306
307
        $regex = $syntax;
308
        $regex = preg_quote($regex);
309
        $regex = str_replace("MERGETAG", "([\w\.]+)", $regex);
310
311
        $allMatches = [];
312
        foreach ($fields as $field) {
313
            $content = $this->$field;
314
            $matches = [];
315
            preg_match_all('/' . $regex . '/', $content, $matches);
316
            if (!empty($matches[1])) {
317
                $allMatches = array_merge($allMatches, $matches[1]);
318
            }
319
        }
320
321
        return $allMatches;
322
    }
323
324
325
    /**
326
     * Returns an instance of an Email with the content of the emailing
327
     *
328
     * @return BetterEmail
329
     */
330
    public function getEmail()
331
    {
332
        $email = Email::create();
333
        if (!$email instanceof BetterEmail) {
334
            throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
335
        }
336
        if ($this->Sender) {
337
            $senderEmail = EmailUtils::get_email_from_rfc_email($this->Sender);
338
            $senderName = EmailUtils::get_displayname_from_rfc_email($this->Sender);
339
            $email->setFrom($senderEmail, $senderName);
340
        }
341
        foreach ($this->getAllRecipients() as $r) {
342
            $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
343
        }
344
        $email->setSubject($this->Subject);
345
        $email->addData('EmailContent', $this->Content);
346
        $email->addData('Callout', $this->Callout);
347
        $email->addData('IsEmailing', true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type null|string expected by parameter $value of SilverStripe\Control\Email\Email::addData(). ( Ignorable by Annotation )

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

347
        $email->addData('IsEmailing', /** @scrutinizer ignore-type */ true);
Loading history...
348
        return $email;
349
    }
350
351
    /**
352
     * Various email providers use various types of mail merge headers
353
     * By default, we use mandrill that is expected to work for other platforms through compat layer
354
     *
355
     * X-Mailgun-Recipient-Variables: {"[email protected]": {"first":"Bob", "id":1}, "[email protected]": {"first":"Alice", "id": 2}}
356
     * Template syntax: %recipient.first%
357
     * @link https://documentation.mailgun.com/en/latest/user_manual.html#batch-sending
358
     *
359
     * X-MC-MergeVars [{"rcpt":"[email protected]","vars":[{"name":"merge2","content":"merge2 content"}]}]
360
     * Template syntax: *|MERGETAG|*
361
     * @link https://mandrill.zendesk.com/hc/en-us/articles/205582117-How-to-Use-SMTP-Headers-to-Customize-Your-Messages
362
     *
363
     * @link https://developers.sparkpost.com/api/smtp/#header-using-the-x-msys-api-custom-header
364
     *
365
     * @return string
366
     */
367
    public function getMergeVarsHeader()
368
    {
369
        return self::config()->mail_merge_header;
370
    }
371
372
    /**
373
     * Returns an array of emails with members by locale, grouped by a given number of recipients
374
     * Some apis prevent sending too many emails at the same time
375
     *
376
     * @return array
377
     */
378
    public function getEmailsByLocales()
379
    {
380
        $batchCount = self::config()->batch_count ?? 1000;
381
        $sendBcc = self::config()->send_bcc;
382
383
        $membersByLocale = [];
384
        foreach ($this->getAllRecipients() as $r) {
385
            if (!isset($membersByLocale[$r->Locale])) {
386
                $membersByLocale[$r->Locale] = [];
387
            }
388
            $membersByLocale[$r->Locale][] = $r;
389
        }
390
391
        $mergeVars = $this->collectMergeVars();
392
        $mergeVarHeader = $this->getMergeVarsHeader();
393
394
        $emails = [];
395
        foreach ($membersByLocale as $locale => $membersList) {
396
            $emails[$locale] = [];
397
            $chunks = array_chunk($membersList, $batchCount);
398
            foreach ($chunks as $chunk) {
399
                $email = Email::create();
400
                if (!$email instanceof BetterEmail) {
401
                    throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
402
                }
403
                if ($this->Sender) {
404
                    $senderEmail = EmailUtils::get_email_from_rfc_email($this->Sender);
405
                    $senderName = EmailUtils::get_displayname_from_rfc_email($this->Sender);
406
                    $email->setFrom($senderEmail, $senderName);
407
                }
408
                $mergeVarsData = [];
409
                foreach ($chunk as $r) {
410
                    if ($sendBcc) {
411
                        $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
412
                    } else {
413
                        $email->addTo($r->Email, $r->FirstName . ' ' . $r->Surname);
414
                    }
415
                    if (!empty($mergeVars)) {
416
                        $vars = [];
417
                        foreach ($mergeVars as $mergeVar) {
418
                            $v = null;
419
                            if ($r->hasMethod($mergeVar)) {
420
                                $v = $r->$mergeVar();
421
                            } else {
422
                                $v = $r->$mergeVar;
423
                            }
424
                            $vars[$mergeVar] = $v;
425
                        }
426
                        $mergeVarsData[] = [
427
                            'rcpt' => $r->Email,
428
                            'vars' => $vars
429
                        ];
430
                    }
431
                }
432
                // Merge vars
433
                if (!empty($mergeVars)) {
434
                    $email->getSwiftMessage()->getHeaders()->addTextHeader($mergeVarHeader, json_encode($mergeVarsData));
435
                }
436
                // Localize
437
                $EmailingID = $this->ID;
438
                FluentHelper::withLocale($locale, function () use ($EmailingID, $email) {
439
                    $Emailing = Emailing::get()->byID($EmailingID);
440
                    $email->setSubject($Emailing->Subject);
441
                    $email->addData('EmailContent', $Emailing->Content);
442
                    $email->addData('Callout', $Emailing->Callout);
443
                    $email->addData('IsEmailing', true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type null|string expected by parameter $value of SilverStripe\Control\Email\Email::addData(). ( Ignorable by Annotation )

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

443
                    $email->addData('IsEmailing', /** @scrutinizer ignore-type */ true);
Loading history...
444
                });
445
                $emails[$locale][] = $email;
446
            }
447
        }
448
        return $emails;
449
    }
450
}
451