Passed
Push — master ( 8e24af...edd2ba )
by Thomas
02:15
created

Emailing::collectMergeVars()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 13
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 21
rs 9.8333
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 SilverStripe\Forms\FormAction;
25
26
/**
27
 * Send emails to a group of members
28
 *
29
 * @property string $Subject
30
 * @property string $Recipients
31
 * @property string $RecipientsList
32
 * @property string $Sender
33
 * @property string $Content
34
 * @property string $Callout
35
 * @author lekoala
36
 */
37
class Emailing extends DataObject
38
{
39
    private static $table_name = 'Emailing';
40
41
    private static $db = array(
42
        'Subject' => 'Varchar(255)',
43
        'Recipients' => 'Varchar(255)',
44
        'RecipientsList' => 'Text',
45
        'Sender' => 'Varchar(255)',
46
        'LastSent' => 'Datetime',
47
        // Content
48
        'Content' => 'HTMLText',
49
        'Callout' => 'HTMLText',
50
    );
51
    private static $summary_fields = array(
52
        'Subject', 'LastSent'
53
    );
54
    private static $searchable_fields = array(
55
        'Subject',
56
    );
57
    private static $translate = array(
58
        'Subject', 'Content', 'Callout'
59
    );
60
61
    public function getTitle()
62
    {
63
        return $this->Subject;
64
    }
65
66
    public function getCMSActions()
67
    {
68
        $actions = parent::getCMSActions();
69
        $label = _t('Emailing.SEND', 'Send');
70
        $sure = _t('Emailing.SURE', 'Are you sure?');
71
        $onclick = 'return confirm(\'' . $sure . '\');';
72
73
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
74
        $adminSegment = EmailTemplatesAdmin::config()->url_segment;
75
        $link = '/admin/' . $adminSegment . '/' . $sanitisedModel . '/SendEmailing/?id=' . $this->ID;
76
        $btnContent = '<a href="' . $link . '" id="action_doSend" onclick="' . $onclick . '" class="btn action btn-info font-icon-angle-double-right">';
77
        $btnContent .= '<span class="btn__title">' . $label . '</span></a>';
78
        $actions->push(new LiteralField('doSend', $btnContent));
79
        return $actions;
80
    }
81
82
    public function getCMSFields()
83
    {
84
        $fields = parent::getCMSFields();
85
86
        // Do not allow changing subsite
87
        $fields->removeByName('SubsiteID');
88
89
        // Recipients
90
        $recipientsList = $this->listRecipients();
91
        $fields->replaceField('Recipients', $Recipients = new DropdownField('Recipients', null, $recipientsList));
92
        $Recipients->setDescription(_t('Emailing.EMAIL_COUNT', "Email will be sent to {count} members", ['count' => $this->getAllRecipients()->count()]));
93
94
        $fields->dataFieldByName('Callout')->setRows(5);
95
96
        if ($this->ID) {
97
            $fields->addFieldToTab('Root.Preview', $this->previewTab());
98
        }
99
100
        $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

100
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new TextField('Sender'));
Loading history...
101
        $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

101
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new ReadonlyField('LastSent'));
Loading history...
102
        $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

102
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ $RecipientsList = new TextareaField('RecipientsList'));
Loading history...
103
        $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'));
104
105
        return $fields;
106
    }
107
108
    /**
109
     * @return DataList
110
     */
111
    public function getAllRecipients()
112
    {
113
        $list = null;
114
        $locales = self::getMembersLocales();
115
        foreach ($locales as $locale) {
116
            if ($this->Recipients == $locale . '_MEMBERS') {
117
                $list = Member::get()->filter('Locale', $locale);
118
            }
119
        }
120
        $recipients = $this->Recipients;
121
        if (!$list) {
122
            switch ($recipients) {
123
                case 'ALL_MEMBERS':
124
                    $list = Member::get();
125
                    break;
126
                case 'SELECTED_MEMBERS':
127
                    $IDs =  $this->getNormalizedRecipientsList();
128
                    if (empty($IDs)) {
129
                        $IDs = 0;
130
                    }
131
                    $list = Member::get()->filter('ID', $IDs);
132
                    break;
133
                default:
134
                    $list = Member::get()->filter('ID', 0);
135
                    break;
136
            }
137
        }
138
        $this->extend('updateGetAllRecipients', $list, $locales, $recipients);
139
        return $list;
140
    }
141
142
    /**
143
     * List of ids
144
     *
145
     * @return array
146
     */
147
    public function getNormalizedRecipientsList()
148
    {
149
        $list = $this->RecipientsList;
150
151
        $perLine = explode("\n", $list);
152
153
        $arr = [];
154
        foreach ($perLine as $line) {
155
            $items = explode(',', $line);
156
            foreach ($items as $item) {
157
                // Prevent whitespaces from messing up our queries
158
                $item = trim($item);
159
160
                if (!$item) {
161
                    continue;
162
                }
163
                if (is_numeric($item)) {
164
                    $arr[] = $item;
165
                } elseif (strpos($item, '@') !== false) {
166
                    $arr[] = DB::prepared_query("SELECT ID FROM Member WHERE Email = ?", [$item])->value();
167
                } else {
168
                    throw new Exception("Unprocessable item $item");
169
                }
170
            }
171
        }
172
        return $arr;
173
    }
174
175
    /**
176
     * @return array
177
     */
178
    public static function getMembersLocales()
179
    {
180
        return DB::query("SELECT DISTINCT Locale FROM Member")->column();
181
    }
182
183
    /**
184
     * @return array
185
     */
186
    public function listRecipients()
187
    {
188
        $arr = [];
189
        $arr['ALL_MEMBERS'] = _t('Emailing.ALL_MEMBERS', 'All members');
190
        $arr['SELECTED_MEMBERS'] = _t('Emailing.SELECTED_MEMBERS', 'Selected members');
191
        $locales = self::getMembersLocales();
192
        foreach ($locales as $locale) {
193
            $arr[$locale . '_MEMBERS'] = _t('Emailing.LOCALE_MEMBERS', '{locale} members', ['locale' => $locale]);
194
        }
195
        $this->extend("updateListRecipients", $arr, $locales);
196
        return $arr;
197
    }
198
199
    /**
200
     * Provide content for the Preview tab
201
     *
202
     * @return Tab
203
     */
204
    protected function previewTab()
205
    {
206
        $tab = new Tab('Preview');
207
208
        // Preview iframe
209
        $sanitisedModel =  str_replace('\\', '-', Emailing::class);
210
        $adminSegment = EmailTemplatesAdmin::config()->url_segment;
211
        $iframeSrc = '/admin/' . $adminSegment . '/' . $sanitisedModel . '/PreviewEmailing/?id=' . $this->ID;
212
        $iframe = new LiteralField('iframe', '<iframe src="' . $iframeSrc . '" style="width:800px;background:#fff;border:1px solid #ccc;min-height:500px;vertical-align:top"></iframe>');
213
        $tab->push($iframe);
214
215
        // Merge var helper
216
        $vars = $this->collectMergeVars();
217
        $syntax = self::config()->mail_merge_syntax;
218
        if (empty($vars)) {
219
            $varsHelperContent = "You can use $syntax notation to use mail merge variable for the recipients";
220
        } else {
221
            $varsHelperContent = "The following mail merge variables are used : " . implode(", ", $vars);
222
        }
223
        $varsHelper = new LiteralField("varsHelpers", '<div><br/><br/>' . $varsHelperContent . '</div>');
224
        $tab->push($varsHelper);
225
226
        return $tab;
227
    }
228
229
    public function canView($member = null)
230
    {
231
        return true;
232
    }
233
234
    public function canEdit($member = null)
235
    {
236
        return Permission::check('CMS_ACCESS', 'any', $member);
237
    }
238
239
    public function canCreate($member = null, $context = [])
240
    {
241
        return Permission::check('CMS_ACCESS', 'any', $member);
242
    }
243
244
    public function canDelete($member = null)
245
    {
246
        return Permission::check('CMS_ACCESS', 'any', $member);
247
    }
248
249
    /**
250
     * Get rendered body
251
     *
252
     * @return string
253
     */
254
    public function renderTemplate()
255
    {
256
        // Disable debug bar in the iframe
257
        Config::modify()->set('LeKoala\\DebugBar\\DebugBar', 'auto_inject', false);
258
259
        $email = $this->getEmail();
260
        $html = $email->getRenderedBody();
261
        return $html;
262
    }
263
264
    /**
265
     * Collect all merge vars
266
     *
267
     * @return array
268
     */
269
    public function collectMergeVars()
270
    {
271
        $fields = ['Subject', 'Content', 'Callout'];
272
273
        $syntax = self::config()->mail_merge_syntax;
274
275
        $regex = $syntax;
276
        $regex = preg_quote($regex);
277
        $regex = str_replace("MERGETAG", "([\w\.]+)", $regex);
278
279
        $allMatches = [];
280
        foreach ($fields as $field) {
281
            $content = $this->$field;
282
            $matches = [];
283
            preg_match_all('/' . $regex . '/', $content, $matches);
284
            if (!empty($matches[1])) {
285
                $allMatches = array_merge($allMatches, $matches[1]);
286
            }
287
        }
288
289
        return $allMatches;
290
    }
291
292
293
    /**
294
     * Returns an instance of an Email with the content of the emailing
295
     *
296
     * @return BetterEmail
297
     */
298
    public function getEmail()
299
    {
300
        $email = Email::create();
301
        if (!$email instanceof BetterEmail) {
302
            throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
303
        }
304
        if ($this->Sender) {
305
            $email->setFrom($this->Sender);
306
        }
307
        foreach ($this->getAllRecipients() as $r) {
308
            $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
309
        }
310
        $email->setSubject($this->Subject);
311
        $email->addData('EmailContent', $this->Content);
312
        $email->addData('Callout', $this->Callout);
313
        return $email;
314
    }
315
316
    /**
317
     * Various email providers use various types of mail merge headers
318
     * By default, we use mandrill that is expected to work for other platforms through compat layer
319
     *
320
     * X-Mailgun-Recipient-Variables: {"[email protected]": {"first":"Bob", "id":1}, "[email protected]": {"first":"Alice", "id": 2}}
321
     * Template syntax: %recipient.first%
322
     * @link https://documentation.mailgun.com/en/latest/user_manual.html#batch-sending
323
     *
324
     * X-MC-MergeVars [{"rcpt":"[email protected]","vars":[{"name":"merge2","content":"merge2 content"}]}]
325
     * Template syntax: *|MERGETAG|*
326
     * @link https://mandrill.zendesk.com/hc/en-us/articles/205582117-How-to-Use-SMTP-Headers-to-Customize-Your-Messages
327
     *
328
     * @link https://developers.sparkpost.com/api/smtp/#header-using-the-x-msys-api-custom-header
329
     *
330
     * @return string
331
     */
332
    public function getMergeVarsHeader()
333
    {
334
        return self::config()->mail_merge_header;
335
    }
336
337
    /**
338
     * Returns an array of emails with members by locale, grouped by a given number of recipients
339
     * Some apis prevent sending too many emails at the same time
340
     *
341
     * @return array
342
     */
343
    public function getEmailsByLocales()
344
    {
345
        $batchCount = self::config()->batch_count ?? 1000;
346
        $sendBcc = self::config()->send_bcc;
347
348
        $membersByLocale = [];
349
        foreach ($this->getAllRecipients() as $r) {
350
            if (!isset($membersByLocale[$r->Locale])) {
351
                $membersByLocale[$r->Locale] = [];
352
            }
353
            $membersByLocale[$r->Locale][] = $r;
354
        }
355
356
        $mergeVars = $this->collectMergeVars();
357
        $mergeVarHeader = $this->getMergeVarsHeader();
358
359
        $emails = [];
360
        foreach ($membersByLocale as $locale => $membersList) {
361
            $emails[$locale] = [];
362
            $chunks = array_chunk($membersList, $batchCount);
363
            foreach ($chunks as $chunk) {
364
                $email = Email::create();
365
                if (!$email instanceof BetterEmail) {
366
                    throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
367
                }
368
                if ($this->Sender) {
369
                    $email->setFrom($this->Sender);
370
                }
371
                $mergeVarsData = [];
372
                foreach ($chunk as $r) {
373
                    if ($sendBcc) {
374
                        $email->addBCC($r->Email, $r->FirstName . ' ' . $r->Surname);
375
                    } else {
376
                        $email->addTo($r->Email, $r->FirstName . ' ' . $r->Surname);
377
                    }
378
                    if (!empty($mergeVars)) {
379
                        $vars = [];
380
                        foreach ($mergeVars as $mergeVar) {
381
                            $vars[$mergeVar] = $r->$mergeVar;
382
                        }
383
                        $mergeVarsData[] = [
384
                            'rcpt' => $r->Email,
385
                            'vars' => $vars
386
                        ];
387
                    }
388
                }
389
                // Merge vars
390
                if (!empty($mergeVars)) {
391
                    $email->getSwiftMessage()->getHeaders()->addTextHeader($mergeVarHeader, json_encode($mergeVarsData));
392
                }
393
                // Localize
394
                $EmailingID = $this->ID;
395
                FluentHelper::withLocale($locale, function () use ($EmailingID, $email) {
396
                    $Emailing = Emailing::get()->byID($EmailingID);
397
                    $email->setSubject($Emailing->Subject);
398
                    $email->addData('EmailContent', $Emailing->Content);
399
                    $email->addData('Callout', $Emailing->Callout);
400
                });
401
                $emails[$locale][] = $email;
402
            }
403
        }
404
        return $emails;
405
    }
406
}
407