Issues (68)

src/Models/EmailTemplate.php (1 issue)

Labels
Severity
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
use LeKoala\EmailTemplates\Extensions\EmailTemplateSiteConfigExtension;
28
use SilverStripe\Forms\HTMLEditor\HTMLEditorField;
29
30
/**
31
 * User defined email templates
32
 *
33
 * Content of the template should override default content provided with setHTMLTemplate
34
 *
35
 * For example, in the framework we have
36
 *    $email = Email::create()->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail')
37
 *
38
 * It means our template code should match this : ForgotPasswordEmail
39
 *
40
 * @property string $Subject
41
 * @property string $DefaultSender
42
 * @property string $DefaultRecipient
43
 * @property string $Category
44
 * @property string $Code
45
 * @property string $Content
46
 * @property string $Callout
47
 * @property boolean $Disabled
48
 * @property int $SubsiteID
49
 * @author lekoala
50
 */
51
class EmailTemplate extends DataObject
52
{
53
    private static $table_name = 'EmailTemplate';
54
55
    private static $db = [
56
        'Subject' => 'Varchar(255)',
57
        'DefaultSender' => 'Varchar(255)',
58
        'DefaultRecipient' => 'Varchar(255)',
59
        'Category' => 'Varchar(255)',
60
        'Code' => 'Varchar(255)',
61
        // Content
62
        'Content' => 'HTMLText',
63
        'Callout' => 'HTMLText',
64
        // Configuration
65
        'Disabled' => 'Boolean',
66
    ];
67
    private static $summary_fields = [
68
        'Subject',
69
        'Code',
70
        'Category',
71
        'Disabled',
72
    ];
73
    private static $searchable_fields = [
74
        'Subject',
75
        'Code',
76
        'Category',
77
        'Disabled',
78
    ];
79
    private static $indexes = [
80
        'Code' => true, // Code is not unique because it can be used by subsites
81
    ];
82
    private static $translate = [
83
        'Subject', 'Content', 'Callout'
84
    ];
85
86
    public function getTitle()
87
    {
88
        return $this->Subject;
89
    }
90
91
    public function getCMSFields()
92
    {
93
        $fields = parent::getCMSFields();
94
95
        // Do not allow changing subsite
96
        $fields->removeByName('SubsiteID');
97
98
        /** @var HTMLEditorField */
99
        $fCallout = $fields->dataFieldByName('Callout');
100
        $fCallout->setRows(5);
101
102
        $codeField = $fields->dataFieldByName('Code');
103
        $codeField->setAttribute('placeholder', _t('EmailTemplate.CODEPLACEHOLDER', 'A unique code that will be used in code to retrieve the template, e.g.: MyEmail'));
104
105
        if ($this->Code) {
106
            $codeField->setReadonly(true);
107
        }
108
109
        // Merge fields helper
110
        $fields->addFieldToTab('Root.Main', new HeaderField('MergeFieldsHelperTitle', _t('EmailTemplate.AVAILABLEMERGEFIELDSTITLE', 'Available merge fields')));
111
112
        $fields->addFieldToTab('Root.Main', new LiteralField('MergeFieldsHelper', $this->mergeFieldsHelper()));
113
114
        if ($this->ID) {
115
            $fields->addFieldToTab('Root.Preview', $this->previewTab());
116
        }
117
118
        // Cleanup UI
119
        $categories = EmailTemplate::get()->column('Category');
120
        $fields->addFieldToTab('Root.Settings', new DropdownField('Category', 'Category', array_combine($categories, $categories)));
121
        $fields->addFieldToTab('Root.Settings', new CheckboxField('Disabled'));
122
        $fields->addFieldToTab('Root.Settings', new TextField('DefaultSender'));
123
        $fields->addFieldToTab('Root.Settings', new TextField('DefaultRecipient'));
124
125
126
        return $fields;
127
    }
128
129
    public function canView($member = null)
130
    {
131
        return true;
132
    }
133
134
    public function canEdit($member = null)
135
    {
136
        return Permission::check('CMS_ACCESS', 'any', $member);
137
    }
138
139
    public function canCreate($member = null, $context = [])
140
    {
141
        // Should be created by developer
142
        return false;
143
    }
144
145
    public function canDelete($member = null)
146
    {
147
        return Permission::check('CMS_ACCESS', 'any', $member);
148
    }
149
150
    /**
151
     * A map of Name => Class
152
     *
153
     * User models are variables with a . that should match an existing DataObject name
154
     *
155
     * @return array
156
     */
157
    public function getAvailableModels()
158
    {
159
        $fields = ['Content', 'Callout'];
160
161
        $models = self::config()->get('default_models');
162
163
        // Build a list of non namespaced models
164
        // They are not likely to clash anyway because of their unique table name
165
        $dataobjects = ClassInfo::getValidSubClasses(DataObject::class);
166
        $map = [];
167
        foreach ($dataobjects as $k => $v) {
168
            $parts = explode('\\', $v);
169
            $name = end($parts);
170
            $map[$name] = $v;
171
        }
172
173
        foreach ($fields as $field) {
174
            // Match variables with a dot in the call, like $MyModel.SomeMethod
175
            preg_match_all('/\$([a-zA-Z]+)\./m', $this->$field ?? '', $matches);
176
177
            if (!empty($matches[1])) {
178
                // Get unique model names
179
                $arr = array_unique($matches[1]);
180
181
                foreach ($arr as $name) {
182
                    if (!isset($map[$name])) {
183
                        continue;
184
                    }
185
                    $class = $map[$name];
186
                    $singl = singleton($class);
187
                    if ($singl instanceof DataObject) {
188
                        $models[$name] = $class;
189
                    }
190
                }
191
            }
192
        }
193
194
        return $models;
195
    }
196
197
    /**
198
     * Get an email template by code
199
     *
200
     * @param string $code
201
     * @param bool $alwaysReturn
202
     * @param string $locale
203
     * @return static|null
204
     */
205
    public static function getByCode($code, $alwaysReturn = true, $locale = null): ?static
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STATIC on line 205 at column 84
Loading history...
206
    {
207
        if ($locale) {
208
            $template = FluentHelper::withLocale($locale, function () use ($code) {
209
                return EmailTemplate::get()->filter('Code', $code)->first();
210
            });
211
        } else {
212
            $template = EmailTemplate::get()->filter('Code', $code)->first();
213
        }
214
        // Always return a template
215
        if (!$template && $alwaysReturn) {
216
            $template = new EmailTemplate();
217
            $template->Subject = $code;
218
            $template->Code = $code;
219
            $template->Content = 'Replace this with your own content and untick disabled';
220
            $template->Disabled = true;
221
            $template->write();
222
        }
223
        /** @var static|null */
224
        return $template;
225
    }
226
227
    /**
228
     * A shorthand to get an email by code
229
     *
230
     * @param string $code
231
     * @param string $locale
232
     * @return BetterEmail
233
     */
234
    public static function getEmailByCode($code, $locale = null)
235
    {
236
        return self::getByCode($code, true, $locale)->getEmail();
237
    }
238
239
    public function onBeforeWrite()
240
    {
241
        parent::onBeforeWrite();
242
    }
243
244
    /**
245
     * Content of the literal field for the merge fields
246
     *
247
     * @return string
248
     */
249
    protected function mergeFieldsHelper()
250
    {
251
        $content = '<strong>Base fields:</strong><br/>';
252
        $baseFields = [
253
            'To', 'Cc', 'Bcc', 'From', 'Subject', 'Body', 'BaseURL', 'Controller'
254
        ];
255
        foreach ($baseFields as $baseField) {
256
            $content .= $baseField . ', ';
257
        }
258
        $content = trim($content, ', ') . '<br/>';
259
260
        $models = $this->getAvailableModels();
261
262
        $modelsByClass = [];
263
        $classes = [];
264
        foreach ($models as $name => $model) {
265
            $classes[] = $model;
266
            if (!isset($modelsByClass[$model])) {
267
                $modelsByClass[$model] = [];
268
            }
269
            $modelsByClass[$model][] = $name;
270
        }
271
        $classes = array_unique($classes);
272
273
        $locales = [];
274
        // if (class_exists('Fluent')) {
275
        //     $locales = Fluent::locales();
276
        // }
277
278
        foreach ($classes as $model) {
279
            if (!class_exists($model)) {
280
                continue;
281
            }
282
            /** @var string[] */
283
            $props = Config::inst()->get($model, 'db');
284
            $o = singleton($model);
285
            $content .= '<strong>' . $model . ' (' . implode(',', $modelsByClass[$model]) . '):</strong><br/>';
286
            foreach ($props as $fieldName => $fieldType) {
287
                // // Filter out locale fields
288
                // foreach ($locales as $locale) {
289
                //     if (strpos($fieldName, $locale) !== false) {
290
                //         continue;
291
                //     }
292
                // }
293
                $content .= $fieldName . ', ';
294
            }
295
296
            // We could also show methods but that may be long
297
            if (self::config()->get('helper_show_methods')) {
298
                $methods = array_diff($o->allMethodNames(true), $o->allMethodNames());
299
                foreach ($methods as $method) {
300
                    if (strpos($method, 'get') === 0) {
301
                        $content .= $method . ', ';
302
                    }
303
                }
304
            }
305
306
            $content = trim($content, ', ') . '<br/>';
307
        }
308
        $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>';
309
        return $content;
310
    }
311
312
    /**
313
     * Provide content for the Preview tab
314
     *
315
     * @return Tab
316
     */
317
    protected function previewTab()
318
    {
319
        $tab = new Tab('Preview');
320
321
        // Preview iframe
322
        $sanitisedModel =  str_replace('\\', '-', EmailTemplate::class);
323
        $adminSegment = EmailTemplatesAdmin::config()->get('url_segment');
324
        $adminBaseSegment = AdminRootController::config()->get('url_base');
325
        $iframeSrc = Director::baseURL() . $adminBaseSegment . '/' . $adminSegment . '/' . $sanitisedModel . '/PreviewEmail/?id=' . $this->ID;
326
        $iframe = new LiteralField('iframe', '<iframe src="' . $iframeSrc . '" style="width:800px;background:#fff;border:1px solid #ccc;min-height:500px;vertical-align:top"></iframe>');
327
        $tab->push($iframe);
328
329
        $env = Environment::getEnv('SS_SEND_ALL_EMAILS_TO');
330
        if ($env || Director::isDev()) {
331
            $sendTestLink = Director::baseURL() . $adminBaseSegment . '/' . $adminSegment . '/' . $sanitisedModel . '/SendTestEmailTemplate/?id=' . $this->ID . '&to=' . urlencode($env);
332
            $sendTest = new LiteralField("send_test", "<hr/><a href='$sendTestLink'>Send test email</a>");
333
            $tab->push($sendTest);
334
        }
335
336
        return $tab;
337
    }
338
339
    /**
340
     * Returns an instance of an Email with the content of the template
341
     *
342
     * @return BetterEmail
343
     */
344
    public function getEmail()
345
    {
346
        $email = Email::create();
347
        if (!$email instanceof BetterEmail) {
348
            throw new Exception("Make sure you are injecting the BetterEmail class instead of your base Email class");
349
        }
350
351
        $this->applyTemplate($email);
352
        if ($this->Disabled) {
353
            $email->setDisabled(true);
354
        }
355
        return $email;
356
    }
357
358
    /**
359
     * Returns an instance of an Email with the content tailored to the member
360
     *
361
     * @param Member $member
362
     * @return BetterEmail
363
     */
364
    public function getEmailForMember(Member $member)
365
    {
366
        $restoreLocale = null;
367
        if ($member->Locale) {
368
            $restoreLocale = i18n::get_locale();
369
            i18n::set_locale($member->Locale);
370
        }
371
372
        $email = $this->getEmail();
373
        $email->setToMember($member);
374
375
        if ($restoreLocale) {
376
            i18n::set_locale($restoreLocale);
377
        }
378
379
        return $email;
380
    }
381
382
    /**
383
     * Apply this template to the email
384
     *
385
     * @param BetterEmail $email
386
     */
387
    public function applyTemplate(&$email)
388
    {
389
        $email->setEmailTemplate($this);
390
391
        if ($this->Subject) {
392
            $email->setSubject($this->Subject);
393
        }
394
395
        // Use dbObject to handle shortcodes as well
396
        $email->setData([
397
            'EmailContent' => $this->dbObject('Content')->forTemplate(),
398
            'Callout' => $this->dbObject('Callout')->forTemplate(),
399
        ]);
400
401
        // Email are initialized with admin_email if set, we may want to use our own sender
402
        if ($this->DefaultSender) {
403
            $email->setFrom($this->DefaultSender);
404
        } else {
405
            /** @var SiteConfig|EmailTemplateSiteConfigExtension */
406
            $SiteConfig = SiteConfig::current_site_config();
407
            $email->setFrom($SiteConfig->EmailDefaultSender());
408
        }
409
        if ($this->DefaultRecipient) {
410
            $email->setTo($this->DefaultRecipient);
411
        }
412
413
        $this->extend('updateApplyTemplate', $email);
414
    }
415
416
    /**
417
     * Get rendered body
418
     *
419
     * @param bool $injectFake
420
     * @return string
421
     */
422
    public function renderTemplate($injectFake = false)
423
    {
424
        // Disable debug bar in the iframe
425
        Config::modify()->set('LeKoala\\DebugBar\\DebugBar', 'auto_inject', false);
426
427
        $email = $this->getEmail();
428
        if ($injectFake) {
429
            $email = $this->setPreviewData($email);
430
        }
431
432
        $html = $email->getRenderedBody();
433
434
        return $html;
435
    }
436
437
    /**
438
     * Inject random data into email for nicer preview
439
     *
440
     * @param BetterEmail $email
441
     * @return BetterEmail
442
     */
443
    public function setPreviewData(BetterEmail $email)
444
    {
445
        $data = [];
446
447
        // Get an array of data like ["Body" => "My content", "Callout" => "The callout..."]
448
        $emailData = $email->getData();
449
450
        // Parse the data for variables
451
        // For now, simply replace them by their name in curly braces
452
        foreach ($emailData as $k => $v) {
453
            if (!$v) {
454
                continue;
455
            }
456
457
            $matches = null;
458
459
            // This match all $Variable or $Member.Firstname kind of vars
460
            preg_match_all('/\$([a-zA-Z.]*)/', $v, $matches);
461
            if (!empty($matches[1])) {
462
                foreach ($matches[1] as $name) {
463
                    $name = trim($name, '.');
464
465
                    if (strpos($name, '.') !== false) {
466
                        // It's an object
467
                        $parts = explode('.', $name);
468
                        $objectName = array_shift($parts);
469
                        if (isset($data[$objectName])) {
470
                            $object = $data[$objectName];
471
                        } else {
472
                            $object = new ArrayData([]);
473
                        }
474
                        $curr = $object;
475
476
                        // May be recursive
477
                        foreach ($parts as $part) {
478
                            if (is_string($curr)) {
479
                                $curr = [];
480
                                $object->$part = $curr;
481
                            }
482
                            $object->$part = '{' . "$objectName.$part" . '}';
483
                            $prevPart = $part;
484
                            $curr = $object->$part;
485
                        }
486
                        $data[$objectName] = $object;
487
                    } else {
488
                        // It's a simple var
489
                        $data[$name] = '{' . $name . '}';
490
                    }
491
                }
492
            }
493
        }
494
495
        // Inject random data for known classes
496
        foreach ($this->getAvailableModels() as $name => $class) {
497
            if (!class_exists($class)) {
498
                continue;
499
            }
500
            if (singleton($class)->hasMethod('getSampleRecord')) {
501
                $o = $class::getSampleRecord();
502
            } else {
503
                $o = $class::get()->shuffle()->first();
504
            }
505
506
            if (!$o) {
507
                $o = new $class;
508
            }
509
            $data[$name] = $o;
510
        }
511
512
        foreach ($data as $name => $value) {
513
            $email->addData($name, $value);
514
        }
515
516
        return $email;
517
    }
518
}
519