EmailTemplate   F
last analyzed

Complexity

Total Complexity 64

Size/Duplication

Total Lines 461
Duplicated Lines 0 %

Importance

Changes 9
Bugs 1 Features 0
Metric Value
eloc 207
c 9
b 1
f 0
dl 0
loc 461
rs 3.28
wmc 64

17 Methods

Rating   Name   Duplication   Size   Complexity  
B getAvailableModels() 0 38 8
A getTitle() 0 3 1
A renderTemplate() 0 13 2
A canEdit() 0 3 1
C mergeFieldsHelper() 0 60 12
A getEmailForMember() 0 16 3
A applyTemplate() 0 26 4
A getEmail() 0 12 3
A canView() 0 3 1
A onBeforeWrite() 0 3 1
A canDelete() 0 3 1
A previewTab() 0 20 3
A getByCode() 0 19 4
C setPreviewData() 0 74 15
A getCMSFields() 0 34 3
A getEmailByCode() 0 3 1
A canCreate() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like EmailTemplate 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 EmailTemplate, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\EmailTemplates\Models;
4
5
use Exception;
6
use SilverStripe\Forms\Tab;
7
use SilverStripe\i18n\i18n;
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\View\ArrayData;
11
use SilverStripe\Forms\TextField;
12
use SilverStripe\Security\Member;
13
use SilverStripe\Control\Director;
14
use SilverStripe\Core\Environment;
15
use SilverStripe\Forms\HeaderField;
16
use SilverStripe\Core\Config\Config;
17
use SilverStripe\Forms\LiteralField;
18
use SilverStripe\Control\Email\Email;
19
use SilverStripe\Forms\CheckboxField;
20
use SilverStripe\Forms\DropdownField;
21
use SilverStripe\Security\Permission;
22
use SilverStripe\SiteConfig\SiteConfig;
23
use SilverStripe\Admin\AdminRootController;
24
use LeKoala\EmailTemplates\Email\BetterEmail;
25
use LeKoala\EmailTemplates\Helpers\FluentHelper;
26
use LeKoala\EmailTemplates\Admin\EmailTemplatesAdmin;
27
28
/**
29
 * User defined email templates
30
 *
31
 * Content of the template should override default content provided with setHTMLTemplate
32
 *
33
 * For example, in the framework we have
34
 *    $email = Email::create()->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail')
35
 *
36
 * It means our template code should match this : ForgotPasswordEmail
37
 *
38
 * @property string $Subject
39
 * @property string $DefaultSender
40
 * @property string $DefaultRecipient
41
 * @property string $Category
42
 * @property string $Code
43
 * @property string $Content
44
 * @property string $Callout
45
 * @property boolean $Disabled
46
 * @author lekoala
47
 */
48
class EmailTemplate extends DataObject
49
{
50
    private static $table_name = 'EmailTemplate';
51
52
    private static $db = array(
53
        'Subject' => 'Varchar(255)',
54
        'DefaultSender' => 'Varchar(255)',
55
        'DefaultRecipient' => 'Varchar(255)',
56
        'Category' => 'Varchar(255)',
57
        'Code' => 'Varchar(255)',
58
        // Content
59
        'Content' => 'HTMLText',
60
        'Callout' => 'HTMLText',
61
        // Configuration
62
        'Disabled' => 'Boolean',
63
    );
64
    private static $summary_fields = array(
65
        'Subject',
66
        'Code',
67
        'Category',
68
        'Disabled',
69
    );
70
    private static $searchable_fields = array(
71
        'Subject',
72
        'Code',
73
        'Category',
74
        'Disabled',
75
    );
76
    private static $indexes = array(
77
        'Code' => true, // Code is not unique because it can be used by subsites
78
    );
79
    private static $translate = array(
80
        'Subject', 'Content', 'Callout'
81
    );
82
83
    public function getTitle()
84
    {
85
        return $this->Subject;
86
    }
87
88
    public function getCMSFields()
89
    {
90
        $fields = parent::getCMSFields();
91
92
        // Do not allow changing subsite
93
        $fields->removeByName('SubsiteID');
94
95
        $fields->dataFieldByName('Callout')->setRows(5);
96
97
        $codeField = $fields->dataFieldByName('Code');
98
        $codeField->setAttribute('placeholder', _t('EmailTemplate.CODEPLACEHOLDER', 'A unique code that will be used in code to retrieve the template, e.g.: MyEmail'));
99
100
        if ($this->Code) {
101
            $codeField->setReadonly(true);
102
        }
103
104
        // Merge fields helper
105
        $fields->addFieldToTab('Root.Main', new HeaderField('MergeFieldsHelperTitle', _t('EmailTemplate.AVAILABLEMERGEFIELDSTITLE', 'Available merge fields')));
106
107
        $fields->addFieldToTab('Root.Main', new LiteralField('MergeFieldsHelper', $this->mergeFieldsHelper()));
108
109
        if ($this->ID) {
110
            $fields->addFieldToTab('Root.Preview', $this->previewTab());
111
        }
112
113
        // Cleanup UI
114
        $categories = EmailTemplate::get()->column('Category');
115
        $fields->addFieldsToTab('Root.Settings', new DropdownField('Category', 'Category', array_combine($categories, $categories)));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\D...tegories, $categories)) of type SilverStripe\Forms\DropdownField 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

115
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new DropdownField('Category', 'Category', array_combine($categories, $categories)));
Loading history...
116
        $fields->addFieldsToTab('Root.Settings', new CheckboxField('Disabled'));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\CheckboxField('Disabled') of type SilverStripe\Forms\CheckboxField 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.Settings', /** @scrutinizer ignore-type */ new CheckboxField('Disabled'));
Loading history...
117
        $fields->addFieldsToTab('Root.Settings', new TextField('DefaultSender'));
0 ignored issues
show
Bug introduced by
new SilverStripe\Forms\TextField('DefaultSender') 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

117
        $fields->addFieldsToTab('Root.Settings', /** @scrutinizer ignore-type */ new TextField('DefaultSender'));
Loading history...
118
        $fields->addFieldsToTab('Root.Settings', new TextField('DefaultRecipient'));
119
120
121
        return $fields;
122
    }
123
124
    public function canView($member = null)
125
    {
126
        return true;
127
    }
128
129
    public function canEdit($member = null)
130
    {
131
        return Permission::check('CMS_ACCESS', 'any', $member);
132
    }
133
134
    public function canCreate($member = null, $context = [])
135
    {
136
        // Should be created by developer
137
        return false;
138
    }
139
140
    public function canDelete($member = null)
141
    {
142
        return Permission::check('CMS_ACCESS', 'any', $member);
143
    }
144
145
    /**
146
     * A map of Name => Class
147
     *
148
     * User models are variables with a . that should match an existing DataObject name
149
     *
150
     * @return array
151
     */
152
    public function getAvailableModels()
153
    {
154
        $fields = ['Content', 'Callout'];
155
156
        $models = self::config()->default_models;
157
158
        // Build a list of non namespaced models
159
        // They are not likely to clash anyway because of their unique table name
160
        $dataobjects = ClassInfo::getValidSubClasses(DataObject::class);
161
        $map = [];
162
        foreach ($dataobjects as $k => $v) {
163
            $parts = explode('\\', $v);
164
            $name = end($parts);
165
            $map[$name] = $v;
166
        }
167
168
        foreach ($fields as $field) {
169
            // Match variables with a dot in the call, like $MyModel.SomeMethod
170
            preg_match_all('/\$([a-zA-Z]+)\./m', $this->$field ?? '', $matches);
171
172
            if (!empty($matches) && !empty($matches[1])) {
173
                // Get unique model names
174
                $arr = array_unique($matches[1]);
175
176
                foreach ($arr as $name) {
177
                    if (!isset($map[$name])) {
178
                        continue;
179
                    }
180
                    $class = $map[$name];
181
                    $singl = singleton($class);
182
                    if ($singl instanceof DataObject) {
183
                        $models[$name] = $class;
184
                    }
185
                }
186
            }
187
        }
188
189
        return $models;
190
    }
191
192
    /**
193
     * Get an email template by code
194
     *
195
     * @param string $code
196
     * @param bool $alwaysReturn
197
     * @param string $locale
198
     * @return EmailTemplate
199
     */
200
    public static function getByCode($code, $alwaysReturn = true, $locale = null)
201
    {
202
        if ($locale) {
203
            $template = FluentHelper::withLocale($locale, function () use ($code) {
204
                return EmailTemplate::get()->filter('Code', $code)->first();
205
            });
206
        } else {
207
            $template = EmailTemplate::get()->filter('Code', $code)->first();
208
        }
209
        // Always return a template
210
        if (!$template && $alwaysReturn) {
211
            $template = new EmailTemplate();
212
            $template->Subject = $code;
213
            $template->Code = $code;
214
            $template->Content = 'Replace this with your own content and untick disabled';
215
            $template->Disabled = true;
216
            $template->write();
217
        }
218
        return $template;
219
    }
220
221
    /**
222
     * A shorthand to get an email by code
223
     *
224
     * @param string $code
225
     * @param string $locale
226
     * @return BetterEmail
227
     */
228
    public static function getEmailByCode($code, $locale = null)
229
    {
230
        return self::getByCode($code, true, $locale)->getEmail();
231
    }
232
233
    public function onBeforeWrite()
234
    {
235
        parent::onBeforeWrite();
236
    }
237
238
    /**
239
     * Content of the literal field for the merge fields
240
     *
241
     * @return string
242
     */
243
    protected function mergeFieldsHelper()
244
    {
245
        $content = '<strong>Base fields:</strong><br/>';
246
        $baseFields = array(
247
            'To', 'Cc', 'Bcc', 'From', 'Subject', 'Body', 'BaseURL', 'Controller'
248
        );
249
        foreach ($baseFields as $baseField) {
250
            $content .= $baseField . ', ';
251
        }
252
        $content = trim($content, ', ') . '<br/>';
253
254
        $models = $this->getAvailableModels();
255
256
        $modelsByClass = array();
257
        $classes = array();
258
        foreach ($models as $name => $model) {
259
            $classes[] = $model;
260
            if (!isset($modelsByClass[$model])) {
261
                $modelsByClass[$model] = array();
262
            }
263
            $modelsByClass[$model][] = $name;
264
        }
265
        $classes = array_unique($classes);
266
267
        $locales = array();
268
        // if (class_exists('Fluent')) {
269
        //     $locales = Fluent::locales();
270
        // }
271
272
        foreach ($classes as $model) {
273
            if (!class_exists($model)) {
274
                continue;
275
            }
276
            $props = Config::inst()->get($model, 'db');
277
            $o = singleton($model);
278
            $content .= '<strong>' . $model . ' (' . implode(',', $modelsByClass[$model]) . '):</strong><br/>';
279
            foreach ($props as $fieldName => $fieldType) {
280
                // Filter out locale fields
281
                foreach ($locales as $locale) {
282
                    if (strpos($fieldName, $locale) !== false) {
283
                        continue;
284
                    }
285
                }
286
                $content .= $fieldName . ', ';
287
            }
288
289
            // We could also show methods but that may be long
290
            if (self::config()->helper_show_methods) {
291
                $methods = array_diff($o->allMethodNames(true), $o->allMethodNames());
292
                foreach ($methods as $method) {
293
                    if (strpos($method, 'get') === 0) {
294
                        $content .= $method . ', ';
295
                    }
296
                }
297
            }
298
299
            $content = trim($content, ', ') . '<br/>';
300
        }
301
        $content .= "<br/><div class='message info'>" . _t('EmailTemplate.ENCLOSEFIELD', 'To escape a field from surrounding text, you can enclose it between brackets, eg: {$Member.FirstName}.') . '</div>';
302
        return $content;
303
    }
304
305
    /**
306
     * Provide content for the Preview tab
307
     *
308
     * @return Tab
309
     */
310
    protected function previewTab()
311
    {
312
        $tab = new Tab('Preview');
313
314
        // Preview iframe
315
        $sanitisedModel =  str_replace('\\', '-', EmailTemplate::class);
316
        $adminSegment = EmailTemplatesAdmin::config()->url_segment;
317
        $adminBaseSegment = AdminRootController::config()->url_base;
318
        $iframeSrc = Director::baseURL() . $adminBaseSegment . '/' . $adminSegment . '/' . $sanitisedModel . '/PreviewEmail/?id=' . $this->ID;
319
        $iframe = new LiteralField('iframe', '<iframe src="' . $iframeSrc . '" style="width:800px;background:#fff;border:1px solid #ccc;min-height:500px;vertical-align:top"></iframe>');
320
        $tab->push($iframe);
321
322
        $env = Environment::getEnv('SS_SEND_ALL_EMAILS_TO');
323
        if ($env || Director::isDev()) {
324
            $sendTestLink = Director::baseURL() . $adminBaseSegment . '/' . $adminSegment . '/' . $sanitisedModel . '/SendTestEmailTemplate/?id=' . $this->ID . '&to=' . urlencode($env);
325
            $sendTest = new LiteralField("send_test", "<hr/><a href='$sendTestLink'>Send test email</a>");
326
            $tab->push($sendTest);
327
        }
328
329
        return $tab;
330
    }
331
332
    /**
333
     * Returns an instance of an Email with the content of the template
334
     *
335
     * @return BetterEmail
336
     */
337
    public function getEmail()
338
    {
339
        $email = Email::create();
340
        if (!$email instanceof BetterEmail) {
341
            throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
342
        }
343
344
        $this->applyTemplate($email);
345
        if ($this->Disabled) {
346
            $email->setDisabled(true);
347
        }
348
        return $email;
349
    }
350
351
    /**
352
     * Returns an instance of an Email with the content tailored to the member
353
     *
354
     * @param Member $member
355
     * @return BetterEmail
356
     */
357
    public function getEmailForMember(Member $member)
358
    {
359
        $restoreLocale = null;
360
        if ($member->Locale) {
361
            $restoreLocale = i18n::get_locale();
362
            i18n::set_locale($member->Locale);
363
        }
364
365
        $email = $this->getEmail();
366
        $email->setToMember($member);
367
368
        if ($restoreLocale) {
369
            i18n::set_locale($restoreLocale);
370
        }
371
372
        return $email;
373
    }
374
375
    /**
376
     * Apply this template to the email
377
     *
378
     * @param BetterEmail $email
379
     */
380
    public function applyTemplate(&$email)
381
    {
382
        $email->setEmailTemplate($this);
383
384
        if ($this->Subject) {
385
            $email->setSubject($this->Subject);
386
        }
387
388
        // Use dbObject to handle shortcodes as well
389
        $email->setData([
390
            'EmailContent' => $this->dbObject('Content')->forTemplate(),
391
            'Callout' => $this->dbObject('Callout')->forTemplate(),
392
        ]);
393
394
        // Email are initialized with admin_email if set, we may want to use our own sender
395
        if ($this->DefaultSender) {
396
            $email->setFrom($this->DefaultSender);
397
        } else {
398
            $SiteConfig = SiteConfig::current_site_config();
399
            $email->setFrom($SiteConfig->EmailDefaultSender());
400
        }
401
        if ($this->DefaultRecipient) {
402
            $email->setTo($this->DefaultRecipient);
403
        }
404
405
        $this->extend('updateApplyTemplate', $email);
406
    }
407
408
    /**
409
     * Get rendered body
410
     *
411
     * @param bool $injectFake
412
     * @return string
413
     */
414
    public function renderTemplate($injectFake = false)
415
    {
416
        // Disable debug bar in the iframe
417
        Config::modify()->set('LeKoala\\DebugBar\\DebugBar', 'auto_inject', false);
418
419
        $email = $this->getEmail();
420
        if ($injectFake) {
421
            $email = $this->setPreviewData($email);
422
        }
423
424
        $html = $email->getRenderedBody();
425
426
        return $html;
427
    }
428
429
    /**
430
     * Inject random data into email for nicer preview
431
     *
432
     * @param BetterEmail $email
433
     * @return BetterEmail
434
     */
435
    public function setPreviewData(BetterEmail $email)
436
    {
437
        $data = array();
438
439
        // Get an array of data like ["Body" => "My content", "Callout" => "The callout..."]
440
        $emailData = $email->getData();
441
442
        // Parse the data for variables
443
        // For now, simply replace them by their name in curly braces
444
        foreach ($emailData as $k => $v) {
445
            if (!$v) {
446
                continue;
447
            }
448
449
            $matches = null;
450
451
            // This match all $Variable or $Member.Firstname kind of vars
452
            preg_match_all('/\$([a-zA-Z.]*)/', $v, $matches);
453
            if ($matches && !empty($matches[1])) {
454
                foreach ($matches[1] as $name) {
455
                    $name = trim($name, '.');
456
457
                    if (strpos($name, '.') !== false) {
458
                        // It's an object
459
                        $parts = explode('.', $name);
460
                        $objectName = array_shift($parts);
461
                        if (isset($data[$objectName])) {
462
                            $object = $data[$objectName];
463
                        } else {
464
                            $object = new ArrayData(array());
465
                        }
466
                        $curr = $object;
467
468
                        // May be recursive
469
                        foreach ($parts as $part) {
470
                            if (is_string($curr)) {
471
                                $curr = [];
472
                                $object->$part = $curr;
473
                            }
474
                            $object->$part = '{' . "$objectName.$part" . '}';
475
                            $prevPart = $part;
0 ignored issues
show
Unused Code introduced by
The assignment to $prevPart is dead and can be removed.
Loading history...
476
                            $curr = $object->$part;
477
                        }
478
                        $data[$objectName] = $object;
479
                    } else {
480
                        // It's a simple var
481
                        $data[$name] = '{' . $name . '}';
482
                    }
483
                }
484
            }
485
        }
486
487
        // Inject random data for known classes
488
        foreach ($this->getAvailableModels() as $name => $class) {
489
            if (!class_exists($class)) {
490
                continue;
491
            }
492
            if (singleton($class)->hasMethod('getSampleRecord')) {
493
                $o = $class::getSampleRecord();
494
            } else {
495
                $o = $class::get()->sort('RAND()')->first();
496
            }
497
498
            if (!$o) {
499
                $o = new $class;
500
            }
501
            $data[$name] = $o;
502
        }
503
504
        foreach ($data as $name => $value) {
505
            $email->addData($name, $value);
506
        }
507
508
        return $email;
509
    }
510
}
511