Issues (3627)

app/bundles/EmailBundle/Helper/MailHelper.php (1 issue)

1
<?php
2
3
/*
4
 * @copyright   2015 Mautic Contributors. All rights reserved
5
 * @author      Mautic
6
 *
7
 * @link        http://mautic.org
8
 *
9
 * @license     GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
10
 */
11
12
namespace Mautic\EmailBundle\Helper;
13
14
use Doctrine\ORM\ORMException;
15
use Mautic\AssetBundle\Entity\Asset;
16
use Mautic\CoreBundle\Factory\MauticFactory;
17
use Mautic\CoreBundle\Helper\EmojiHelper;
18
use Mautic\EmailBundle\EmailEvents;
19
use Mautic\EmailBundle\Entity\Email;
20
use Mautic\EmailBundle\Entity\Stat;
21
use Mautic\EmailBundle\Event\EmailSendEvent;
22
use Mautic\EmailBundle\Exception\PartialEmailSendFailure;
23
use Mautic\EmailBundle\Swiftmailer\Exception\BatchQueueMaxException;
24
use Mautic\EmailBundle\Swiftmailer\Message\MauticMessage;
25
use Mautic\EmailBundle\Swiftmailer\Transport\SpoolTransport;
26
use Mautic\EmailBundle\Swiftmailer\Transport\TokenTransportInterface;
27
use Mautic\LeadBundle\Entity\Lead;
28
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
29
30
/**
31
 * Class MailHelper.
32
 */
33
class MailHelper
34
{
35
    const QUEUE_RESET_TO          = 'RESET_TO';
36
    const QUEUE_FULL_RESET        = 'FULL_RESET';
37
    const QUEUE_DO_NOTHING        = 'DO_NOTHING';
38
    const QUEUE_NOTHING_IF_FAILED = 'IF_FAILED';
39
    const QUEUE_RETURN_ERRORS     = 'RETURN_ERRORS';
40
    /**
41
     * @var MauticFactory
42
     */
43
    protected $factory;
44
45
    protected $mailer;
46
47
    protected $transport;
48
49
    /**
50
     * @var \Symfony\Bundle\FrameworkBundle\Templating\DelegatingEngine
51
     */
52
    protected $templating;
53
54
    /**
55
     * @var null
56
     */
57
    protected $dispatcher;
58
59
    /**
60
     * @var \Swift_Plugins_Loggers_ArrayLogger
61
     */
62
    protected $logger;
63
64
    /**
65
     * @var bool|MauticMessage
66
     */
67
    public $message;
68
69
    /**
70
     * @var null
71
     */
72
    protected $from;
73
74
    protected $systemFrom;
75
76
    /**
77
     * @var string
78
     */
79
    protected $returnPath;
80
81
    /**
82
     * @var array
83
     */
84
    protected $errors = [];
85
86
    /**
87
     * @var array|Lead
88
     */
89
    protected $lead;
90
91
    /**
92
     * @var bool
93
     */
94
    protected $internalSend = false;
95
96
    /**
97
     * @var null
98
     */
99
    protected $idHash;
100
101
    /**
102
     * @var bool
103
     */
104
    protected $idHashState = true;
105
106
    /**
107
     * @var bool
108
     */
109
    protected $appendTrackingPixel = false;
110
111
    /**
112
     * @var array
113
     */
114
    protected $source = [];
115
116
    /**
117
     * @var Email|null
118
     */
119
    protected $email;
120
121
    /**
122
     * @var array
123
     */
124
    protected $globalTokens = [];
125
126
    /**
127
     * @var array
128
     */
129
    protected $eventTokens = [];
130
131
    /**
132
     * Tells the helper that the transport supports tokenized emails (likely HTTP API).
133
     *
134
     * @var bool
135
     */
136
    protected $tokenizationEnabled = false;
137
138
    /**
139
     * Use queue mode when sending email through this mailer; this requires a transport that supports tokenization and the use of queue/flushQueue.
140
     *
141
     * @var bool
142
     */
143
    protected $queueEnabled = false;
144
145
    /**
146
     * @var array
147
     */
148
    protected $queuedRecipients = [];
149
150
    /**
151
     * @var array
152
     */
153
    public $metadata = [];
154
155
    /**
156
     * @var string
157
     */
158
    protected $subject = '';
159
160
    /**
161
     * @var string
162
     */
163
    protected $plainText = '';
164
165
    /**
166
     * @var bool
167
     */
168
    protected $plainTextSet = false;
169
170
    /**
171
     * @var array
172
     */
173
    protected $assets = [];
174
175
    /**
176
     * @var array
177
     */
178
    protected $attachedAssets = [];
179
180
    /**
181
     * @var array
182
     */
183
    protected $assetStats = [];
184
185
    /**
186
     * @var array
187
     */
188
    protected $headers = [];
189
190
    /**
191
     * @var array
192
     */
193
    protected $body = [
194
        'content'     => '',
195
        'contentType' => 'text/html',
196
        'charset'     => null,
197
    ];
198
199
    /**
200
     * Cache for lead owners.
201
     *
202
     * @var array
203
     */
204
    protected static $leadOwners = [];
205
206
    /**
207
     * @var bool
208
     */
209
    protected $fatal = false;
210
211
    /**
212
     * Flag whether to use only the globally set From email and name or whether to switch to mailer is owner.
213
     *
214
     * @var bool
215
     */
216
    protected $useGlobalFrom = false;
217
218
    /**
219
     * Large batch mail sends may result on timeouts with SMTP servers. This will will keep track of the number of sends and restart the connection once met.
220
     *
221
     * @var int
222
     */
223
    private $messageSentCount = 0;
224
225
    /**
226
     * Large batch mail sends may result on timeouts with SMTP servers. This will will keep track of when a transport was last started and force a restart after set number of minutes.
227
     *
228
     * @var int
229
     */
230
    private $transportStartTime;
231
232
    /**
233
     * Simply a md5 of the content so that event listeners can easily determine if the content has been changed.
234
     *
235
     * @var string
236
     */
237
    private $contentHash;
238
239
    /**
240
     * @var array
241
     */
242
    private $copies = [];
243
244
    /**
245
     * @var array
246
     */
247
    private $embedImagesReplaces = [];
248
249
    public function __construct(MauticFactory $factory, \Swift_Mailer $mailer, $from = null)
250
    {
251
        $this->factory   = $factory;
252
        $this->mailer    = $mailer;
253
        $this->transport = $mailer->getTransport();
254
255
        try {
256
            $this->logger = new \Swift_Plugins_Loggers_ArrayLogger();
257
            $this->mailer->registerPlugin(new \Swift_Plugins_LoggerPlugin($this->logger));
258
        } catch (\Exception $e) {
259
            $this->logError($e);
260
        }
261
262
        $systemFromEmail  = $factory->getParameter('mailer_from_email');
263
        $systemFromName   = $this->cleanName(
264
            $factory->getParameter('mailer_from_name')
265
        );
266
        $this->setDefaultFrom($from, [$systemFromEmail => $systemFromName]);
267
268
        $this->returnPath = $factory->getParameter('mailer_return_path');
269
270
        // Check if batching is supported by the transport
271
        if (
272
            ('memory' == $this->factory->getParameter('mailer_spool_type') && $this->transport instanceof TokenTransportInterface)
273
            || ($this->transport instanceof SpoolTransport && $this->transport->supportsTokenization())
274
        ) {
275
            $this->tokenizationEnabled = true;
276
        }
277
278
        // Set factory if supported
279
        if (method_exists($this->transport, 'setMauticFactory')) {
280
            $this->transport->setMauticFactory($factory);
281
        }
282
283
        $this->message = $this->getMessageInstance();
284
    }
285
286
    /**
287
     * Mirrors previous MauticFactory functionality.
288
     *
289
     * @param bool $cleanSlate
290
     *
291
     * @return $this
292
     */
293
    public function getMailer($cleanSlate = true)
294
    {
295
        $this->reset($cleanSlate);
296
297
        return $this;
298
    }
299
300
    /**
301
     * Mirrors previous MauticFactory functionality.
302
     *
303
     * @param bool $cleanSlate
304
     *
305
     * @return $this
306
     */
307
    public function getSampleMailer($cleanSlate = true)
308
    {
309
        $queueMode = $this->factory->getParameter('mailer_spool_type');
310
        if ('file' != $queueMode) {
311
            return $this->getMailer($cleanSlate);
312
        }
313
314
        $transport  = $this->factory->get('swiftmailer.transport.real');
315
        $mailer     = new \Swift_Mailer($transport);
316
        $mailHelper = new self($this->factory, $mailer, $this->from);
317
318
        return $mailHelper->getMailer($cleanSlate);
319
    }
320
321
    /**
322
     * Send the message.
323
     *
324
     * @param bool $dispatchSendEvent
325
     * @param bool $isQueueFlush      (a tokenized/batch send via API such as Mandrill)
326
     * @param bool $useOwnerAsMailer
327
     *
328
     * @return bool
329
     */
330
    public function send($dispatchSendEvent = false, $isQueueFlush = false, $useOwnerAsMailer = true)
331
    {
332
        if ($this->tokenizationEnabled && !empty($this->queuedRecipients) && !$isQueueFlush) {
333
            // This transport uses tokenization and queue()/flushQueue() was not used therefore use them in order
334
            // properly populate metadata for this transport
335
336
            if ($result = $this->queue($dispatchSendEvent)) {
337
                $result = $this->flushQueue(['To', 'Cc', 'Bcc'], $useOwnerAsMailer);
338
            }
339
340
            return $result;
341
        }
342
343
        // Set from email
344
        $ownerSignature = false;
345
        if (!$isQueueFlush) {
346
            if ($useOwnerAsMailer) {
347
                if ($owner = $this->getContactOwner($this->lead)) {
348
                    $this->setFrom($owner['email'], $owner['first_name'].' '.$owner['last_name'], null);
349
                    $ownerSignature = $this->getContactOwnerSignature($owner);
350
                } else {
351
                    $this->setFrom($this->from, null, null);
352
                }
353
            } elseif (!$from = $this->message->getFrom()) {
354
                $this->setFrom($this->from, null, null);
355
            }
356
        } // from is set in flushQueue
357
358
        // Set system return path if applicable
359
        if (!$isQueueFlush && ($bounceEmail = $this->generateBounceEmail())) {
360
            $this->message->setReturnPath($bounceEmail);
361
        } elseif (!empty($this->returnPath)) {
362
            $this->message->setReturnPath($this->returnPath);
363
        }
364
365
        if (empty($this->fatal)) {
366
            if (!$isQueueFlush) {
367
                // Search/replace tokens if this is not a queue flush
368
369
                // Generate tokens from listeners
370
                if ($dispatchSendEvent) {
371
                    $this->dispatchSendEvent();
372
                }
373
374
                // Queue an asset stat if applicable
375
                $this->queueAssetDownloadEntry();
376
            }
377
378
            $this->message->setSubject($this->subject);
379
            // Only set body if not empty or if plain text is empty - this ensures an empty HTML body does not show for
380
            // messages only with plain text
381
            if (!empty($this->body['content']) || empty($this->plainText)) {
382
                $this->message->setBody($this->body['content'], $this->body['contentType'], $this->body['charset']);
383
            }
384
            $this->setMessagePlainText();
385
386
            $this->setMessageHeaders();
387
388
            if (!$isQueueFlush) {
389
                // Replace token content
390
                $tokens = $this->getTokens();
391
                if ($ownerSignature) {
392
                    $tokens['{signature}'] = $ownerSignature;
393
                }
394
395
                // Set metadata if applicable
396
                if (method_exists($this->message, 'addMetadata')) {
397
                    foreach ($this->queuedRecipients as $email => $name) {
398
                        $this->message->addMetadata($email, $this->buildMetadata($name, $tokens));
399
                    }
400
                } elseif (!empty($tokens)) {
401
                    // Replace tokens
402
                    $search  = array_keys($tokens);
403
                    $replace = $tokens;
404
405
                    self::searchReplaceTokens($search, $replace, $this->message);
406
                }
407
            }
408
409
            // Attach assets
410
            if (!empty($this->assets)) {
411
                /** @var \Mautic\AssetBundle\Entity\Asset $asset */
412
                foreach ($this->assets as $asset) {
413
                    if (!in_array($asset->getId(), $this->attachedAssets)) {
414
                        $this->attachedAssets[] = $asset->getId();
415
                        $this->attachFile(
416
                            $asset->getFilePath(),
417
                            $asset->getOriginalFileName(),
418
                            $asset->getMime()
419
                        );
420
                    }
421
                }
422
            }
423
424
            try {
425
                if (!$this->transport->isStarted()) {
426
                    $this->transportStartTime = time();
427
                }
428
429
                $failures = null;
430
431
                $this->mailer->send($this->message, $failures);
432
433
                if (!empty($failures)) {
434
                    $this->errors['failures'] = $failures;
435
                    $this->logError('Sending failed for one or more recipients');
436
                }
437
438
                // Clear the log so that previous output is not associated with new errors
439
                $this->logger->clear();
440
            } catch (PartialEmailSendFailure $exception) {
441
                // Don't fail the entire message
442
                if (!empty($failures)) {
443
                    $this->errors['failures'] = $failures;
444
                    $this->logError($exception->getMessage());
445
                }
446
447
                // Clear the log so that previous output is not associated with new errors
448
                $this->logger->clear();
449
            } catch (\Exception $e) {
450
                $failures = $this->tokenizationEnabled ? array_keys($this->message->getMetadata()) : [];
451
452
                // Exception encountered when sending so all recipients are considered failures
453
                $this->errors['failures'] = array_unique(
454
                    array_merge(
455
                        $failures,
456
                        array_keys((array) $this->message->getTo()),
457
                        array_keys((array) $this->message->getCc()),
458
                        array_keys((array) $this->message->getBcc())
459
                    )
460
                );
461
462
                $this->logError($e, 'send');
463
            }
464
        }
465
466
        ++$this->messageSentCount;
467
        $this->checkIfTransportNeedsRestart();
468
469
        $error = empty($this->errors);
470
471
        if (!$isQueueFlush) {
472
            $this->createAssetDownloadEntries();
473
        } // else handled in flushQueue
474
475
        return $error;
476
    }
477
478
    /**
479
     * If batching is supported and enabled, the message will be queued and will on be sent upon flushQueue().
480
     * Otherwise, the message will be sent to the transport immediately.
481
     *
482
     * @param bool   $dispatchSendEvent
483
     * @param string $returnMode        What should happen post send/queue to $this->message after the email send is attempted.
484
     *                                  Options are:
485
     *                                  RESET_TO           resets the to recipients and resets errors
486
     *                                  FULL_RESET         creates a new MauticMessage instance and resets errors
487
     *                                  DO_NOTHING         leaves the current errors array and MauticMessage instance intact
488
     *                                  NOTHING_IF_FAILED  leaves the current errors array MauticMessage instance intact if it fails, otherwise reset_to
489
     *                                  RETURN_ERROR       return an array of [success, $errors]; only one applicable if message is queued
490
     *
491
     * @return bool|array
492
     */
493
    public function queue($dispatchSendEvent = false, $returnMode = self::QUEUE_RESET_TO)
494
    {
495
        if ($this->tokenizationEnabled) {
496
            // Dispatch event to get custom tokens from listeners
497
            if ($dispatchSendEvent) {
498
                $this->dispatchSendEvent();
499
            }
500
501
            // Metadata has to be set for each recipient
502
            foreach ($this->queuedRecipients as $email => $name) {
503
                $fromKey = 'default';
504
                $tokens  = $this->getTokens();
505
506
                if ($owner = $this->getContactOwner($this->lead)) {
507
                    $fromKey = $owner['email'];
508
509
                    // Override default signature with owner
510
                    if ($ownerSignature = $this->getContactOwnerSignature($owner)) {
511
                        $tokens['{signature}'] = $ownerSignature;
512
                    }
513
                }
514
515
                if (!isset($this->metadata[$fromKey])) {
516
                    $this->metadata[$fromKey] = [
517
                        'from'     => $owner,
518
                        'contacts' => [],
519
                    ];
520
                }
521
522
                $this->metadata[$fromKey]['contacts'][$email] = $this->buildMetadata($name, $tokens);
523
            }
524
525
            // Reset recipients
526
            $this->queuedRecipients = [];
527
528
            // Assume success
529
            return (self::QUEUE_RETURN_ERRORS) ? [true, []] : true;
530
        } else {
531
            $success = $this->send($dispatchSendEvent);
532
533
            // Reset the message for the next
534
            $this->queuedRecipients = [];
535
536
            // Reset message
537
            switch (strtoupper($returnMode)) {
538
                case self::QUEUE_RESET_TO:
539
                    $this->message->setTo([]);
540
                    $this->clearErrors();
541
                    break;
542
                case self::QUEUE_NOTHING_IF_FAILED:
543
                    if ($success) {
544
                        $this->message->setTo([]);
545
                        $this->clearErrors();
546
                    }
547
548
                    break;
549
                case self::QUEUE_FULL_RESET:
550
                    $this->message        = $this->getMessageInstance();
551
                    $this->attachedAssets = [];
552
                    $this->clearErrors();
553
                    break;
554
                case self::QUEUE_RETURN_ERRORS:
555
                    $this->message->setTo([]);
556
                    $errors = $this->getErrors();
557
558
                    $this->clearErrors();
559
560
                    return [$success, $errors];
561
                case self::QUEUE_DO_NOTHING:
562
                default:
563
                    // Nada
564
565
                    break;
566
            }
567
568
            return $success;
569
        }
570
    }
571
572
    /**
573
     * Send batched mail to mailer.
574
     *
575
     * @param array $resetEmailTypes  Array of email types to clear after flusing the queue
576
     * @param bool  $useOwnerAsMailer
577
     *
578
     * @return bool
579
     */
580
    public function flushQueue($resetEmailTypes = ['To', 'Cc', 'Bcc'], $useOwnerAsMailer = true)
581
    {
582
        // Assume true unless there was a fatal error configuring the mailer because if tokenizationEnabled is false, the send happened in queue()
583
        $flushed = empty($this->fatal);
584
        if ($this->tokenizationEnabled && count($this->metadata) && $flushed) {
585
            $errors             = $this->errors;
586
            $errors['failures'] = [];
587
            $flushed            = false;
588
589
            foreach ($this->metadata as $fromKey => $metadatum) {
590
                // Whatever is in the message "to" should be ignored as we will send to the contacts grouped by from addresses
591
                // This prevents mailers such as sparkpost from sending duplicates to contacts
592
                $this->message->setTo([]);
593
594
                $this->errors = [];
595
596
                if (!$this->useGlobalFrom && $useOwnerAsMailer && 'default' !== $fromKey) {
597
                    $this->setFrom($metadatum['from']['email'], $metadatum['from']['first_name'].' '.$metadatum['from']['last_name'], null);
598
                } else {
599
                    $this->setFrom($this->from, null, null);
600
                }
601
602
                foreach ($metadatum['contacts'] as $email => $contact) {
603
                    $this->message->addMetadata($email, $contact);
604
605
                    // Add asset stats if applicable
606
                    if (!empty($contact['leadId'])) {
607
                        $this->queueAssetDownloadEntry($email, $contact);
608
                    }
609
610
                    $this->message->addTo($email, $contact['name']);
611
                }
612
613
                $flushed = $this->send(false, true);
614
615
                // Merge errors
616
                if (isset($this->errors['failures'])) {
617
                    $errors['failures'] = array_merge($errors['failures'], $this->errors['failures']);
618
                    unset($this->errors['failures']);
619
                }
620
621
                if (!empty($this->errors)) {
622
                    $errors = array_merge($errors, $this->errors);
623
                }
624
625
                // Clear metadata for the previous recipients
626
                $this->message->clearMetadata();
627
            }
628
629
            $this->errors = $errors;
630
631
            // Clear queued to recipients
632
            $this->queuedRecipients = [];
633
            $this->metadata         = [];
634
        }
635
636
        foreach ($resetEmailTypes as $type) {
637
            $type = ucfirst($type);
638
            $this->message->{'set'.$type}([]);
639
        }
640
641
        return $flushed;
642
    }
643
644
    /**
645
     * Resets the mailer.
646
     *
647
     * @param bool $cleanSlate
648
     */
649
    public function reset($cleanSlate = true)
650
    {
651
        $this->eventTokens      = [];
652
        $this->queuedRecipients = [];
653
        $this->errors           = [];
654
        $this->lead             = null;
655
        $this->idHash           = null;
656
        $this->contentHash      = null;
657
        $this->internalSend     = false;
658
        $this->fatal            = false;
659
        $this->idHashState      = true;
660
        $this->useGlobalFrom    = false;
661
        $this->checkIfTransportNeedsRestart(true);
662
663
        $this->logger->clear();
664
665
        if ($cleanSlate) {
666
            $this->appendTrackingPixel = false;
667
            $this->queueEnabled        = false;
668
            $this->from                = $this->systemFrom;
669
            $this->headers             = [];
670
            [];
671
            $this->source              = [];
672
            $this->assets              = [];
673
            $this->globalTokens        = [];
674
            $this->assets              = [];
675
            $this->attachedAssets      = [];
676
            $this->email               = null;
677
            $this->copies              = [];
678
            $this->message             = $this->getMessageInstance();
679
            $this->subject             = '';
680
            $this->plainText           = '';
681
            $this->plainTextSet        = false;
682
            $this->body                = [
683
                'content'     => '',
684
                'contentType' => 'text/html',
685
                'charset'     => null,
686
            ];
687
        }
688
    }
689
690
    /**
691
     * Search and replace tokens
692
     * Adapted from \Swift_Plugins_DecoratorPlugin.
693
     *
694
     * @param array $search
695
     * @param array $replace
696
     */
697
    public static function searchReplaceTokens($search, $replace, \Swift_Message &$message)
698
    {
699
        // Body
700
        $body         = $message->getBody();
701
        $bodyReplaced = str_ireplace($search, $replace, $body, $updated);
702
        if ($updated) {
703
            $message->setBody($bodyReplaced);
704
        }
705
        unset($body, $bodyReplaced);
706
707
        // Subject
708
        $subject      = $message->getSubject();
709
        $bodyReplaced = str_ireplace($search, $replace, $subject, $updated);
710
711
        if ($updated) {
712
            $message->setSubject($bodyReplaced);
713
        }
714
        unset($subject, $bodyReplaced);
715
716
        // Headers
717
        /** @var \Swift_Mime_Header $header */
718
        foreach ($message->getHeaders()->getAll() as $header) {
719
            $headerBody = $header->getFieldBodyModel();
720
            if ($headerBody instanceof \DateTimeInterface) {
721
                // It's not possible to replace tokens in \DateTime objects
722
                // because they can't contain tokens
723
                continue;
724
            }
725
726
            $updated    = false;
727
            if (is_array($headerBody)) {
728
                $bodyReplaced = [];
729
                foreach ($headerBody as $key => $value) {
730
                    $count1             = $count2             = 0;
731
                    $key                = is_string($key) ? str_ireplace($search, $replace, $key, $count1) : $key;
732
                    $value              = is_string($value) ? str_ireplace($search, $replace, $value, $count2) : $value;
733
                    $bodyReplaced[$key] = $value;
734
                    if (($count1 + $count2)) {
735
                        $updated = true;
736
                    }
737
                }
738
            } else {
739
                $bodyReplaced = str_ireplace($search, $replace, $headerBody, $updated);
740
            }
741
742
            if (!empty($updated)) {
743
                $header->setFieldBodyModel($bodyReplaced);
744
            }
745
746
            unset($headerBody, $bodyReplaced);
747
        }
748
749
        // Parts (plaintext)
750
        $children = (array) $message->getChildren();
751
        /** @var \Swift_Mime_SimpleMimeEntity $child */
752
        foreach ($children as $child) {
753
            $childType  = $child->getContentType();
754
            [$type]     = sscanf($childType, '%[^/]/%s');
755
756
            if ('text' == $type) {
757
                $childBody = $child->getBody();
758
759
                $bodyReplaced = str_ireplace($search, $replace, $childBody);
760
                if ($childBody != $bodyReplaced) {
761
                    $childBody = strip_tags($bodyReplaced);
762
                    $child->setBody($childBody);
763
                }
764
            }
765
766
            unset($childBody, $bodyReplaced);
767
        }
768
    }
769
770
    /**
771
     * @return string
772
     */
773
    public static function getBlankPixel()
774
    {
775
        return 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
776
    }
777
778
    /**
779
     * Get a MauticMessage/Swift_Message instance.
780
     *
781
     * @return bool|MauticMessage
782
     */
783
    public function getMessageInstance()
784
    {
785
        try {
786
            return $this->tokenizationEnabled ? MauticMessage::newInstance() : (new \Swift_Message());
787
        } catch (\Exception $e) {
788
            $this->logError($e);
789
790
            return false;
791
        }
792
    }
793
794
    /**
795
     * Add an attachment to email.
796
     *
797
     * @param string $filePath
798
     * @param string $fileName
799
     * @param string $contentType
800
     * @param bool   $inline
801
     */
802
    public function attachFile($filePath, $fileName = null, $contentType = null, $inline = false)
803
    {
804
        if ($this->tokenizationEnabled) {
805
            // Stash attachment to be processed by the transport
806
            $this->message->addAttachment($filePath, $fileName, $contentType, $inline);
807
        } else {
808
            // filePath can contain the value of a local file path or the value of an URL where the file can be found
809
            if (filter_var($filePath, FILTER_VALIDATE_URL) || (file_exists($filePath) && is_readable($filePath))) {
810
                try {
811
                    $attachment = \Swift_Attachment::fromPath($filePath);
812
813
                    if (!empty($fileName)) {
814
                        $attachment->setFilename($fileName);
815
                    }
816
817
                    if (!empty($contentType)) {
818
                        $attachment->setContentType($contentType);
819
                    }
820
821
                    if ($inline) {
822
                        $attachment->setDisposition('inline');
823
                    }
824
825
                    $this->message->attach($attachment);
826
                } catch (\Exception $e) {
827
                    error_log($e);
828
                }
829
            }
830
        }
831
    }
832
833
    /**
834
     * @param int|Asset $asset
835
     */
836
    public function attachAsset($asset)
837
    {
838
        $model = $this->factory->getModel('asset');
839
840
        if (!$asset instanceof Asset) {
841
            $asset = $model->getEntity($asset);
842
843
            if (null == $asset) {
844
                return;
845
            }
846
        }
847
848
        if ($asset->isPublished()) {
849
            $asset->setUploadDir($this->factory->getParameter('upload_dir'));
850
            $this->assets[$asset->getId()] = $asset;
851
        }
852
    }
853
854
    /**
855
     * Use a template as the body.
856
     *
857
     * @param string $template
858
     * @param array  $vars
859
     * @param bool   $returnContent
860
     * @param null   $charset
861
     *
862
     * @return void|string
863
     */
864
    public function setTemplate($template, $vars = [], $returnContent = false, $charset = null)
865
    {
866
        if (null == $this->templating) {
867
            $this->templating = $this->factory->getTemplating();
868
        }
869
870
        $content = $this->templating->renderResponse($template, $vars)->getContent();
871
872
        unset($vars);
873
874
        if ($returnContent) {
875
            return $content;
876
        }
877
878
        $this->setBody($content, 'text/html', $charset);
879
        unset($content);
880
    }
881
882
    /**
883
     * Set subject.
884
     *
885
     * @param $subject
886
     */
887
    public function setSubject($subject)
888
    {
889
        $this->subject = $subject;
890
    }
891
892
    /**
893
     * @return string
894
     */
895
    public function getSubject()
896
    {
897
        return $this->subject;
898
    }
899
900
    /**
901
     * Set a plain text part.
902
     *
903
     * @param $content
904
     */
905
    public function setPlainText($content)
906
    {
907
        $this->plainText = $content;
908
909
        // Update the identifier for the content
910
        $this->contentHash = md5($this->body['content'].$this->plainText);
911
    }
912
913
    /**
914
     * @return string
915
     */
916
    public function getPlainText()
917
    {
918
        return $this->plainText;
919
    }
920
921
    /**
922
     * Set plain text for $this->message, replacing if necessary.
923
     */
924
    protected function setMessagePlainText()
925
    {
926
        if ($this->tokenizationEnabled && $this->plainTextSet) {
927
            // No need to find and replace since tokenization happens at the transport level
928
929
            return;
930
        }
931
932
        if ($this->plainTextSet) {
933
            $children = (array) $this->message->getChildren();
934
935
            /** @var \Swift_Mime_SimpleMimeEntity $child */
936
            foreach ($children as $child) {
937
                $childType = $child->getContentType();
938
                if ('text/plain' == $childType && $child instanceof \Swift_MimePart) {
939
                    $child->setBody($this->plainText);
940
941
                    break;
942
                }
943
            }
944
        } else {
945
            $this->message->addPart($this->plainText, 'text/plain');
946
            $this->plainTextSet = true;
947
        }
948
    }
949
950
    /**
951
     * @param        $content
952
     * @param string $contentType
953
     * @param null   $charset
954
     * @param bool   $ignoreTrackingPixel
955
     */
956
    public function setBody($content, $contentType = 'text/html', $charset = null, $ignoreTrackingPixel = false)
957
    {
958
        if ($this->factory->getParameter('mailer_convert_embed_images')) {
959
            $content = $this->convertEmbedImages($content);
960
        }
961
962
        if (!$ignoreTrackingPixel && $this->factory->getParameter('mailer_append_tracking_pixel')) {
963
            // Append tracking pixel
964
            $trackingImg = '<img height="1" width="1" src="{tracking_pixel}" alt="" />';
965
            if (false !== strpos($content, '</body>')) {
966
                $content = str_replace('</body>', $trackingImg.'</body>', $content);
967
            } else {
968
                $content .= $trackingImg;
969
            }
970
        }
971
972
        // Update the identifier for the content
973
        $this->contentHash = md5($content.$this->plainText);
974
975
        $this->body = [
976
            'content'     => $content,
977
            'contentType' => $contentType,
978
            'charset'     => $charset,
979
        ];
980
    }
981
982
    /**
983
     * @param string $content
984
     *
985
     * @return string
986
     */
987
    private function convertEmbedImages($content)
988
    {
989
        $matches = [];
990
        $content = strtr($content, $this->embedImagesReplaces);
991
        if (preg_match_all('/<img.+?src=[\"\'](.+?)[\"\'].*?>/i', $content, $matches)) {
992
            foreach ($matches[1] as $match) {
993
                if (false === strpos($match, 'cid:') && false === strpos($match, '{tracking_pixel}') && !array_key_exists($match, $this->embedImagesReplaces)) {
994
                    $this->embedImagesReplaces[$match] = $this->message->embed(\Swift_Image::fromPath($match));
995
                }
996
            }
997
            $content = strtr($content, $this->embedImagesReplaces);
998
        }
999
1000
        return $content;
1001
    }
1002
1003
    /**
1004
     * Get a copy of the raw body.
1005
     *
1006
     * @return mixed
1007
     */
1008
    public function getBody()
1009
    {
1010
        return $this->body['content'];
1011
    }
1012
1013
    /**
1014
     * Return the content identifier.
1015
     *
1016
     * @return string
1017
     */
1018
    public function getContentHash()
1019
    {
1020
        return $this->contentHash;
1021
    }
1022
1023
    /**
1024
     * Set to address(es).
1025
     *
1026
     * @param $addresses
1027
     * @param $name
1028
     *
1029
     * @return bool
1030
     */
1031
    public function setTo($addresses, $name = null)
1032
    {
1033
        $name = $this->cleanName($name);
1034
1035
        if (!is_array($addresses)) {
1036
            $addresses = [$addresses => $name];
1037
        } elseif (0 === array_keys($addresses)[0]) {
1038
            // We need an array of $email => $name pairs
1039
            $addresses = array_reduce($addresses, function ($address, $item) use ($name) {
1040
                $address[$item] = $name;
1041
1042
                return $address;
1043
            }, []);
1044
        }
1045
1046
        $this->checkBatchMaxRecipients(count($addresses));
1047
1048
        try {
1049
            $this->message->setTo($addresses);
1050
            $this->queuedRecipients = array_merge($this->queuedRecipients, $addresses);
1051
1052
            return true;
1053
        } catch (\Exception $e) {
1054
            $this->logError($e, 'to');
1055
1056
            return false;
1057
        }
1058
    }
1059
1060
    /**
1061
     * Add to address.
1062
     *
1063
     * @param string $address
1064
     * @param null   $name
1065
     *
1066
     * @return bool
1067
     */
1068
    public function addTo($address, $name = null)
1069
    {
1070
        $this->checkBatchMaxRecipients();
1071
1072
        try {
1073
            $name = $this->cleanName($name);
1074
            $this->message->addTo($address, $name);
1075
            $this->queuedRecipients[$address] = $name;
1076
1077
            return true;
1078
        } catch (\Exception $e) {
1079
            $this->logError($e, 'to');
1080
1081
            return false;
1082
        }
1083
    }
1084
1085
    /**
1086
     * Set CC address(es).
1087
     *
1088
     * @param mixed  $addresses
1089
     * @param string $name
1090
     *
1091
     * @return bool
1092
     */
1093
    public function setCc($addresses, $name = null)
1094
    {
1095
        $this->checkBatchMaxRecipients(count($addresses), 'cc');
1096
1097
        try {
1098
            $name = $this->cleanName($name);
1099
            $this->message->setCc($addresses, $name);
1100
1101
            return true;
1102
        } catch (\Exception $e) {
1103
            $this->logError($e, 'cc');
1104
1105
            return false;
1106
        }
1107
    }
1108
1109
    /**
1110
     * Add cc address.
1111
     *
1112
     * @param mixed $address
1113
     * @param null  $name
1114
     *
1115
     * @return bool
1116
     */
1117
    public function addCc($address, $name = null)
1118
    {
1119
        $this->checkBatchMaxRecipients(1, 'cc');
1120
1121
        try {
1122
            $name = $this->cleanName($name);
1123
            $this->message->addCc($address, $name);
1124
1125
            return true;
1126
        } catch (\Exception $e) {
1127
            $this->logError($e, 'cc');
1128
1129
            return false;
1130
        }
1131
    }
1132
1133
    /**
1134
     * Set BCC address(es).
1135
     *
1136
     * @param mixed  $addresses
1137
     * @param string $name
1138
     *
1139
     * @return bool
1140
     */
1141
    public function setBcc($addresses, $name = null)
1142
    {
1143
        $this->checkBatchMaxRecipients(count($addresses), 'bcc');
1144
1145
        try {
1146
            $name = $this->cleanName($name);
1147
            $this->message->setBcc($addresses, $name);
1148
1149
            return true;
1150
        } catch (\Exception $e) {
1151
            $this->logError($e, 'bcc');
1152
1153
            return false;
1154
        }
1155
    }
1156
1157
    /**
1158
     * Add bcc address.
1159
     *
1160
     * @param string $address
1161
     * @param null   $name
1162
     *
1163
     * @return bool
1164
     */
1165
    public function addBcc($address, $name = null)
1166
    {
1167
        $this->checkBatchMaxRecipients(1, 'bcc');
1168
1169
        try {
1170
            $name = $this->cleanName($name);
1171
            $this->message->addBcc($address, $name);
1172
1173
            return true;
1174
        } catch (\Exception $e) {
1175
            $this->logError($e, 'bcc');
1176
1177
            return false;
1178
        }
1179
    }
1180
1181
    /**
1182
     * @param int    $toBeAdded
1183
     * @param string $type
1184
     *
1185
     * @throws BatchQueueMaxException
1186
     */
1187
    protected function checkBatchMaxRecipients($toBeAdded = 1, $type = 'to')
1188
    {
1189
        if ($this->queueEnabled) {
1190
            // Check if max batching has been hit
1191
            $maxAllowed = $this->transport->getMaxBatchLimit();
1192
1193
            if ($maxAllowed > 0) {
1194
                $currentCount = $this->transport->getBatchRecipientCount($this->message, $toBeAdded, $type);
1195
1196
                if ($currentCount > $maxAllowed) {
1197
                    throw new BatchQueueMaxException();
1198
                }
1199
            }
1200
        }
1201
    }
1202
1203
    /**
1204
     * Set reply to address(es).
1205
     *
1206
     * @param $addresses
1207
     * @param $name
1208
     */
1209
    public function setReplyTo($addresses, $name = null)
1210
    {
1211
        try {
1212
            $name = $this->cleanName($name);
1213
            $this->message->setReplyTo($addresses, $name);
1214
        } catch (\Exception $e) {
1215
            $this->logError($e, 'reply to');
1216
        }
1217
    }
1218
1219
    /**
1220
     * Set a custom return path.
1221
     *
1222
     * @param $address
1223
     */
1224
    public function setReturnPath($address)
1225
    {
1226
        try {
1227
            $this->message->setReturnPath($address);
1228
        } catch (\Exception $e) {
1229
            $this->logError($e, 'return path');
1230
        }
1231
    }
1232
1233
    /**
1234
     * Set from email address and name (defaults to determining automatically unless isGlobal is true).
1235
     *
1236
     * @param string|array $fromEmail
1237
     * @param string       $fromName
1238
     * @param bool|null    $isGlobal
1239
     */
1240
    public function setFrom($fromEmail, $fromName = null, $isGlobal = true)
1241
    {
1242
        $fromName = $this->cleanName($fromName);
1243
1244
        if (null !== $isGlobal) {
1245
            if ($isGlobal) {
1246
                if (is_array($fromEmail)) {
1247
                    $this->from = $fromEmail;
1248
                } else {
1249
                    $this->from = [$fromEmail => $fromName];
1250
                }
1251
            } else {
1252
                // Reset the default to the system from
1253
                $this->from = $this->systemFrom;
1254
            }
1255
1256
            $this->useGlobalFrom = $isGlobal;
1257
        }
1258
1259
        try {
1260
            $this->message->setFrom($fromEmail, $fromName);
1261
        } catch (\Exception $e) {
1262
            $this->logError($e, 'from');
1263
        }
1264
    }
1265
1266
    /**
1267
     * @return string|null
1268
     */
1269
    public function getIdHash()
1270
    {
1271
        return $this->idHash;
1272
    }
1273
1274
    /**
1275
     * @param null $idHash
1276
     * @param bool $statToBeGenerated Pass false if a stat entry is not to be created
1277
     */
1278
    public function setIdHash($idHash = null, $statToBeGenerated = true)
1279
    {
1280
        if (null === $idHash) {
0 ignored issues
show
The condition null === $idHash is always true.
Loading history...
1281
            $idHash = str_replace('.', '', uniqid('', true));
1282
        }
1283
1284
        $this->idHash      = $idHash;
1285
        $this->idHashState = $statToBeGenerated;
1286
1287
        // Append pixel to body before send
1288
        $this->appendTrackingPixel = true;
1289
1290
        // Add the trackingID to the $message object in order to update the stats if the email failed to send
1291
        $this->message->leadIdHash = $idHash;
1292
    }
1293
1294
    /**
1295
     * @return array|Lead
1296
     */
1297
    public function getLead()
1298
    {
1299
        return $this->lead;
1300
    }
1301
1302
    /**
1303
     * @param array|Lead $lead
1304
     */
1305
    public function setLead($lead, $interalSend = false)
1306
    {
1307
        $this->lead         = $lead;
1308
        $this->internalSend = $interalSend;
1309
    }
1310
1311
    /**
1312
     * Check if this is not being send directly to the lead.
1313
     *
1314
     * @return bool
1315
     */
1316
    public function isInternalSend()
1317
    {
1318
        return $this->internalSend;
1319
    }
1320
1321
    /**
1322
     * @return array
1323
     */
1324
    public function getSource()
1325
    {
1326
        return $this->source;
1327
    }
1328
1329
    /**
1330
     * @param array $source
1331
     */
1332
    public function setSource($source)
1333
    {
1334
        $this->source = $source;
1335
    }
1336
1337
    /**
1338
     * @return Email|null
1339
     */
1340
    public function getEmail()
1341
    {
1342
        return $this->email;
1343
    }
1344
1345
    /**
1346
     * @param bool  $allowBcc            Honor BCC if set in email
1347
     * @param array $slots               Slots configured in theme
1348
     * @param array $assetAttachments    Assets to send
1349
     * @param bool  $ignoreTrackingPixel Do not append tracking pixel HTML
1350
     *
1351
     * @return bool Returns false if there were errors with the email configuration
1352
     */
1353
    public function setEmail(Email $email, $allowBcc = true, $slots = [], $assetAttachments = [], $ignoreTrackingPixel = false)
1354
    {
1355
        $this->email = $email;
1356
1357
        $subject = $email->getSubject();
1358
1359
        // Convert short codes to emoji
1360
        $subject = EmojiHelper::toEmoji($subject, 'short');
1361
1362
        // Set message settings from the email
1363
        $this->setSubject($subject);
1364
1365
        $fromEmail = $email->getFromAddress();
1366
        $fromName  = $email->getFromName();
1367
        if (!empty($fromEmail) || !empty($fromName)) {
1368
            if (empty($fromName)) {
1369
                $fromName = array_values($this->from)[0];
1370
            } elseif (empty($fromEmail)) {
1371
                $fromEmail = key($this->from);
1372
            }
1373
1374
            $this->setFrom($fromEmail, $fromName, null);
1375
            $this->from = [$fromEmail => $fromName];
1376
        } else {
1377
            $this->from = $this->systemFrom;
1378
        }
1379
1380
        $replyTo = $email->getReplyToAddress();
1381
        if (!empty($replyTo)) {
1382
            $addresses = explode(',', $replyTo);
1383
1384
            // Only a single email is supported
1385
            $this->setReplyTo($addresses[0]);
1386
        }
1387
1388
        if ($allowBcc) {
1389
            $bccAddress = $email->getBccAddress();
1390
            if (!empty($bccAddress)) {
1391
                $addresses = array_fill_keys(array_map('trim', explode(',', $bccAddress)), null);
1392
                foreach ($addresses as $bccAddress => $name) {
1393
                    $this->addBcc($bccAddress, $name);
1394
                }
1395
            }
1396
        }
1397
1398
        if ($plainText = $email->getPlainText()) {
1399
            $this->setPlainText($plainText);
1400
        }
1401
1402
        $BCcontent  = $email->getContent();
1403
        $customHtml = $email->getCustomHtml();
1404
        // Process emails created by Mautic v1
1405
        if (empty($customHtml) && !empty($BCcontent)) {
1406
            $template = $email->getTemplate();
1407
            if (empty($slots)) {
1408
                $template = $email->getTemplate();
1409
                $slots    = $this->factory->getTheme($template)->getSlots('email');
1410
            }
1411
1412
            if (isset($slots[$template])) {
1413
                $slots = $slots[$template];
1414
            }
1415
1416
            $this->processSlots($slots, $email);
1417
1418
            $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate(':'.$template.':email.html.php');
1419
1420
            $customHtml = $this->setTemplate($logicalName, [
1421
                'slots'    => $slots,
1422
                'content'  => $email->getContent(),
1423
                'email'    => $email,
1424
                'template' => $template,
1425
            ], true);
1426
        }
1427
1428
        // Convert short codes to emoji
1429
        $customHtml = EmojiHelper::toEmoji($customHtml, 'short');
1430
1431
        $this->setBody($customHtml, 'text/html', null, $ignoreTrackingPixel);
1432
1433
        // Reset attachments
1434
        $this->assets = $this->attachedAssets = [];
1435
        if (empty($assetAttachments)) {
1436
            if ($assets = $email->getAssetAttachments()) {
1437
                foreach ($assets as $asset) {
1438
                    $this->attachAsset($asset);
1439
                }
1440
            }
1441
        } else {
1442
            foreach ($assetAttachments as $asset) {
1443
                $this->attachAsset($asset);
1444
            }
1445
        }
1446
1447
        // Set custom headers
1448
        if ($headers = $email->getHeaders()) {
1449
            // HTML decode headers
1450
            $headers = array_map('html_entity_decode', $headers);
1451
1452
            foreach ($headers as $name => $value) {
1453
                $this->addCustomHeader($name, $value);
1454
            }
1455
        }
1456
1457
        return empty($this->errors);
1458
    }
1459
1460
    /**
1461
     * Set custom headers.
1462
     *
1463
     * @param bool $merge
1464
     */
1465
    public function setCustomHeaders(array $headers, $merge = true)
1466
    {
1467
        if ($merge) {
1468
            $this->headers = array_merge($this->headers, $headers);
1469
1470
            return;
1471
        }
1472
1473
        $this->headers = $headers;
1474
    }
1475
1476
    /**
1477
     * @param $name
1478
     * @param $value
1479
     */
1480
    public function addCustomHeader($name, $value)
1481
    {
1482
        $this->headers[$name] = $value;
1483
    }
1484
1485
    /**
1486
     * @return array
1487
     */
1488
    public function getCustomHeaders()
1489
    {
1490
        $headers = array_merge($this->headers, $this->getSystemHeaders());
1491
1492
        $listUnsubscribeHeader = $this->getUnsubscribeHeader();
1493
        if ($listUnsubscribeHeader) {
1494
            if (!empty($headers['List-Unsubscribe'])) {
1495
                if (false === strpos($headers['List-Unsubscribe'], $listUnsubscribeHeader)) {
1496
                    // Ensure Mautic's is always part of this header
1497
                    $headers['List-Unsubscribe'] .= ','.$listUnsubscribeHeader;
1498
                }
1499
            } else {
1500
                $headers['List-Unsubscribe'] = $listUnsubscribeHeader;
1501
            }
1502
        }
1503
1504
        return $headers;
1505
    }
1506
1507
    /**
1508
     * @return bool|string
1509
     */
1510
    private function getUnsubscribeHeader()
1511
    {
1512
        if ($this->idHash) {
1513
            $url = $this->factory->getRouter()->generate('mautic_email_unsubscribe', ['idHash' => $this->idHash], UrlGeneratorInterface::ABSOLUTE_URL);
1514
1515
            return "<$url>";
1516
        }
1517
1518
        if (!empty($this->queuedRecipients) || !empty($this->lead)) {
1519
            return '<{unsubscribe_url}>';
1520
        }
1521
1522
        return false;
1523
    }
1524
1525
    /**
1526
     * Append tokens.
1527
     */
1528
    public function addTokens(array $tokens)
1529
    {
1530
        $this->globalTokens = array_merge($this->globalTokens, $tokens);
1531
    }
1532
1533
    /**
1534
     * Set tokens.
1535
     */
1536
    public function setTokens(array $tokens)
1537
    {
1538
        $this->globalTokens = $tokens;
1539
    }
1540
1541
    /**
1542
     * Get tokens.
1543
     *
1544
     * @return array
1545
     */
1546
    public function getTokens()
1547
    {
1548
        $tokens = array_merge($this->globalTokens, $this->eventTokens);
1549
1550
        // Include the tracking pixel token as it's auto appended to the body
1551
        if ($this->appendTrackingPixel) {
1552
            $tokens['{tracking_pixel}'] = $this->factory->getRouter()->generate(
1553
                'mautic_email_tracker',
1554
                [
1555
                    'idHash' => $this->idHash,
1556
                ],
1557
                UrlGeneratorInterface::ABSOLUTE_URL
1558
            );
1559
        } else {
1560
            $tokens['{tracking_pixel}'] = self::getBlankPixel();
1561
        }
1562
1563
        return $tokens;
1564
    }
1565
1566
    /**
1567
     * @return array
1568
     */
1569
    public function getGlobalTokens()
1570
    {
1571
        return $this->globalTokens;
1572
    }
1573
1574
    /**
1575
     * Parses html into basic plaintext.
1576
     *
1577
     * @param string $content
1578
     */
1579
    public function parsePlainText($content = null)
1580
    {
1581
        if (null == $content) {
1582
            if (!$content = $this->message->getBody()) {
1583
                $content = $this->body['content'];
1584
            }
1585
        }
1586
1587
        $request = $this->factory->getRequest();
1588
        $parser  = new PlainTextHelper([
1589
            'base_url' => $request->getSchemeAndHttpHost().$request->getBasePath(),
1590
        ]);
1591
1592
        $this->plainText = $parser->setHtml($content)->getText();
1593
    }
1594
1595
    /**
1596
     * Enables queue mode if the transport supports tokenization.
1597
     *
1598
     * @param bool $enabled
1599
     */
1600
    public function enableQueue($enabled = true)
1601
    {
1602
        if ($this->tokenizationEnabled) {
1603
            $this->queueEnabled = $enabled;
1604
        }
1605
    }
1606
1607
    /**
1608
     * Dispatch send event to generate tokens.
1609
     *
1610
     * @return array
1611
     */
1612
    public function dispatchSendEvent()
1613
    {
1614
        if (null == $this->dispatcher) {
1615
            $this->dispatcher = $this->factory->getDispatcher();
1616
        }
1617
1618
        $event = new EmailSendEvent($this);
1619
1620
        $this->dispatcher->dispatch(EmailEvents::EMAIL_ON_SEND, $event);
1621
1622
        $this->eventTokens = array_merge($this->eventTokens, $event->getTokens(false));
1623
1624
        unset($event);
1625
    }
1626
1627
    /**
1628
     * Log exception.
1629
     *
1630
     * @param      $error
1631
     * @param null $context
1632
     */
1633
    protected function logError($error, $context = null)
1634
    {
1635
        if ($error instanceof \Exception) {
1636
            $exceptionContext = ['exception' => $error];
1637
            $errorMessage     = $error->getMessage();
1638
            $error            = ('dev' === MAUTIC_ENV) ? (string) $error : $errorMessage;
1639
1640
            // Clean up the error message
1641
            $errorMessage = trim(preg_replace('/(.*?)Log data:(.*)$/is', '$1', $errorMessage));
1642
1643
            $this->fatal = true;
1644
        } else {
1645
            $exceptionContext = [];
1646
            $errorMessage     = trim($error);
1647
        }
1648
1649
        $logDump = $this->logger->dump();
1650
        if (!empty($logDump) && false === strpos($error, $logDump)) {
1651
            $error .= " Log data: $logDump";
1652
        }
1653
1654
        if ($context) {
1655
            $error .= " ($context)";
1656
1657
            if ('send' === $context) {
1658
                $error .= '; '.implode(', ', $this->errors['failures']);
1659
            }
1660
        }
1661
1662
        $this->errors[] = $errorMessage;
1663
1664
        $this->logger->clear();
1665
1666
        $this->factory->getLogger()->log('error', '[MAIL ERROR] '.$error, $exceptionContext);
1667
    }
1668
1669
    /**
1670
     * Get list of errors.
1671
     *
1672
     * @param bool $reset Resets the error array in preparation for the next mail send or else it'll fail
1673
     *
1674
     * @return array
1675
     */
1676
    public function getErrors($reset = true)
1677
    {
1678
        $errors = $this->errors;
1679
1680
        if ($reset) {
1681
            $this->clearErrors();
1682
        }
1683
1684
        return $errors;
1685
    }
1686
1687
    /**
1688
     * Clears the errors from a previous send.
1689
     */
1690
    public function clearErrors()
1691
    {
1692
        $this->errors = [];
1693
        $this->fatal  = false;
1694
    }
1695
1696
    /**
1697
     * Return transport.
1698
     *
1699
     * @return \Swift_Transport
1700
     */
1701
    public function getTransport()
1702
    {
1703
        return $this->transport;
1704
    }
1705
1706
    /**
1707
     * Creates a download stat for the asset.
1708
     */
1709
    protected function createAssetDownloadEntries()
1710
    {
1711
        // Nothing was sent out so bail
1712
        if ($this->fatal || empty($this->assetStats)) {
1713
            return;
1714
        }
1715
1716
        if (isset($this->errors['failures'])) {
1717
            // Remove the failures from the asset queue
1718
            foreach ($this->errors['failures'] as $failed) {
1719
                unset($this->assetStats[$failed]);
1720
            }
1721
        }
1722
1723
        // Create a download entry if there is an Asset attachment
1724
        if (!empty($this->assetStats)) {
1725
            /** @var \Mautic\AssetBundle\Model\AssetModel $assetModel */
1726
            $assetModel = $this->factory->getModel('asset');
1727
            foreach ($this->assets as $asset) {
1728
                foreach ($this->assetStats as $stat) {
1729
                    $assetModel->trackDownload(
1730
                        $asset,
1731
                        null,
1732
                        200,
1733
                        $stat
1734
                    );
1735
                }
1736
1737
                $assetModel->upDownloadCount($asset, count($this->assetStats), true);
1738
            }
1739
        }
1740
1741
        // Reset the stat
1742
        $this->assetStats = [];
1743
    }
1744
1745
    /**
1746
     * Queues the details to note if a lead received an asset if no errors are generated.
1747
     *
1748
     * @param null $contactEmail
1749
     * @param null $metadata
1750
     */
1751
    protected function queueAssetDownloadEntry($contactEmail = null, array $metadata = null)
1752
    {
1753
        if ($this->internalSend || empty($this->assets)) {
1754
            return;
1755
        }
1756
1757
        if (null === $contactEmail) {
1758
            if (!$this->lead) {
1759
                return;
1760
            }
1761
1762
            $contactEmail = $this->lead['email'];
1763
            $contactId    = $this->lead['id'];
1764
            $emailId      = $this->email->getId();
1765
            $idHash       = $this->idHash;
1766
        } else {
1767
            $contactId = $metadata['leadId'];
1768
            $emailId   = $metadata['emailId'];
1769
            $idHash    = $metadata['hashId'];
1770
        }
1771
1772
        $this->assetStats[$contactEmail] = [
1773
            'lead'        => $contactId,
1774
            'email'       => $emailId,
1775
            'source'      => ['email', $emailId],
1776
            'tracking_id' => $idHash,
1777
        ];
1778
    }
1779
1780
    /**
1781
     * Returns if the mailer supports and is in tokenization mode.
1782
     *
1783
     * @return bool
1784
     */
1785
    public function inTokenizationMode()
1786
    {
1787
        return $this->tokenizationEnabled;
1788
    }
1789
1790
    /**
1791
     * @param $url
1792
     *
1793
     * @return \Mautic\PageBundle\Entity\Redirect|object|null
1794
     */
1795
    public function getTrackableLink($url)
1796
    {
1797
        // Ensure a valid URL and that it has not already been found
1798
        if ('http' !== substr($url, 0, 4) && 'ftp' !== substr($url, 0, 3)) {
1799
            return null;
1800
        }
1801
1802
        if ($this->email) {
1803
            // Get a Trackable which is channel aware
1804
            /** @var \Mautic\PageBundle\Model\TrackableModel $trackableModel */
1805
            $trackableModel = $this->factory->getModel('page.trackable');
1806
            $trackable      = $trackableModel->getTrackableByUrl($url, 'email', $this->email->getId());
1807
1808
            return $trackable->getRedirect();
1809
        }
1810
1811
        /** @var \Mautic\PageBundle\Model\RedirectModel $redirectModel */
1812
        $redirectModel = $this->factory->getModel('page.redirect');
1813
1814
        return $redirectModel->getRedirectByUrl($url);
1815
    }
1816
1817
    /**
1818
     * Create an email stat.
1819
     *
1820
     * @param bool|true   $persist
1821
     * @param string|null $emailAddress
1822
     * @param null        $listId
1823
     *
1824
     * @return Stat
1825
     */
1826
    public function createEmailStat($persist = true, $emailAddress = null, $listId = null)
1827
    {
1828
        //create a stat
1829
        $stat = new Stat();
1830
        $stat->setDateSent(new \DateTime());
1831
        $stat->setEmail($this->email);
1832
1833
        // Note if a lead
1834
        if (null !== $this->lead) {
1835
            try {
1836
                $stat->setLead($this->factory->getEntityManager()->getReference('MauticLeadBundle:Lead', $this->lead['id']));
1837
            } catch (ORMException $exception) {
1838
                // keep IDE happy
1839
            }
1840
            $emailAddress = $this->lead['email'];
1841
        }
1842
1843
        // Find email if applicable
1844
        if (null === $emailAddress) {
1845
            // Use the last address set
1846
            $emailAddresses = $this->message->getTo();
1847
1848
            if (count($emailAddresses)) {
1849
                end($emailAddresses);
1850
                $emailAddress = key($emailAddresses);
1851
            }
1852
        }
1853
        $stat->setEmailAddress($emailAddress);
1854
1855
        // Note if sent from a lead list
1856
        if (null !== $listId) {
1857
            try {
1858
                $stat->setList($this->factory->getEntityManager()->getReference('MauticLeadBundle:LeadList', $listId));
1859
            } catch (ORMException $exception) {
1860
                // keep IDE happy
1861
            }
1862
        }
1863
1864
        $stat->setTrackingHash($this->idHash);
1865
        if (!empty($this->source)) {
1866
            $stat->setSource($this->source[0]);
1867
            $stat->setSourceId($this->source[1]);
1868
        }
1869
1870
        $stat->setTokens($this->getTokens());
1871
1872
        /** @var \Mautic\EmailBundle\Model\EmailModel $emailModel */
1873
        $emailModel = $this->factory->getModel('email');
1874
1875
        // Save a copy of the email - use email ID if available simply to prevent from having to rehash over and over
1876
        $id = (null !== $this->email) ? $this->email->getId() : md5($this->subject.$this->body['content']);
1877
        if (!isset($this->copies[$id])) {
1878
            $hash = (32 !== strlen($id)) ? md5($this->subject.$this->body['content']) : $id;
1879
1880
            $copy        = $emailModel->getCopyRepository()->findByHash($hash);
1881
            $copyCreated = false;
1882
            if (null === $copy) {
1883
                $contentToPersist = strtr($this->body['content'], array_flip($this->embedImagesReplaces));
1884
                if (!$emailModel->getCopyRepository()->saveCopy($hash, $this->subject, $contentToPersist)) {
1885
                    // Try one more time to find the ID in case there was overlap when creating
1886
                    $copy = $emailModel->getCopyRepository()->findByHash($hash);
1887
                } else {
1888
                    $copyCreated = true;
1889
                }
1890
            }
1891
1892
            if ($copy || $copyCreated) {
1893
                $this->copies[$id] = $hash;
1894
            }
1895
        }
1896
1897
        if (isset($this->copies[$id])) {
1898
            try {
1899
                $stat->setStoredCopy($this->factory->getEntityManager()->getReference('MauticEmailBundle:Copy', $this->copies[$id]));
1900
            } catch (ORMException $exception) {
1901
                // keep IDE happy
1902
            }
1903
        }
1904
1905
        if ($persist) {
1906
            $emailModel->getStatRepository()->saveEntity($stat);
1907
        }
1908
1909
        return $stat;
1910
    }
1911
1912
    /**
1913
     * Check to see if a monitored email box is enabled and configured.
1914
     *
1915
     * @param $bundleKey
1916
     * @param $folderKey
1917
     *
1918
     * @return bool|array
1919
     */
1920
    public function isMontoringEnabled($bundleKey, $folderKey)
1921
    {
1922
        /** @var \Mautic\EmailBundle\MonitoredEmail\Mailbox $mailboxHelper */
1923
        $mailboxHelper = $this->factory->getHelper('mailbox');
1924
1925
        if ($mailboxHelper->isConfigured($bundleKey, $folderKey)) {
1926
            return $mailboxHelper->getMailboxSettings();
1927
        }
1928
1929
        return false;
1930
    }
1931
1932
    /**
1933
     * Generate bounce email for the lead.
1934
     *
1935
     * @param null $idHash
1936
     *
1937
     * @return bool|string
1938
     */
1939
    public function generateBounceEmail($idHash = null)
1940
    {
1941
        $monitoredEmail = false;
1942
1943
        if ($settings = $this->isMontoringEnabled('EmailBundle', 'bounces')) {
1944
            // Append the bounce notation
1945
            [$email, $domain] = explode('@', $settings['address']);
1946
            $email .= '+bounce';
1947
            if ($idHash || $this->idHash) {
1948
                $email .= '_'.($idHash ?: $this->idHash);
1949
            }
1950
            $monitoredEmail = $email.'@'.$domain;
1951
        }
1952
1953
        return $monitoredEmail;
1954
    }
1955
1956
    /**
1957
     * Generate an unsubscribe email for the lead.
1958
     *
1959
     * @param null $idHash
1960
     *
1961
     * @return bool|string
1962
     */
1963
    public function generateUnsubscribeEmail($idHash = null)
1964
    {
1965
        $monitoredEmail = false;
1966
1967
        if ($settings = $this->isMontoringEnabled('EmailBundle', 'unsubscribes')) {
1968
            // Append the bounce notation
1969
            [$email, $domain] = explode('@', $settings['address']);
1970
            $email .= '+unsubscribe';
1971
            if ($idHash || $this->idHash) {
1972
                $email .= '_'.($idHash ?: $this->idHash);
1973
            }
1974
            $monitoredEmail = $email.'@'.$domain;
1975
        }
1976
1977
        return $monitoredEmail;
1978
    }
1979
1980
    /**
1981
     * A large number of mail sends may result on timeouts with SMTP servers. This checks for the number of email sends and restarts the transport if necessary.
1982
     *
1983
     * @param bool $force
1984
     */
1985
    public function checkIfTransportNeedsRestart($force = false)
1986
    {
1987
        // Check if we should restart the SMTP transport
1988
        if ($this->transport instanceof \Swift_SmtpTransport) {
1989
            $maxNumberOfMessages = (method_exists($this->transport, 'getNumberOfMessagesTillRestart'))
1990
                ? $this->transport->getNumberOfMessagesTillRestart() : 50;
1991
1992
            $maxNumberOfMinutes = (method_exists($this->transport, 'getNumberOfMinutesTillRestart'))
1993
                ? $this->transport->getNumberOfMinutesTillRestart() : 2;
1994
1995
            $numberMinutesRunning = floor(time() - $this->transportStartTime) / 60;
1996
1997
            if ($force || $this->messageSentCount >= $maxNumberOfMessages || $numberMinutesRunning >= $maxNumberOfMinutes) {
1998
                // Stop the transport
1999
                $this->transport->stop();
2000
                $this->messageSentCount = 0;
2001
            }
2002
        }
2003
    }
2004
2005
    /**
2006
     * @param $slots
2007
     * @param Email $entity
2008
     */
2009
    public function processSlots($slots, $entity)
2010
    {
2011
        /** @var \Mautic\CoreBundle\Templating\Helper\SlotsHelper $slotsHelper */
2012
        $slotsHelper = $this->factory->getHelper('template.slots');
2013
2014
        $content = $entity->getContent();
2015
2016
        foreach ($slots as $slot => $slotConfig) {
2017
            if (is_numeric($slot)) {
2018
                $slot       = $slotConfig;
2019
                $slotConfig = [];
2020
            }
2021
2022
            $value = isset($content[$slot]) ? $content[$slot] : '';
2023
            $slotsHelper->set($slot, $value);
2024
        }
2025
    }
2026
2027
    /**
2028
     * Clean the name - if empty, set as null to ensure pretty headers.
2029
     *
2030
     * @param $name
2031
     *
2032
     * @return string|null
2033
     */
2034
    protected function cleanName($name)
2035
    {
2036
        if (null === $name) {
2037
            return $name;
2038
        }
2039
2040
        $name = trim(html_entity_decode($name, ENT_QUOTES));
2041
2042
        // If empty, replace with null so that email clients do not show empty name because of To: '' <[email protected]>
2043
        if (empty($name)) {
2044
            $name = null;
2045
        }
2046
2047
        return $name;
2048
    }
2049
2050
    /**
2051
     * @param $contact
2052
     *
2053
     * @return bool|array
2054
     */
2055
    protected function getContactOwner(&$contact)
2056
    {
2057
        $owner = false;
2058
2059
        if ($this->factory->getParameter('mailer_is_owner') && is_array($contact) && isset($contact['id'])) {
2060
            if (!isset($contact['owner_id'])) {
2061
                $contact['owner_id'] = 0;
2062
            } elseif (isset($contact['owner_id'])) {
2063
                if (isset(self::$leadOwners[$contact['owner_id']])) {
2064
                    $owner = self::$leadOwners[$contact['owner_id']];
2065
                } elseif ($owner = $this->factory->getModel('lead')->getRepository()->getLeadOwner($contact['owner_id'])) {
2066
                    self::$leadOwners[$owner['id']] = $owner;
2067
                }
2068
            }
2069
        }
2070
2071
        return $owner;
2072
    }
2073
2074
    /**
2075
     * @param $owner
2076
     *
2077
     * @return mixed
2078
     */
2079
    protected function getContactOwnerSignature($owner)
2080
    {
2081
        return empty($owner['signature'])
2082
            ? false
2083
            : EmojiHelper::toHtml(
2084
                str_replace('|FROM_NAME|', $owner['first_name'].' '.$owner['last_name'], nl2br($owner['signature']))
2085
            );
2086
    }
2087
2088
    /**
2089
     * @return array
2090
     */
2091
    private function getSystemHeaders()
2092
    {
2093
        if ($this->email) {
2094
            // We are purposively ignoring system headers if using an Email entity
2095
            return [];
2096
        }
2097
2098
        if (!$systemHeaders = $this->factory->getParameter('mailer_custom_headers', [])) {
2099
            return [];
2100
        }
2101
2102
        // HTML decode headers
2103
        $systemHeaders = array_map('html_entity_decode', $systemHeaders);
2104
2105
        return $systemHeaders;
2106
    }
2107
2108
    /**
2109
     * Merge system headers into custom headers if applicable.
2110
     */
2111
    private function setMessageHeaders()
2112
    {
2113
        $headers = $this->getCustomHeaders();
2114
2115
        // Set custom headers
2116
        if (!empty($headers)) {
2117
            $messageHeaders = $this->message->getHeaders();
2118
            foreach ($headers as $headerKey => $headerValue) {
2119
                if ($messageHeaders->has($headerKey)) {
2120
                    $header = $messageHeaders->get($headerKey);
2121
                    $header->setFieldBodyModel($headerValue);
2122
                } else {
2123
                    $messageHeaders->addTextHeader($headerKey, $headerValue);
2124
                }
2125
            }
2126
        }
2127
2128
        if (array_key_exists('List-Unsubscribe', $headers)) {
2129
            unset($headers['List-Unsubscribe']);
2130
            $this->setCustomHeaders($headers, false);
2131
        }
2132
    }
2133
2134
    /**
2135
     * @param $name
2136
     *
2137
     * @return array
2138
     */
2139
    private function buildMetadata($name, array $tokens)
2140
    {
2141
        return [
2142
            'name'        => $name,
2143
            'leadId'      => (!empty($this->lead)) ? $this->lead['id'] : null,
2144
            'emailId'     => (!empty($this->email)) ? $this->email->getId() : null,
2145
            'emailName'   => (!empty($this->email)) ? $this->email->getName() : null,
2146
            'hashId'      => $this->idHash,
2147
            'hashIdState' => $this->idHashState,
2148
            'source'      => $this->source,
2149
            'tokens'      => $tokens,
2150
            'utmTags'     => (!empty($this->email)) ? $this->email->getUtmTags() : [],
2151
        ];
2152
    }
2153
2154
    /**
2155
     * Validates a given address to ensure RFC 2822, 3.6.2 specs.
2156
     *
2157
     * @deprecated 2.11.0 to be removed in 3.0; use Mautic\EmailBundle\Helper\EmailValidator
2158
     *
2159
     * @param $address
2160
     *
2161
     * @throws \Swift_RfcComplianceException
2162
     */
2163
    public static function validateEmail($address)
2164
    {
2165
        $invalidChar = strpbrk($address, '\'^&*%');
2166
2167
        if (false !== $invalidChar) {
2168
            throw new \Swift_RfcComplianceException('Email address ['.$address.'] contains this invalid character: '.substr($invalidChar, 0, 1));
2169
        }
2170
2171
        if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
2172
            throw new \Swift_RfcComplianceException('Email address ['.$address.'] is invalid');
2173
        }
2174
    }
2175
2176
    /**
2177
     * @param $overrideFrom
2178
     */
2179
    private function setDefaultFrom($overrideFrom, array $systemFrom)
2180
    {
2181
        if (is_array($overrideFrom)) {
2182
            $fromEmail         = key($overrideFrom);
2183
            $fromName          = $this->cleanName($overrideFrom[$fromEmail]);
2184
            $overrideFrom      = [$fromEmail => $fromName];
2185
        } elseif (!empty($overrideFrom)) {
2186
            $overrideFrom = [$overrideFrom => null];
2187
        }
2188
2189
        $this->systemFrom = $overrideFrom ?: $systemFrom;
2190
        $this->from       = $this->systemFrom;
2191
    }
2192
}
2193