Passed
Push — master ( f1182f...484a31 )
by Chris
01:48
created

SparkPostCourier::buildTemplateData()   C

Complexity

Conditions 10
Paths 288

Size

Total Lines 45
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10

Importance

Changes 0
Metric Value
cc 10
eloc 21
nc 288
nop 1
dl 0
loc 45
ccs 22
cts 22
cp 1
crap 10
rs 5.7333
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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

271
            self::TEMPLATE_ID => $email->getContent()->/** @scrutinizer ignore-call */ getTemplateId(),
Loading history...
272
        ];
273
274 8
        if ($headers = $this->getContentHeaders($email)) {
275 3
            $content[self::HEADERS] = $headers;
276
        }
277
278 8
        if ($email->getAttachments()) {
279
            /*
280
             * SparkPost does not currently support sending attachments with templated emails. For this reason,
281
             * we will instead get the template from SparkPost and create a new inline template using the information
282
             * from it instead.
283
             */
284 6
            $template    = $this->getTemplate($email);
285 5
            $inlineEmail = clone $email;
286
287
            $inlineEmail
288 5
                ->setSubject($template[self::SUBJECT])
289 5
                ->setContent($this->getInlineContent($template));
290
291
            // If the from contains a templated from, it should be actively replaced now to avoid validation errors.
292 5
            if (strpos($template[self::FROM][self::CONTACT_EMAIL], '{{') !== false) {
293 3
                $inlineEmail->setFrom($email->getFrom());
294
            } else {
295 2
                $inlineEmail->setFrom(
296 2
                    new Address(
297 2
                        $template[self::FROM][self::CONTACT_EMAIL],
298 2
                        $template[self::FROM][self::CONTACT_NAME]
299
                    )
300
                );
301
            }
302
303
            // If the form contains a templated replyTo, it should be actively replaced now to avoid validation errors.
304 5
            if (array_key_exists(self::REPLY_TO, $template)) {
305 5
                if (strpos($template[self::REPLY_TO], '{{') !== false) {
306 3
                    if (empty($email->getReplyTos())) {
307 1
                        throw new ValidationException('Reply to is templated but no value was given');
308
                    }
309
310 2
                    $inlineEmail->setReplyTos($email->getReplyTos()[0]);
311
                } else {
312 2
                    $inlineEmail->setReplyTos(Address::fromString($template[self::REPLY_TO]));
313
                }
314
            }
315
316 4
            $content = $this->buildSimpleContent($inlineEmail);
317
318
            // If the template AND content include headers, merge them
319
            // if only the template includes headers, then just use that
320 4
            if (array_key_exists(self::HEADERS, $template) && array_key_exists(self::HEADERS, $content)) {
321 1
                $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

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