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
Bug
introduced
by
![]() |
|||
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 |