Completed
Push — master ( 51a61d...db77c9 )
by Chris
03:04
created

SparkPostCourier   C

Complexity

Total Complexity 57

Size/Duplication

Total Lines 480
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 57
eloc 189
dl 0
loc 480
ccs 167
cts 167
cp 1
rs 5.04
c 0
b 0
f 0

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 2
A supportsContent() 0 9 3
B buildTemplateData() 0 39 9
A prepareContent() 0 24 4
A prepareEmail() 0 20 4
A deliver() 0 12 2
A supportedContent() 0 6 1
A send() 0 16 2
A getContentHeaders() 0 13 3
A buildAttachments() 0 13 2
B buildTemplateContent() 0 60 10
A buildCcHeader() 0 5 1
A getInlineContent() 0 13 3
A buildInlineAttachments() 0 13 2
A buildSimpleContent() 0 32 5
A getTemplate() 0 16 2
A createAddress() 0 6 1
A buildHeaderTo() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like SparkPostCourier often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SparkPostCourier, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Courier;
6
7
use Courier\Exceptions\TransmissionException;
8
use Courier\Exceptions\UnsupportedContentException;
9
use Courier\Exceptions\ValidationException;
10
use PhpEmail\Address;
11
use PhpEmail\Content;
12
use PhpEmail\Email;
13
use Psr\Log\LoggerInterface;
14
use Psr\Log\NullLogger;
15
use SparkPost\SparkPost;
16
use SparkPost\SparkPostException;
17
use SparkPost\SparkPostResponse;
18
19
/**
20
 * A courier implementation using SparkPost as the third-party provider. This library uses the web API and the
21
 * php-sparkpost library to send transmissions.
22
 *
23
 * An important note is that while the SparkPost API does not support sending attachments on templated transmissions,
24
 * this API simulates the feature by creating an inline template based on the defined template using the API. In this
25
 * case, all template variables will be sent as expected, but tracking/reporting may not work as expected within
26
 * SparkPost.
27
 */
28
class SparkPostCourier implements ConfirmingCourier
29
{
30
    use SavesReceipts;
31
32
    const RECIPIENTS        = 'recipients';
33
    const CC                = 'cc';
34
    const BCC               = 'bcc';
35
    const REPLY_TO          = 'reply_to';
36
    const SUBSTITUTION_DATA = 'substitution_data';
37
38
    const CONTENT       = 'content';
39
    const FROM          = 'from';
40
    const SUBJECT       = 'subject';
41
    const HTML          = 'html';
42
    const TEXT          = 'text';
43
    const INLINE_IMAGES = 'inline_images';
44
    const ATTACHMENTS   = 'attachments';
45
    const TEMPLATE_ID   = 'template_id';
46
47
    const HEADERS   = 'headers';
48
    const CC_HEADER = 'CC';
49
50
    const ADDRESS       = 'address';
51
    const CONTACT_NAME  = 'name';
52
    const CONTACT_EMAIL = 'email';
53
    const HEADER_TO     = 'header_to';
54
55
    const ATTACHMENT_NAME = 'name';
56
    const ATTACHMENT_TYPE = 'type';
57
    const ATTACHMENT_DATA = 'data';
58
59
    /**
60
     * @var SparkPost
61
     */
62
    private $sparkPost;
63
64
    /**
65
     * @var LoggerInterface
66
     */
67
    private $logger;
68
69
    /**
70
     * @param SparkPost       $sparkPost
71
     * @param LoggerInterface $logger
72
     */
73 14
    public function __construct(SparkPost $sparkPost, LoggerInterface $logger = null)
74
    {
75 14
        $this->sparkPost = $sparkPost;
76 14
        $this->logger    = $logger ?: new NullLogger();
77
    }
78
79 14
    public function deliver(Email $email): void
80
    {
81 14
        if (!$this->supportsContent($email->getContent())) {
82 1
            throw new UnsupportedContentException($email->getContent());
83
        }
84
85 13
        $mail = $this->prepareEmail($email);
86 13
        $mail = $this->prepareContent($email, $mail);
87
88 11
        $response = $this->send($mail);
89
90 10
        $this->saveReceipt($email, $response->getBody()['results']['id']);
91
    }
92
93
    /**
94
     * @param array $mail
95
     *
96
     * @return SparkPostResponse
97
     */
98 11
    protected function send(array $mail): SparkPostResponse
99
    {
100 11
        $promise = $this->sparkPost->transmissions->post($mail);
101
102
        try {
103 11
            return $promise->wait();
104 1
        } catch (SparkPostException $e) {
105 1
            $this->logger->error(
106 1
                'Received status {code} from SparkPost with body: {body}',
107
                [
108 1
                    'code' => $e->getCode(),
109 1
                    'body' => $e->getBody(),
110
                ]
111
            );
112
113 1
            throw new TransmissionException($e->getCode(), $e);
114
        }
115
    }
116
117
    /**
118
     * @return array
119
     */
120 14
    protected function supportedContent(): array
121
    {
122
        return [
123 14
            Content\EmptyContent::class,
124
            Content\Contracts\SimpleContent::class,
125
            Content\Contracts\TemplatedContent::class,
126
        ];
127
    }
128
129
    /**
130
     * Determine if the content is supported by this courier.
131
     *
132
     * @param Content $content
133
     *
134
     * @return bool
135
     */
136 14
    protected function supportsContent(Content $content): bool
137
    {
138 14
        foreach ($this->supportedContent() as $contentType) {
139 14
            if ($content instanceof $contentType) {
140 14
                return true;
141
            }
142
        }
143
144 1
        return false;
145
    }
146
147
    /**
148
     * @param Email $email
149
     *
150
     * @return array
151
     */
152 13
    protected function prepareEmail(Email $email): array
153
    {
154 13
        $message  = [];
155 13
        $headerTo = $this->buildHeaderTo($email);
156
157 13
        $message[self::RECIPIENTS] = [];
158
159 13
        foreach ($email->getToRecipients() as $recipient) {
160 13
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
161
        }
162
163 13
        foreach ($email->getCcRecipients() as $recipient) {
164 5
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
165
        }
166
167 13
        foreach ($email->getBccRecipients() as $recipient) {
168 3
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
169
        }
170
171 13
        return $message;
172
    }
173
174
    /**
175
     * @param Email $email
176
     * @param array $message
177
     *
178
     * @return array
179
     */
180 13
    protected function prepareContent(Email $email, array $message): array
181
    {
182
        switch (true) {
183 13
            case $email->getContent() instanceof Content\Contracts\TemplatedContent:
184 8
                $message[self::CONTENT]           = $this->buildTemplateContent($email);
185 6
                $message[self::SUBSTITUTION_DATA] = $this->buildTemplateData($email);
186
187 6
                break;
188
189 5
            case $email->getContent() instanceof Content\EmptyContent:
190 2
                $email->setContent(new Content\SimpleContent(
191 2
                    new Content\SimpleContent\Message(''),
192 2
                    new Content\SimpleContent\Message('')
193
                ));
194
195 2
                $message[self::CONTENT] = $this->buildSimpleContent($email);
196
197 2
                break;
198
199 3
            case $email->getContent() instanceof Content\SimpleContent:
200 3
                $message[self::CONTENT] = $this->buildSimpleContent($email);
201
        }
202
203 11
        return $message;
204
    }
205
206
    /**
207
     * Attempt to create template data using the from, subject and reply to, which SparkPost considers to be
208
     * part of the templates substitutable content.
209
     *
210
     * @param Email $email
211
     *
212
     * @return array
213
     */
214 6
    protected function buildTemplateData(Email $email): array
215
    {
216
        /** @var Content\TemplatedContent $emailContent */
217 6
        $emailContent = $email->getContent();
218 6
        $templateData = $emailContent->getTemplateData();
219
220 6
        if ($email->getReplyTos()) {
221 4
            $replyTos = $email->getReplyTos();
222 4
            $first    = reset($replyTos);
223
224 4
            if (!array_key_exists('replyTo', $templateData)) {
225 4
                $templateData['replyTo'] = $first->toRfc2822();
226
            }
227
        }
228
229 6
        if (!array_key_exists('fromName', $templateData)) {
230 6
            $templateData['fromName'] = $email->getFrom()->getName();
231
        }
232
233 6
        if (!array_key_exists('fromEmail', $templateData)) {
234 6
            $templateData['fromEmail'] = explode('@', $email->getFrom()->getEmail())[0];
235
        }
236
237 6
        if (!array_key_exists('fromDomain', $templateData)) {
238 6
            $templateData['fromDomain'] = explode('@', $email->getFrom()->getEmail())[1];
239
        }
240
241 6
        if (!array_key_exists('subject', $templateData)) {
242 6
            $templateData['subject'] = $email->getSubject();
243
        }
244
245
        // @TODO Remove this variable once SparkPost CC headers work properly for templates
246 6
        if (!array_key_exists('ccHeader', $templateData)) {
247 6
            if ($header = $this->buildCcHeader($email)) {
248 3
                $templateData['ccHeader'] = $header;
249
            }
250
        }
251
252 6
        return $templateData;
253
    }
254
255
    /**
256
     * @param Email $email
257
     *
258
     * @return array
259
     */
260 8
    protected function buildTemplateContent(Email $email): array
261
    {
262
        $content = [
263 8
            self::TEMPLATE_ID => $email->getContent()->getTemplateId(),
264
        ];
265
266 8
        if ($headers = $this->getContentHeaders($email)) {
267 3
            $content[self::HEADERS] = $headers;
268
        }
269
270 8
        if ($email->getAttachments()) {
271
            /*
272
             * SparkPost does not currently support sending attachments with templated emails. For this reason,
273
             * we will instead get the template from SparkPost and create a new inline template using the information
274
             * from it instead.
275
             */
276 6
            $template    = $this->getTemplate($email);
277 5
            $inlineEmail = clone $email;
278
279
            $inlineEmail
280 5
                ->setSubject($template[self::SUBJECT])
281 5
                ->setContent($this->getInlineContent($template));
282
283
            // If the from contains a templated from, it should be actively replaced now to avoid validation errors.
284 5
            if (strpos($template[self::FROM][self::CONTACT_EMAIL], '{{') !== false) {
285 3
                $inlineEmail->setFrom($email->getFrom());
286
            } else {
287 2
                $inlineEmail->setFrom(
288 2
                    new Address(
289 2
                        $template[self::FROM][self::CONTACT_EMAIL],
290 2
                        $template[self::FROM][self::CONTACT_NAME]
291
                    )
292
                );
293
            }
294
295
            // If the form contains a templated replyTo, it should be actively replaced now to avoid validation errors.
296 5
            if (array_key_exists(self::REPLY_TO, $template)) {
297 5
                if (strpos($template[self::REPLY_TO], '{{') !== false) {
298 2
                    if (empty($email->getReplyTos())) {
299 1
                        throw new ValidationException('Reply to is templated but no value was given');
300
                    }
301
302 1
                    $inlineEmail->setReplyTos($email->getReplyTos()[0]);
303
                } else {
304 3
                    $inlineEmail->setReplyTos(Address::fromString($template[self::REPLY_TO]));
305
                }
306
            }
307
308 4
            $content = $this->buildSimpleContent($inlineEmail);
309
310
            // If the template AND content include headers, merge them
311
            // if only the template includes headers, then just use that
312 4
            if (array_key_exists(self::HEADERS, $template) && array_key_exists(self::HEADERS, $content)) {
313 2
                $content[self::HEADERS] = array_merge($template[self::HEADERS], $content[self::HEADERS]);
0 ignored issues
show
Bug introduced by
It seems like $content[self::HEADERS] can also be of type string; however, parameter $array2 of array_merge() does only seem to accept array|null, 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

313
                $content[self::HEADERS] = array_merge($template[self::HEADERS], /** @scrutinizer ignore-type */ $content[self::HEADERS]);
Loading history...
314 2
            } elseif (array_key_exists(self::HEADERS, $template)) {
315 2
                $content[self::HEADERS] = $template[self::HEADERS];
316
            }
317
        }
318
319 6
        return $content;
320
    }
321
322
    /**
323
     * @param Email $email
324
     *
325
     * @return array
326
     */
327 9
    protected function buildSimpleContent(Email $email): array
328
    {
329 9
        $replyTo = null;
330 9
        if (!empty($email->getReplyTos())) {
331
            // SparkPost only supports a single reply-to
332 5
            $replyTos = $email->getReplyTos();
333 5
            $first    = reset($replyTos);
334
335 5
            $replyTo = $first->toRfc2822();
336
        }
337
338
        /** @var Content\Contracts\SimpleContent $emailContent */
339 9
        $emailContent = $email->getContent();
340
341
        $content = [
342 9
            self::FROM          => [
343 9
                self::CONTACT_NAME  => $email->getFrom()->getName(),
344 9
                self::CONTACT_EMAIL => $email->getFrom()->getEmail(),
345
            ],
346 9
            self::SUBJECT       => $email->getSubject(),
347 9
            self::HTML          => $emailContent->getHtml() !== null ? $emailContent->getHtml()->getBody() : null,
348 9
            self::TEXT          => $emailContent->getText() !== null ? $emailContent->getText()->getBody() : null,
349 9
            self::ATTACHMENTS   => $this->buildAttachments($email),
350 9
            self::INLINE_IMAGES => $this->buildInlineAttachments($email),
351 9
            self::REPLY_TO      => $replyTo,
352
        ];
353
354 9
        if ($headers = $this->getContentHeaders($email)) {
355 4
            $content[self::HEADERS] = $headers;
356
        }
357
358 9
        return $content;
359
    }
360
361 13
    protected function getContentHeaders(Email $email): array
362
    {
363 13
        $headers = [];
364
365 13
        if ($ccHeader = $this->buildCcHeader($email)) {
366 5
            $headers[self::CC_HEADER] = $ccHeader;
367
        }
368
369 13
        foreach ($email->getHeaders() as $header) {
370 3
            $headers[$header->getField()] = $header->getValue();
371
        }
372
373 13
        return $headers;
374
    }
375
376
    /**
377
     * Create the SimpleContent based on the SparkPost template data.
378
     *
379
     * @param array $template
380
     *
381
     * @return Content\SimpleContent
382
     */
383 5
    protected function getInlineContent(array $template): Content\SimpleContent
384
    {
385 5
        $htmlContent = null;
386 5
        if (array_key_exists(self::HTML, $template)) {
387 5
            $htmlContent = new Content\SimpleContent\Message($template[self::HTML]);
388
        }
389
390 5
        $textContent = null;
391 5
        if (array_key_exists(self::TEXT, $template)) {
392 1
            $textContent = new Content\SimpleContent\Message($template[self::TEXT]);
393
        }
394
395 5
        return new Content\SimpleContent($htmlContent, $textContent);
396
    }
397
398
    /**
399
     * Get the template content from SparkPost.
400
     *
401
     * @param Email $email
402
     *
403
     * @throws TransmissionException
404
     *
405
     * @return array
406
     */
407 6
    private function getTemplate(Email $email): array
408
    {
409
        try {
410 6
            $response = $this->sparkPost->syncRequest('GET', "templates/{$email->getContent()->getTemplateId()}");
411
412 5
            return $response->getBody()['results'][self::CONTENT];
413 1
        } catch (SparkPostException $e) {
414 1
            $this->logger->error(
415 1
                'Received status {code} from SparkPost while retrieving template with body: {body}',
416
                [
417 1
                    'code' => $e->getCode(),
418 1
                    'body' => $e->getBody(),
419
                ]
420
            );
421
422 1
            throw new TransmissionException($e->getCode(), $e);
423
        }
424
    }
425
426
    /**
427
     * @param Email $email
428
     *
429
     * @return array
430
     */
431 9
    private function buildAttachments(Email $email): array
432
    {
433 9
        $attachments = [];
434
435 9
        foreach ($email->getAttachments() as $attachment) {
436 6
            $attachments[] = [
437 6
                self::ATTACHMENT_NAME => $attachment->getName(),
438 6
                self::ATTACHMENT_TYPE => $attachment->getRfc2822ContentType(),
439 6
                self::ATTACHMENT_DATA => $attachment->getBase64Content(),
440
            ];
441
        }
442
443 9
        return $attachments;
444
    }
445
446
    /**
447
     * @param Email $email
448
     *
449
     * @return array
450
     */
451 9
    private function buildInlineAttachments(Email $email): array
452
    {
453 9
        $inlineAttachments = [];
454
455 9
        foreach ($email->getEmbedded() as $embedded) {
456 4
            $inlineAttachments[] = [
457 4
                self::ATTACHMENT_NAME => $embedded->getContentId(),
458 4
                self::ATTACHMENT_TYPE => $embedded->getRfc2822ContentType(),
459 4
                self::ATTACHMENT_DATA => $embedded->getBase64Content(),
460
            ];
461
        }
462
463 9
        return $inlineAttachments;
464
    }
465
466
    /**
467
     * @param Address $address
468
     * @param string  $headerTo
469
     *
470
     * @return array
471
     */
472 13
    private function createAddress(Address $address, string $headerTo): array
473
    {
474
        return [
475 13
            self::ADDRESS => [
476 13
                self::CONTACT_EMAIL => $address->getEmail(),
477 13
                self::HEADER_TO     => $headerTo,
478
            ],
479
        ];
480
    }
481
482
    /**
483
     * Build a string representing the header_to field of this email.
484
     *
485
     * @param Email $email
486
     *
487
     * @return string
488
     */
489
    private function buildHeaderTo(Email $email): string
490
    {
491 13
        return implode(',', array_map(function (Address $address) {
492 13
            return $address->toRfc2822();
493 13
        }, $email->getToRecipients()));
494
    }
495
496
    /**
497
     * Build a string representing the CC header for this email.
498
     *
499
     * @param Email $email
500
     *
501
     * @return string
502
     */
503
    private function buildCcHeader(Email $email): string
504
    {
505 13
        return implode(',', array_map(function (Address $address) {
506 5
            return $address->toRfc2822();
507 13
        }, $email->getCcRecipients()));
508
    }
509
}
510