Passed
Push — master ( 4707af...d36b17 )
by Thomas
03:29
created

SparkPostSwiftTransport::isStarted()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
namespace LeKoala\SparkPost;
4
5
use Exception;
6
use \Swift_MimePart;
7
use \Swift_Transport;
8
use \Swift_Attachment;
9
use \Swift_Mime_SimpleMessage;
10
use \Swift_Mime_Headers_UnstructuredHeader;
11
use \Swift_Events_SendEvent;
12
use \Swift_Mime_Header;
13
use Psr\Log\LoggerInterface;
14
use \Swift_Events_EventListener;
15
use SilverStripe\Control\Director;
16
use SilverStripe\Assets\FileNameFilter;
17
use SilverStripe\Core\Injector\Injector;
18
use LeKoala\SparkPost\Api\SparkPostApiClient;
19
20
/**
21
 * A SparkPost transport for Swift Mailer using our custom client
22
 *
23
 * Heavily inspired by slowprog/SparkPostSwiftMailer
24
 *
25
 * @link https://github.com/slowprog/SparkPostSwiftMailer
26
 * @link https://www.sparkpost.com/api#/reference/introduction
27
 * @author LeKoala <[email protected]>
28
 */
29
class SparkPostSwiftTransport implements Swift_Transport
30
{
31
32
    /**
33
     * @var Swift_Transport_SimpleMailInvoker
0 ignored issues
show
Bug introduced by
The type LeKoala\SparkPost\Swift_...sport_SimpleMailInvoker was not found. Did you mean Swift_Transport_SimpleMailInvoker? If so, make sure to prefix the type with \.
Loading history...
34
     */
35
    protected $invoker;
36
37
    /**
38
     * @var Swift_Events_SimpleEventDispatcher
0 ignored issues
show
Bug introduced by
The type LeKoala\SparkPost\Swift_...s_SimpleEventDispatcher was not found. Did you mean Swift_Events_SimpleEventDispatcher? If so, make sure to prefix the type with \.
Loading history...
39
     */
40
    protected $eventDispatcher;
41
42
    /**
43
     * @var LeKoala\SparkPost\Api\SparkPostApiClient
0 ignored issues
show
Bug introduced by
The type LeKoala\SparkPost\LeKoal...\Api\SparkPostApiClient was not found. Did you mean LeKoala\SparkPost\Api\SparkPostApiClient? If so, make sure to prefix the type with \.
Loading history...
44
     */
45
    protected $client;
46
47
    /**
48
     * @var array
49
     */
50
    protected $resultApi;
51
52
    /**
53
     * @var string
54
     */
55
    protected $fromEmail;
56
57
    /**
58
     * @var boolean
59
     */
60
    protected $isStarted = false;
61
62
    public function __construct(SparkPostApiClient $client)
63
    {
64
        $this->client = $client;
0 ignored issues
show
Documentation Bug introduced by
It seems like $client of type LeKoala\SparkPost\Api\SparkPostApiClient is incompatible with the declared type LeKoala\SparkPost\LeKoal...\Api\SparkPostApiClient of property $client.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
65
66
        $this->invoker = new \Swift_Transport_SimpleMailInvoker();
0 ignored issues
show
Documentation Bug introduced by
It seems like new Swift_Transport_SimpleMailInvoker() of type Swift_Transport_SimpleMailInvoker is incompatible with the declared type LeKoala\SparkPost\Swift_...sport_SimpleMailInvoker of property $invoker.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
67
        $this->eventDispatcher = new \Swift_Events_SimpleEventDispatcher();
0 ignored issues
show
Documentation Bug introduced by
It seems like new Swift_Events_SimpleEventDispatcher() of type Swift_Events_SimpleEventDispatcher is incompatible with the declared type LeKoala\SparkPost\Swift_...s_SimpleEventDispatcher of property $eventDispatcher.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
68
    }
69
70
    /**
71
     * Not used
72
     */
73
    public function isStarted()
74
    {
75
        return $this->isStarted;
76
    }
77
78
    /**
79
     * Not used
80
     */
81
    public function start()
82
    {
83
        $this->isStarted = true;
84
    }
85
86
    /**
87
     * Not used
88
     */
89
    public function stop()
90
    {
91
        $this->isStarted = false;
92
    }
93
94
    public function ping()
95
    {
96
        return true;
97
    }
98
99
    /**
100
     * @param Swift_Mime_SimpleMessage $message
101
     * @param string[] $failedRecipients
102
     * @return int Number of messages sent
103
     */
104
    public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null)
105
    {
106
        $this->resultApi = null;
107
        if ($event = $this->eventDispatcher->createSendEvent($this, $message)) {
108
            $this->eventDispatcher->dispatchEvent($event, 'beforeSendPerformed');
109
            if ($event->bubbleCancelled()) {
110
                return 0;
111
            }
112
        }
113
114
        $sendCount = 0;
0 ignored issues
show
Unused Code introduced by
The assignment to $sendCount is dead and can be removed.
Loading history...
115
        $disableSending = $message->getHeaders()->has('X-SendingDisabled') || SparkPostHelper::config()->disable_sending;
116
117
        $transmissionData = $this->getTransmissionFromMessage($message);
118
119
        /* @var $client LeKoala\SparkPost\Api\SparkPostApiClient */
120
        $client = $this->client;
121
122
        if ($disableSending) {
123
            $result = [
124
                'total_rejected_recipients' => 0,
125
                'total_accepted_recipients' => 1,
126
                'id' => uniqid(),
127
                'disabled' => true,
128
            ];
129
        } else {
130
            $result = $client->createTransmission($transmissionData);
131
        }
132
        $this->resultApi = $result;
133
134
        if (SparkPostHelper::config()->enable_logging) {
135
            $this->logMessageContent($message, $result);
136
        }
137
138
        $sendCount = $this->resultApi['total_accepted_recipients'];
139
140
        // We don't know which recipients failed, so simply add fromEmail since it's the only one we know
141
        if ($this->resultApi['total_rejected_recipients'] > 0) {
142
            $failedRecipients[] = $this->fromEmail;
143
        }
144
145
        if ($event) {
146
            if ($sendCount > 0) {
147
                $event->setResult(Swift_Events_SendEvent::RESULT_SUCCESS);
148
            } else {
149
                $event->setResult(Swift_Events_SendEvent::RESULT_FAILED);
150
            }
151
152
            $this->eventDispatcher->dispatchEvent($event, 'sendPerformed');
153
        }
154
155
        return $sendCount;
156
    }
157
158
    /**
159
     * Log message content
160
     *
161
     * @param Swift_Mime_SimpleMessage $message
162
     * @param array $results Results from the api
163
     * @return void
164
     */
165
    protected function logMessageContent(Swift_Mime_SimpleMessage $message, $results = [])
166
    {
167
        $subject = $message->getSubject();
168
        $body = $message->getBody();
169
        $contentType = $this->getMessagePrimaryContentType($message);
170
171
        $logContent = $body;
172
173
        // Append some extra information at the end
174
        $logContent .= '<hr><pre>Debug infos:' . "\n\n";
175
        $logContent .= 'To : ' . print_r($message->getTo(), true) . "\n";
0 ignored issues
show
Bug introduced by
Are you sure print_r($message->getTo(), true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

175
        $logContent .= 'To : ' . /** @scrutinizer ignore-type */ print_r($message->getTo(), true) . "\n";
Loading history...
176
        $logContent .= 'Subject : ' . $subject . "\n";
177
        $logContent .= 'From : ' . print_r($message->getFrom(), true) . "\n";
0 ignored issues
show
Bug introduced by
Are you sure print_r($message->getFrom(), true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

177
        $logContent .= 'From : ' . /** @scrutinizer ignore-type */ print_r($message->getFrom(), true) . "\n";
Loading history...
178
        $logContent .= 'Headers:' . "\n";
179
        foreach ($message->getHeaders()->getAll() as $header) {
180
            $logContent .= '  ' . $header->getFieldName() . ': ' . $header->getFieldBody() . "\n";
181
        }
182
        if (!empty($params['recipients'])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $params seems to never exist and therefore empty should always be true.
Loading history...
183
            $logContent .= 'Recipients : ' . print_r($message->getTo(), true) . "\n";
184
        }
185
        $logContent .= 'Results:' . "\n";
186
        foreach ($results as $resultKey => $resultValue) {
187
            $logContent .= '  ' . $resultKey . ': ' . $resultValue . "\n";
188
        }
189
        $logContent .= '</pre>';
190
191
        $logFolder = SparkPostHelper::getLogFolder();
192
193
        // Generate filename
194
        $filter = new FileNameFilter();
195
        $title = substr($filter->filter($subject), 0, 35);
196
        $logName = date('Ymd_His') . '_' . $title;
197
198
        // Store attachments if any
199
        $attachments = $message->getChildren();
200
        if (!empty($attachments)) {
201
            $logContent .= '<hr />';
202
            foreach ($attachments as $attachment) {
203
                if ($attachment instanceof Swift_Attachment) {
204
                    $attachmentDestination = $logFolder . '/' . $logName . '_' . $attachment->getFilename();
205
                    file_put_contents($attachmentDestination, $attachment->getBody());
206
                    $logContent .= 'File : <a href="' . $attachmentDestination . '">' . $attachment->getFilename() . '</a><br/>';
207
                }
208
            }
209
        }
210
211
        // Store it
212
        $ext = ($contentType == 'text/html') ? 'html' : 'txt';
213
        $r = file_put_contents($logFolder . '/' . $logName . '.' . $ext, $logContent);
214
215
        if (!$r && Director::isDev()) {
216
            throw new Exception('Failed to store email in ' . $logFolder);
217
        }
218
    }
219
220
    /**
221
     * @return LoggerInterface
222
     */
223
    public function getLogger()
224
    {
225
        return Injector::inst()->get(LoggerInterface::class)->withName('SparkPost');
226
    }
227
228
    /**
229
     * @param Swift_Events_EventListener $plugin
230
     */
231
    public function registerPlugin(Swift_Events_EventListener $plugin)
232
    {
233
        $this->eventDispatcher->bindEventListener($plugin);
234
    }
235
236
    /**
237
     * @return array
238
     */
239
    protected function getSupportedContentTypes()
240
    {
241
        return array(
242
            'text/plain',
243
            'text/html'
244
        );
245
    }
246
247
    /**
248
     * @param string $contentType
249
     * @return bool
250
     */
251
    protected function supportsContentType($contentType)
252
    {
253
        return in_array($contentType, $this->getSupportedContentTypes());
254
    }
255
256
    /**
257
     * @param Swift_Mime_SimpleMessage $message
258
     * @return string
259
     */
260
    protected function getMessagePrimaryContentType(Swift_Mime_SimpleMessage $message)
261
    {
262
        $contentType = $message->getContentType();
263
264
        if ($this->supportsContentType($contentType)) {
265
            return $contentType;
266
        }
267
268
        // SwiftMailer hides the content type set in the constructor of Swift_Mime_SimpleMessage as soon
269
        // as you add another part to the message. We need to access the protected property
270
        // _userContentType to get the original type.
271
        $messageRef = new \ReflectionClass($message);
272
        if ($messageRef->hasProperty('_userContentType')) {
273
            $propRef = $messageRef->getProperty('_userContentType');
274
            $propRef->setAccessible(true);
275
            $contentType = $propRef->getValue($message);
276
        }
277
278
        return $contentType;
279
    }
280
281
    /**
282
     * @param Swift_Mime_Headers_UnstructuredHeader|null $header
283
     * @return string
284
     */
285
    protected static function getHeaderValue(Swift_Mime_Header $header = null)
286
    {
287
        if (!$header) {
288
            return '';
289
        }
290
        if (method_exists($header, 'getValue')) {
291
            return $header->getValue();
292
        }
293
        return $header->getFieldBody();
294
    }
295
296
    /**
297
     * Convert a Swift Message to a transmission
298
     *
299
     * @param Swift_Mime_SimpleMessage $message
300
     * @return array SparkPost Send Message
301
     * @throws \Swift_SwiftException
302
     */
303
    public function getTransmissionFromMessage(Swift_Mime_SimpleMessage $message)
304
    {
305
        $contentType = $this->getMessagePrimaryContentType($message);
306
307
        $fromAddresses = $message->getFrom();
308
309
        $fromFirstEmail = key($fromAddresses);
0 ignored issues
show
Bug introduced by
It seems like $fromAddresses can also be of type null; however, parameter $array of key() does only seem to accept array|object, 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

309
        $fromFirstEmail = key(/** @scrutinizer ignore-type */ $fromAddresses);
Loading history...
310
        $fromFirstName = current($fromAddresses);
0 ignored issues
show
Bug introduced by
It seems like $fromAddresses can also be of type null; however, parameter $array of current() does only seem to accept array|object, 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

310
        $fromFirstName = current(/** @scrutinizer ignore-type */ $fromAddresses);
Loading history...
311
        if (SparkPostHelper::config()->override_admin_email && SparkPostHelper::isAdminEmail($fromFirstEmail)) {
312
            $fromFirstEmail = SparkPostHelper::resolveDefaultFromEmail();
313
        }
314
        if (SparkPostHelper::getEnvForceSender()) {
315
            $fromFirstEmail = SparkPostHelper::getEnvForceSender();
316
        }
317
        if (!$fromFirstName) {
318
            $fromFirstName = EmailUtils::get_displayname_from_rfc_email($fromFirstEmail);
319
        }
320
        $this->fromEmail = $fromFirstEmail;
321
322
        $toAddresses = $message->getTo();
323
        $ccAddresses = $message->getCc() ? $message->getCc() : [];
324
        $bccAddresses = $message->getBcc() ? $message->getBcc() : [];
325
        $replyToAddresses = $message->getReplyTo() ? $message->getReplyTo() : [];
326
327
        $recipients = array();
328
        $cc = array();
329
        $bcc = array();
330
        $attachments = array();
331
        $headers = array();
332
        $tags = array();
333
        $metadata = array();
334
        $inlineCss = null;
335
336
        // Mandrill compatibility
337
        // Data is merge with transmission and removed from headers
338
        // @link https://mailchimp.com/developer/transactional/docs/tags-metadata/#tags
339
        if ($message->getHeaders()->has('X-MC-Tags')) {
340
            $tagsHeader = $message->getHeaders()->get('X-MC-Tags');
341
            $tags = explode(',', self::getHeaderValue($tagsHeader));
342
            $message->getHeaders()->remove('X-MC-Tags');
343
        }
344
        if ($message->getHeaders()->has('X-MC-Metadata')) {
345
            $metadataHeader = $message->getHeaders()->get('X-MC-Metadata');
346
            $metadata = json_decode(self::getHeaderValue($metadataHeader), JSON_OBJECT_AS_ARRAY);
0 ignored issues
show
Bug introduced by
LeKoala\SparkPost\JSON_OBJECT_AS_ARRAY of type integer is incompatible with the type boolean|null expected by parameter $associative of json_decode(). ( Ignorable by Annotation )

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

346
            $metadata = json_decode(self::getHeaderValue($metadataHeader), /** @scrutinizer ignore-type */ JSON_OBJECT_AS_ARRAY);
Loading history...
347
            $message->getHeaders()->remove('X-MC-Metadata');
348
        }
349
        if ($message->getHeaders()->has('X-MC-InlineCSS')) {
350
            $inlineHeader = $message->getHeaders()->get('X-MC-InlineCSS');
351
            $inlineCss = self::getHeaderValue($inlineHeader);
352
            $message->getHeaders()->remove('X-MC-InlineCSS');
353
        }
354
355
        // Handle MSYS headers
356
        // Data is merge with transmission and removed from headers
357
        // @link https://developers.sparkpost.com/api/smtp-api.html
358
        $msysHeader = [];
359
        if ($message->getHeaders()->has('X-MSYS-API')) {
360
            $msysHeaderObj = $message->getHeaders()->get('X-MSYS-API');
361
            $msysHeader = json_decode(self::getHeaderValue($msysHeaderObj), JSON_OBJECT_AS_ARRAY);
362
            if (!empty($msysHeader['tags'])) {
363
                $tags = array_merge($tags, $msysHeader['tags']);
364
            }
365
            if (!empty($msysHeader['metadata'])) {
366
                $metadata = array_merge($metadata, $msysHeader['metadata']);
367
            }
368
            $message->getHeaders()->remove('X-MSYS-API');
369
        }
370
371
        // Build recipients list
372
        // @link https://developers.sparkpost.com/api/recipient-lists.html
373
        $primaryEmail = null;
374
        foreach ($toAddresses as $toEmail => $toName) {
375
            if ($primaryEmail === null) {
376
                $primaryEmail = $toEmail;
377
            }
378
            if (!$toName) {
379
                $toName = $toEmail;
380
            }
381
            $recipient = array(
382
                'address' => array(
383
                    'email' => $toEmail,
384
                    'name' => $toName,
385
                )
386
            );
387
            if (!empty($tags)) {
388
                $recipient['tags'] = $tags;
389
            }
390
            // TODO: metadata are not valid?
391
            if (!empty($metadata)) {
392
                $recipient['metadata'] = $metadata;
393
            }
394
            $recipients[] = $recipient;
395
        }
396
397
        $reply_to = null;
398
        foreach ($replyToAddresses as $replyToEmail => $replyToName) {
399
            if ($replyToName) {
400
                $reply_to = sprintf('%s <%s>', $replyToName, $replyToEmail);
401
            } else {
402
                $reply_to = $replyToEmail;
403
            }
404
        }
405
406
        // @link https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
407
        foreach ($ccAddresses as $ccEmail => $ccName) {
408
            $cc[] = array(
409
                'email' => $ccEmail,
410
                'name' => $ccName,
411
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
412
            );
413
        }
414
415
        foreach ($bccAddresses as $bccEmail => $bccName) {
416
            $bcc[] = array(
417
                'email' => $bccEmail,
418
                'name' => $bccName,
419
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $ccEmail seems to be defined by a foreach iteration on line 407. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
420
            );
421
        }
422
423
        $bodyHtml = $bodyText = null;
424
425
        if ($contentType === 'text/plain') {
426
            $bodyText = $message->getBody();
427
        } elseif ($contentType === 'text/html') {
428
            $bodyHtml = $message->getBody();
429
        } else {
430
            $bodyHtml = $message->getBody();
431
        }
432
433
        foreach ($message->getChildren() as $child) {
434
            if ($child instanceof Swift_Attachment) {
435
                $attachments[] = array(
436
                    'type' => $child->getContentType(),
437
                    'name' => $child->getFilename(),
438
                    'data' => base64_encode($child->getBody())
439
                );
440
            } elseif ($child instanceof Swift_MimePart && $this->supportsContentType($child->getContentType())) {
441
                if ($child->getContentType() == "text/html") {
442
                    $bodyHtml = $child->getBody();
443
                } elseif ($child->getContentType() == "text/plain") {
444
                    $bodyText = $child->getBody();
445
                }
446
            }
447
        }
448
449
        // If we ask to provide plain, use our custom method instead of the provided one
450
        if ($bodyHtml && SparkPostHelper::config()->provide_plain) {
451
            $bodyText = EmailUtils::convert_html_to_text($bodyHtml);
452
        }
453
454
        // Should we inline css
455
        if (!$inlineCss && SparkPostHelper::config()->inline_styles) {
456
            $bodyHtml = EmailUtils::inline_styles($bodyHtml);
457
        }
458
459
        // Custom unsubscribe list
460
        if ($message->getHeaders()->has('List-Unsubscribe')) {
461
            $unsubHeader = $message->getHeaders()->get('List-Unsubscribe');
462
            $headers['List-Unsubscribe'] = self::getHeaderValue($unsubHeader);
463
        }
464
465
        $defaultParams = SparkPostHelper::config()->default_params;
466
        if ($inlineCss !== null) {
467
            $defaultParams['inline_css'] = $inlineCss;
468
        }
469
470
        // Build base transmission
471
        $sparkPostMessage = array(
472
            'recipients' => $recipients,
473
            'content' => array(
474
                'from' => array(
475
                    'name' => $fromFirstName,
476
                    'email' => $fromFirstEmail,
477
                ),
478
                'subject' => $message->getSubject(),
479
                'html' => $bodyHtml,
480
                'text' => $bodyText,
481
            ),
482
        );
483
        if ($reply_to) {
484
            $sparkPostMessage['reply_to'] = $reply_to;
485
        }
486
487
        // Add default params
488
        $sparkPostMessage = array_merge($defaultParams, $sparkPostMessage);
489
        if ($msysHeader) {
490
            $sparkPostMessage = array_merge($sparkPostMessage, $msysHeader);
491
        }
492
493
        // Add remaining elements
494
        if (!empty($cc)) {
495
            $sparkPostMessage['headers.CC'] = $cc;
496
        }
497
        if (!empty($headers)) {
498
            $sparkPostMessage['customHeaders'] = $headers;
499
        }
500
        if (count($attachments) > 0) {
501
            $sparkPostMessage['attachments'] = $attachments;
502
        }
503
504
        return $sparkPostMessage;
505
    }
506
507
    /**
508
     * @return null|array
509
     */
510
    public function getResultApi()
511
    {
512
        return $this->resultApi;
513
    }
514
}
515