Passed
Push — master ( 61227e...8e24af )
by Thomas
02:01
created

BetterEmail::setData()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 4
eloc 10
c 1
b 1
f 0
nc 4
nop 1
dl 0
loc 15
rs 9.9332
1
<?php
2
3
namespace LeKoala\EmailTemplates\Email;
4
5
use Exception;
6
use Swift_MimePart;
7
use BadMethodCallException;
8
use SilverStripe\i18n\i18n;
9
use SilverStripe\Control\HTTP;
10
use SilverStripe\View\SSViewer;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\Security\Member;
13
use SilverStripe\Control\Director;
14
use SilverStripe\Security\Security;
15
use SilverStripe\View\Requirements;
16
use SilverStripe\Control\Controller;
17
use SilverStripe\Core\Config\Config;
18
use SilverStripe\Control\Email\Email;
19
use SilverStripe\SiteConfig\SiteConfig;
20
use LeKoala\EmailTemplates\Models\SentEmail;
21
use LeKoala\EmailTemplates\Helpers\EmailUtils;
22
use LeKoala\EmailTemplates\Models\EmailTemplate;
23
use LeKoala\EmailTemplates\Helpers\SubsiteHelper;
24
25
/**
26
 * An improved and more pleasant base Email class to use on your project
27
 *
28
 * This class is fully decoupled from the EmailTemplate class and keep be used
29
 * independantly
30
 *
31
 * Improvements are:
32
 *
33
 * - URL safe rewriting
34
 * - Configurable base template (base system use Email class with setHTMLTemplate to provide content)
35
 * - Send email according to member locale
36
 * - Check for subject
37
 * - Send to member or admin
38
 * - Persist emails
39
 * - Parse body (multi part body is supported)
40
 * - Plaintext takes template into account
41
 * - Disable emails
42
 * - Unified send methods that support hooks
43
 *
44
 * @author lekoala
45
 */
46
class BetterEmail extends Email
47
{
48
    const STATE_CANCELLED = 'cancelled';
49
    const STATE_NOT_SENT = 'not_sent';
50
    const STATE_SENT = 'sent';
51
    const STATE_FAILED = 'failed';
52
53
    /**
54
     * @var EmailTemplate
55
     */
56
    protected $emailTemplate;
57
58
    /**
59
     * @var string
60
     */
61
    protected $locale;
62
63
    /**
64
     * @var Member
65
     */
66
    protected $to_member;
67
68
    /**
69
     * @var Member
70
     */
71
    protected $from_member;
72
73
    /**
74
     * @var boolean
75
     */
76
    protected $disabled = false;
77
78
    /**
79
     * @var SentMail
0 ignored issues
show
Bug introduced by
The type LeKoala\EmailTemplates\Email\SentMail was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
80
     */
81
    protected $sentMail = null;
82
83
    /**
84
     * @var boolean
85
     */
86
    protected $sendingCancelled = false;
87
88
    /**
89
     * Email constructor.
90
     * @param string|array|null $from
91
     * @param string|array|null $to
92
     * @param string|null $subject
93
     * @param string|null $body
94
     * @param string|array|null $cc
95
     * @param string|array|null $bcc
96
     * @param string|null $returnPath
97
     */
98
    public function __construct(
99
        $from = null,
100
        $to = null,
101
        $subject = null,
102
        $body = null,
103
        $cc = null,
104
        $bcc = null,
105
        $returnPath = null
106
    ) {
107
        parent::__construct($from, $to, $subject, $body, $cc, $bcc, $returnPath);
108
109
        // Use template as a layout
110
        if ($defaultTemplate = self::config()->template) {
111
            // Call method because variable is private
112
            parent::setHTMLTemplate($defaultTemplate);
113
        }
114
    }
115
116
    /**
117
     * Persists the email to the database
118
     *
119
     * @param bool|array $results
120
     * @return SentEmail
121
     */
122
    protected function persist($results)
123
    {
124
        $record = SentEmail::create(array(
125
            'To' => EmailUtils::format_email_addresses($this->getTo()),
126
            'From' => EmailUtils::format_email_addresses($this->getFrom()),
127
            'ReplyTo' => $this->getReplyTo(),
128
            'Subject' => $this->getSubject(),
129
            'Body' => $this->getRenderedBody(),
130
            'Headers' => $this->getSwiftMessage()->getHeaders()->toString(),
131
            'CC' => EmailUtils::format_email_addresses($this->getCC()),
132
            'BCC' => EmailUtils::format_email_addresses($this->getBCC()),
133
            'Results' => json_encode($results),
134
        ));
135
        $record->write();
136
137
        // TODO: migrate this to a cron task
138
        SentEmail::cleanup();
139
140
        return $record;
141
    }
142
143
144
    /**
145
     * Get body of message after rendering
146
     * Useful for previews
147
     *
148
     * @return string
149
     */
150
    public function getRenderedBody()
151
    {
152
        $this->render();
153
        return $this->getSwiftMessage()->getBody();
154
    }
155
156
    /**
157
     * Don't forget that setBody will erase content of html template
158
     * Prefer to use this instead. Basically you can replace setBody calls with this method
159
     * URLs are rewritten by render process
160
     *
161
     * @param string $body
162
     * @return $this
163
     */
164
    public function addBody($body)
165
    {
166
        return $this->addData("EmailContent", $body);
167
    }
168
169
    /**
170
     * @param array|ViewableData $data The template data to set
0 ignored issues
show
Bug introduced by
The type LeKoala\EmailTemplates\Email\ViewableData was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
171
     * @return $this
172
     */
173
    public function setData($data)
174
    {
175
        // Merge data!
176
        if ($this->emailTemplate) {
177
            if (is_array($data)) {
178
                parent::addData($data);
179
            } elseif ($data instanceof DataObject) {
180
                parent::addData($data->toMap());
181
            } else {
182
                parent::setData($data);
183
            }
184
        } else {
185
            parent::setData($data);
186
        }
187
        return $this;
188
    }
189
190
    /**
191
     * Sends a HTML email
192
     *
193
     * @return bool true if successful or array of failed recipients
194
     */
195
    public function send()
196
    {
197
        return $this->doSend(false);
198
    }
199
200
    /**
201
     * Sends a plain text email
202
     *
203
     * @return bool true if successful or array of failed recipients
204
     */
205
    public function sendPlain()
206
    {
207
        return $this->doSend(true);
208
    }
209
210
    /**
211
     * Send this email
212
     *
213
     * @param bool $plain
214
     * @return bool true if successful or array of failed recipients
215
     * @throws Exception
216
     */
217
    public function doSend($plain = false)
218
    {
219
        if ($this->disabled) {
220
            $this->sendingCancelled = true;
221
            return false;
222
        }
223
224
        // Check for Subject
225
        if (!$this->getSubject()) {
226
            throw new BadMethodCallException('You must set a subject');
227
        }
228
229
        // This hook can prevent email from being sent
230
        $result = $this->extend('onBeforeDoSend', $this);
231
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
232
            $this->sendingCancelled = true;
233
            return false;
234
        }
235
236
        $SiteConfig = SiteConfig::current_site_config();
237
238
        // Check for Sender and use default if necessary
239
        $from = $this->getFrom();
240
        if (empty($from)) {
241
            $this->addFrom($SiteConfig->EmailDefaultSender());
242
        }
243
244
        // Check for Recipient and use default if necessary
245
        $to = $this->getTo();
246
        if (empty($to)) {
247
            $this->addTo($SiteConfig->EmailDefaultRecipient());
248
        }
249
250
        // Set language to use for the email
251
        $restore_locale = null;
252
        if ($this->locale) {
253
            $restore_locale = i18n::get_locale();
254
            i18n::set_locale($this->locale);
255
        }
256
257
        $member = $this->to_member;
258
        if ($member) {
259
            // Maybe this member doesn't want to receive emails?
260
            if ($member->hasMethod('canReceiveEmails') && !$member->canReceiveEmails()) {
261
                return false;
262
            }
263
        }
264
265
        // Make sure we have a full render with current locale
266
        if ($this->emailTemplate) {
267
            $this->clearBody();
268
        }
269
270
        if ($plain) {
271
            // sendPlain will trigger our updated generatePlainPartFromBody
272
            $res = parent::sendPlain();
273
        } else {
274
            $res = parent::send();
275
        }
276
277
        if ($restore_locale) {
278
            i18n::set_locale($restore_locale);
279
        }
280
281
        $this->extend('onAfterDoSend', $this, $res);
282
        $this->sentMail = $this->persist($res);
283
284
        return $res;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $res also could return the type array which is incompatible with the documented return type boolean.
Loading history...
285
    }
286
287
    /**
288
     * Returns one of the STATE_xxxx constant
289
     *
290
     * @return string
291
     */
292
    public function getSendStatus()
293
    {
294
        if ($this->sendingCancelled) {
295
            return self::STATE_CANCELLED;
296
        }
297
        if ($this->sentMail) {
298
            if ($this->sentMail->IsSuccess()) {
299
                return self::STATE_SENT;
300
            }
301
            return self::STATE_FAILED;
302
        }
303
        return self::STATE_NOT_SENT;
304
    }
305
306
    /**
307
     * Was sending cancelled ?
308
     *
309
     * @return bool
310
     */
311
    public function getSendingCancelled()
312
    {
313
        return $this->sendingCancelled;
314
    }
315
316
    /**
317
     * The last result from "send" method. Null if not sent yet or sending was cancelled
318
     *
319
     * @return SentMail
320
     */
321
    public function getSentMail()
322
    {
323
        return $this->sentMail;
324
    }
325
326
    /**
327
     * Automatically adds a plain part to the email generated from the current Body
328
     *
329
     * @return $this
330
     */
331
    public function generatePlainPartFromBody()
332
    {
333
        $plainPart = $this->findPlainPart();
334
        if ($plainPart) {
335
            $this->getSwiftMessage()->detach($plainPart);
336
        }
337
        unset($plainPart);
338
339
        $this->getSwiftMessage()->addPart(
340
            EmailUtils::convert_html_to_text($this->getBody()),
341
            'text/plain',
342
            'utf-8'
343
        );
344
345
        return $this;
346
    }
347
348
    /**
349
     * @return $this
350
     */
351
    public function clearBody()
352
    {
353
        $this->getSwiftMessage()->setBody(null);
354
        return $this;
355
    }
356
357
    /**
358
     * Set the template to render the email with
359
     *
360
     * This method is overidden in order to look for email templates to provide
361
     * content to
362
     *
363
     * @param string $template
364
     * @return $this
365
     */
366
    public function setHTMLTemplate($template)
367
    {
368
        if (substr($template, -3) == '.ss') {
369
            $template = substr($template, 0, -3);
370
        }
371
372
        // Do we have a custom template matching this code?
373
        $code = self::makeTemplateCode($template);
374
        $emailTemplate = EmailTemplate::getByCode($code, false);
375
        if ($emailTemplate) {
0 ignored issues
show
introduced by
$emailTemplate is of type LeKoala\EmailTemplates\Models\EmailTemplate, thus it always evaluated to true.
Loading history...
376
            $emailTemplate->applyTemplate($this);
377
            return $this;
378
        }
379
380
        // If not, keep default behaviour (call method because var is private)
381
        return parent::setHTMLTemplate($template);
382
    }
383
384
    /**
385
     * Make a template code
386
     *
387
     * @param string $str
388
     * @return string
389
     */
390
    public static function makeTemplateCode($str)
391
    {
392
        // If we get a class name
393
        $parts = explode('\\', $str);
394
        $str = end($parts);
395
        $code = preg_replace('/Email$/', '', $str);
396
        return $code;
397
    }
398
399
    /**
400
     * Helper method to render string with data
401
     *
402
     * @param string $content
403
     * @return string
404
     */
405
    public function renderWithData($content)
406
    {
407
        $viewer = SSViewer::fromString($content);
408
        $data = $this->getData();
409
        // SSViewer_DataPresenter requires array
410
        if (is_object($data)) {
411
            if (method_exists($data, 'toArray')) {
412
                $data = $data->toArray();
413
            } else {
414
                $data = (array) $data;
415
            }
416
        }
417
        $result = (string) $viewer->process($this, $data);
418
        $result = self::rewriteURLs($result);
419
        return $result;
420
    }
421
422
    /**
423
     * Render the email
424
     * @param bool $plainOnly Only render the message as plain text
425
     * @return $this
426
     */
427
    public function render($plainOnly = false)
428
    {
429
        if ($existingPlainPart = $this->findPlainPart()) {
430
            $this->getSwiftMessage()->detach($existingPlainPart);
431
        }
432
        unset($existingPlainPart);
433
434
        // Respect explicitly set body
435
        $htmlPart = $plainPart = null;
436
437
        // Only respect if we don't have an email template
438
        if ($this->emailTemplate) {
439
            $htmlPart = $plainOnly ? null : $this->getBody();
440
            $plainPart = $plainOnly ? $this->getBody() : null;
441
        }
442
443
        // Ensure we can at least render something
444
        $htmlTemplate = $this->getHTMLTemplate();
445
        $plainTemplate = $this->getPlainTemplate();
446
        if (!$htmlTemplate && !$plainTemplate && !$plainPart && !$htmlPart) {
447
            return $this;
448
        }
449
450
        // Do not interfere with emails styles
451
        Requirements::clear();
452
453
        // Render plain part
454
        if ($plainTemplate && !$plainPart) {
455
            $plainPart = $this->renderWith($plainTemplate, $this->getData())->Plain();
0 ignored issues
show
Bug introduced by
It seems like $this->getData() can also be of type SilverStripe\View\ViewableData; however, parameter $customFields of SilverStripe\View\ViewableData::renderWith() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

455
            $plainPart = $this->renderWith($plainTemplate, /** @scrutinizer ignore-type */ $this->getData())->Plain();
Loading history...
456
            // Do another round of rendering to render our variables inside
457
            $plainPart = $this->renderWithData($plainPart);
458
        }
459
460
        // Render HTML part, either if sending html email, or a plain part is lacking
461
        if (!$htmlPart && $htmlTemplate && (!$plainOnly || empty($plainPart))) {
462
            $htmlPart = $this->renderWith($htmlTemplate, $this->getData());
463
            // Do another round of rendering to render our variables inside
464
            $htmlPart = $this->renderWithData($htmlPart);
465
        }
466
467
        // Render subject with data as well
468
        $subject = $this->renderWithData($this->getSubject());
469
        parent::setSubject($subject);
470
471
        // Plain part fails over to generated from html
472
        if (!$plainPart && $htmlPart) {
473
            $plainPart = EmailUtils::convert_html_to_text($htmlPart);
474
        }
475
476
        // Rendering is finished
477
        Requirements::restore();
478
479
        // Fail if no email to send
480
        if (!$plainPart && !$htmlPart) {
481
            return $this;
482
        }
483
484
        // Build HTML / Plain components
485
        if ($htmlPart && !$plainOnly) {
486
            $this->setBody($htmlPart);
487
            $this->getSwiftMessage()->setContentType('text/html');
488
            $this->getSwiftMessage()->setCharset('utf-8');
489
            if ($plainPart) {
490
                $this->getSwiftMessage()->addPart($plainPart, 'text/plain', 'utf-8');
491
            }
492
        } else {
493
            if ($plainPart) {
494
                $this->setBody($plainPart);
495
            }
496
            $this->getSwiftMessage()->setContentType('text/plain');
497
            $this->getSwiftMessage()->setCharset('utf-8');
498
        }
499
500
        return $this;
501
    }
502
503
    /**
504
     * Get locale set before email is sent
505
     *
506
     * @return string
507
     */
508
    public function getLocale()
509
    {
510
        return $this->locale;
511
    }
512
513
    /**
514
     *  Set locale to set before email is sent
515
     *
516
     * @param string $val
517
     */
518
    public function setLocale($val)
519
    {
520
        $this->locale = $val;
521
    }
522
523
    /**
524
     * Is this email disabled ?
525
     *
526
     * @return boolean
527
     */
528
    public function getDisabled()
529
    {
530
        return $this->disabled;
531
    }
532
533
    /**
534
     * Disable this email (sending will have no effect)
535
     *
536
     * @param bool $disabled
537
     * @return $this
538
     */
539
    public function setDisabled($disabled)
540
    {
541
        $this->disabled = (bool) $disabled;
542
        return $this;
543
    }
544
545
    /**
546
     * Get recipient as member
547
     *
548
     * @return Member
549
     */
550
    public function getToMember()
551
    {
552
        if (!$this->to_member && $this->to) {
0 ignored issues
show
Bug Best Practice introduced by
The property to does not exist on LeKoala\EmailTemplates\Email\BetterEmail. Since you implemented __get, consider adding a @property annotation.
Loading history...
553
            $email = EmailUtils::get_email_from_rfc_email($this->to);
554
            $member = Member::get()->filter(array('Email' => $email))->first();
555
            if ($member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
556
                $this->setToMember($member);
557
            }
558
        }
559
        return $this->to_member;
560
    }
561
562
    /**
563
     * Set recipient(s) of the email
564
     *
565
     * To send to many, pass an array:
566
     * array('[email protected]' => 'My Name', '[email protected]');
567
     *
568
     * @param string|array $address The message recipient(s) - if sending to multiple, use an array of address => name
569
     * @param string|null $name The name of the recipient (if one)
570
     * @return $this
571
     */
572
    public function setTo($address, $name = null)
573
    {
574
        // Make sure this doesn't conflict with to_member property
575
        if ($this->to_member) {
576
            $this->to_member = null;
577
        }
578
        return parent::setTo($address, $name);
579
    }
580
581
    /**
582
     * @param string $subject The Subject line for the email
583
     * @return $this
584
     */
585
    public function setSubject($subject)
586
    {
587
        // Do not allow changing subject if a template is set
588
        if ($this->emailTemplate && $this->getSubject()) {
589
            return $this;
590
        }
591
        return parent::setSubject($subject);
592
    }
593
594
    /**
595
     * Send to admin
596
     *
597
     * @return Email
598
     */
599
    public function setToAdmin()
600
    {
601
        return $this->setToMember(Security::findAnAdministrator());
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Security\Se...::findAnAdministrator() has been deprecated: 4.0.0:5.0.0 Please use DefaultAdminService::findOrCreateDefaultAdmin() ( Ignorable by Annotation )

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

601
        return $this->setToMember(/** @scrutinizer ignore-deprecated */ Security::findAnAdministrator());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
602
    }
603
604
    /**
605
     * Set a member as a recipient.
606
     *
607
     * It will also set the $Recipient variable in the template
608
     *
609
     * @param Member $member
610
     * @param string $locale Locale to use, set to false to keep current locale
611
     * @return BetterEmail
612
     */
613
    public function setToMember(Member $member, $locale = null)
614
    {
615
        if ($locale === null) {
616
            $this->locale = $member->Locale;
617
        } else {
618
            $this->locale = $locale;
619
        }
620
        $this->to_member = $member;
621
622
        $this->addData(array('Recipient' => $member));
623
624
        return parent::setTo($member->Email, $member->getTitle());
625
    }
626
627
    /**
628
     * Get sender as member
629
     *
630
     * @return Member
631
     */
632
    public function getFromMember()
633
    {
634
        if (!$this->from_member && $this->from) {
0 ignored issues
show
Bug Best Practice introduced by
The property from does not exist on LeKoala\EmailTemplates\Email\BetterEmail. Since you implemented __get, consider adding a @property annotation.
Loading history...
635
            $email = EmailUtils::get_email_from_rfc_email($this->from);
636
            $member = Member::get()->filter(array('Email' => $email))->first();
637
            if ($member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
638
                $this->setFromMember($member);
639
            }
640
        }
641
        return $this->from_member;
642
    }
643
644
    /**
645
     * Set From Member
646
     *
647
     * It will also set the $Sender variable in the template
648
     *
649
     * @param Member $member
650
     * @return BetterEmail
651
     */
652
    public function setFromMember(Member $member)
653
    {
654
        $this->from_member = $member;
655
656
        $this->addData(array('Sender' => $member));
657
658
        return $this->setFrom($member->Email);
659
    }
660
661
    /**
662
     * Bug safe absolute url that support subsites
663
     *
664
     * @param string $url
665
     * @param bool $relativeToSiteBase
666
     * @return string
667
     */
668
    protected static function safeAbsoluteURL($url, $relativeToSiteBase = false)
669
    {
670
        if (empty($url)) {
671
            return Director::baseURL();
672
        }
673
        $absUrl = Director::absoluteURL($url, $relativeToSiteBase);
674
675
        // If we use subsite, absolute url may not use the proper url
676
        if (SubsiteHelper::usesSubsite()) {
677
            $subsite = SubsiteHelper::currentSubsite();
678
            if ($subsite->hasMethod('getPrimarySubsiteDomain')) {
679
                $domain = $subsite->getPrimarySubsiteDomain();
680
                $link = $subsite->domain();
681
                $protocol = $domain->getFullProtocol();
682
            } else {
683
                $protocol = Director::protocol();
684
                $link = $subsite->domain();
685
            }
686
            $absUrl = preg_replace('/\/\/[^\/]+\//', '//' . $link . '/', $absUrl);
687
            $absUrl = preg_replace('/http(s)?:\/\//', $protocol, $absUrl);
688
        }
689
690
        return $absUrl;
691
    }
692
693
    /**
694
     * Turn all relative URLs in the content to absolute URLs
695
     */
696
    protected static function rewriteURLs($html)
697
    {
698
        if (isset($_SERVER['REQUEST_URI'])) {
699
            $html = str_replace('$CurrentPageURL', $_SERVER['REQUEST_URI'], $html);
700
        }
701
        return HTTP::urlRewriter($html, function ($url) {
702
            //no need to rewrite, if uri has a protocol (determined here by existence of reserved URI character ":")
703
            if (preg_match('/^\w+:/', $url)) {
704
                return $url;
705
            }
706
            return self::safeAbsoluteURL($url, true);
707
        });
708
    }
709
710
    /**
711
     * Get the value of emailTemplate
712
     * @return EmailTemplate
713
     */
714
    public function getEmailTemplate()
715
    {
716
        return $this->emailTemplate;
717
    }
718
719
    /**
720
     * Set the value of emailTemplate
721
     *
722
     * @param EmailTemplate $emailTemplate
723
     * @return $this
724
     */
725
    public function setEmailTemplate(EmailTemplate $emailTemplate)
726
    {
727
        $this->emailTemplate = $emailTemplate;
728
        return $this;
729
    }
730
}
731