Completed
Pull Request — master (#18)
by Chris
01:24
created

SparkPostCourier::buildInlineAttachments()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 13
ccs 8
cts 8
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 7
nc 2
nop 1
crap 2
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\Attachment;
12
use PhpEmail\Content;
13
use PhpEmail\Email;
14
use Psr\Log\LoggerInterface;
15
use Psr\Log\NullLogger;
16
use SparkPost\SparkPost;
17
use SparkPost\SparkPostException;
18
use SparkPost\SparkPostResponse;
19
20
/**
21
 * A courier implementation using SparkPost as the third-party provider. This library uses the web API and the
22
 * php-sparkpost library to send transmissions.
23
 *
24
 * An important note is that while the SparkPost API does not support sending attachments on templated transmissions,
25
 * this API simulates the feature by creating an inline template based on the defined template using the API. In this
26
 * case, all template variables will be sent as expected, but tracking/reporting may not work as expected within
27
 * SparkPost.
28
 */
29
class SparkPostCourier implements ConfirmingCourier
30
{
31
    use SavesReceipts;
32
33
    const RECIPIENTS        = 'recipients';
34
    const CC                = 'cc';
35
    const BCC               = 'bcc';
36
    const REPLY_TO          = 'reply_to';
37
    const SUBSTITUTION_DATA = 'substitution_data';
38
39
    const CONTENT       = 'content';
40
    const FROM          = 'from';
41
    const SUBJECT       = 'subject';
42
    const HTML          = 'html';
43
    const TEXT          = 'text';
44
    const INLINE_IMAGES = 'inline_images';
45
    const ATTACHMENTS   = 'attachments';
46
    const TEMPLATE_ID   = 'template_id';
47
48
    const HEADERS   = 'headers';
49
    const CC_HEADER = 'CC';
50
51
    const ADDRESS       = 'address';
52
    const CONTACT_NAME  = 'name';
53
    const CONTACT_EMAIL = 'email';
54
    const HEADER_TO     = 'header_to';
55
56
    const ATTACHMENT_NAME = 'name';
57
    const ATTACHMENT_TYPE = 'type';
58
    const ATTACHMENT_DATA = 'data';
59
60
    /**
61
     * @var SparkPost
62
     */
63
    private $sparkPost;
64
65
    /**
66
     * @var LoggerInterface
67
     */
68
    private $logger;
69
70
    /**
71
     * @param SparkPost       $sparkPost
72
     * @param LoggerInterface $logger
73
     */
74 12
    public function __construct(SparkPost $sparkPost, LoggerInterface $logger = null)
75
    {
76 12
        $this->sparkPost = $sparkPost;
77 12
        $this->logger    = $logger ?: new NullLogger();
78
    }
79
80 12
    public function deliver(Email $email): void
81
    {
82 12
        if (!$this->supportsContent($email->getContent())) {
83 1
            throw new UnsupportedContentException($email->getContent());
84
        }
85
86 11
        $mail = $this->prepareEmail($email);
87 11
        $mail = $this->prepareContent($email, $mail);
88
89 9
        $response = $this->send($mail);
90
91 8
        $this->saveReceipt($email, $response->getBody()['results']['id']);
92
    }
93
94
    /**
95
     * @param array $mail
96
     *
97
     * @return SparkPostResponse
98
     */
99 9
    protected function send(array $mail): SparkPostResponse
100
    {
101 9
        $promise = $this->sparkPost->transmissions->post($mail);
102
103
        try {
104 9
            return $promise->wait();
105 1
        } catch (SparkPostException $e) {
106 1
            $this->logger->error(
107 1
                'Received status {code} from SparkPost with body: {body}',
108
                [
109 1
                    'code' => $e->getCode(),
110 1
                    'body' => $e->getBody(),
111
                ]
112
            );
113
114 1
            throw new TransmissionException($e->getCode(), $e);
115
        }
116
    }
117
118
    /**
119
     * @return array
120
     */
121 12
    protected function supportedContent(): array
122
    {
123
        return [
124 12
            Content\EmptyContent::class,
125
            Content\Contracts\SimpleContent::class,
126
            Content\Contracts\TemplatedContent::class,
127
        ];
128
    }
129
130
    /**
131
     * Determine if the content is supported by this courier.
132
     *
133
     * @param Content $content
134
     *
135
     * @return bool
136
     */
137 12
    protected function supportsContent(Content $content): bool
138
    {
139 12
        foreach ($this->supportedContent() as $contentType) {
140 12
            if ($content instanceof $contentType) {
141 12
                return true;
142
            }
143
        }
144
145 1
        return false;
146
    }
147
148
    /**
149
     * @param Email $email
150
     *
151
     * @return array
152
     */
153 11
    protected function prepareEmail(Email $email): array
154
    {
155 11
        $message  = [];
156 11
        $headerTo = $this->buildHeaderTo($email);
157
158 11
        $message[self::RECIPIENTS] = [];
159
160 11
        foreach ($email->getToRecipients() as $recipient) {
161 11
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
162
        }
163
164 11
        foreach ($email->getCcRecipients() as $recipient) {
165 3
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
166
        }
167
168 11
        foreach ($email->getBccRecipients() as $recipient) {
169 1
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
170
        }
171
172 11
        return $message;
173
    }
174
175
    /**
176
     * @param Email $email
177
     * @param array $message
178
     *
179
     * @return array
180
     */
181 11
    protected function prepareContent(Email $email, array $message): array
182
    {
183
        switch (true) {
184 11
            case $email->getContent() instanceof Content\Contracts\TemplatedContent:
185 7
                $message[self::CONTENT]           = $this->buildTemplateContent($email);
186 5
                $message[self::SUBSTITUTION_DATA] = $this->buildTemplateData($email);
187
188 5
                break;
189
190 4
            case $email->getContent() instanceof Content\EmptyContent:
191 2
                $email->setContent(new Content\SimpleContent(
192 2
                    new Content\SimpleContent\Message(''),
193 2
                    new Content\SimpleContent\Message('')
194
                ));
195
196 2
                $message[self::CONTENT] = $this->buildSimpleContent($email);
197
198 2
                break;
199
200 2
            case $email->getContent() instanceof Content\SimpleContent:
201 2
                $message[self::CONTENT] = $this->buildSimpleContent($email);
202
        }
203
204 9
        return $message;
205
    }
206
207
    /**
208
     * Attempt to create template data using the from, subject and reply to, which SparkPost considers to be
209
     * part of the templates substitutable content.
210
     *
211
     * @param Email $email
212
     *
213
     * @return array
214
     */
215 5
    protected function buildTemplateData(Email $email): array
216
    {
217
        /** @var Content\TemplatedContent $emailContent */
218 5
        $emailContent = $email->getContent();
219 5
        $templateData = $emailContent->getTemplateData();
220
221 5
        if ($email->getReplyTos()) {
222 3
            $replyTos = $email->getReplyTos();
223 3
            $first    = reset($replyTos);
224
225 3
            if (!array_key_exists('replyTo', $templateData)) {
226 3
                $templateData['replyTo'] = $first->toRfc2822();
227
            }
228
        }
229
230 5
        if (!array_key_exists('fromName', $templateData)) {
231 5
            $templateData['fromName'] = $email->getFrom()->getName();
232
        }
233
234 5
        if (!array_key_exists('fromEmail', $templateData)) {
235 5
            $templateData['fromEmail'] = explode('@', $email->getFrom()->getEmail())[0];
236
        }
237
238 5
        if (!array_key_exists('fromDomain', $templateData)) {
239 5
            $templateData['fromDomain'] = explode('@', $email->getFrom()->getEmail())[1];
240
        }
241
242 5
        if (!array_key_exists('subject', $templateData)) {
243 5
            $templateData['subject'] = $email->getSubject();
244
        }
245
246
        // @TODO Remove this variable once SparkPost CC headers work properly for templates
247 5
        if (!array_key_exists('ccHeader', $templateData)) {
248 5
            if ($header = $this->buildCcHeader($email)) {
249 2
                $templateData['ccHeader'] = $header;
250
            }
251
        }
252
253 5
        return $templateData;
254
    }
255
256
    /**
257
     * @param Email $email
258
     *
259
     * @return array
260
     */
261 7
    protected function buildTemplateContent(Email $email): array
262
    {
263
        $content = [
264 7
            self::TEMPLATE_ID => $email->getContent()->getTemplateId(),
265
        ];
266
267 7
        $content[self::HEADERS] = $this->getContentHeaders($email);
268
269 7
        if ($email->getAttachments()) {
270
            /*
271
             * SparkPost does not currently support sending attachments with templated emails. For this reason,
272
             * we will instead get the template from SparkPost and create a new inline template using the information
273
             * from it instead.
274
             */
275 5
            $template    = $this->getTemplate($email);
276 4
            $inlineEmail = clone $email;
277
278
            $inlineEmail
279 4
                ->setSubject($template[self::SUBJECT])
280 4
                ->setContent($this->getInlineContent($template));
281
282
            // If the from contains a templated from, it should be actively replaced now to avoid validation errors.
283 4
            if (strpos($template[self::FROM][self::CONTACT_EMAIL], '{{') !== false) {
284 2
                $inlineEmail->setFrom($email->getFrom());
285
            } else {
286 2
                $inlineEmail->setFrom(
287 2
                    new Address(
288 2
                        $template[self::FROM][self::CONTACT_EMAIL],
289 2
                        $template[self::FROM][self::CONTACT_NAME]
290
                    )
291
                );
292
            }
293
294
            // If the form contains a templated replyTo, it should be actively replaced now to avoid validation errors.
295 4
            if (array_key_exists(self::REPLY_TO, $template)) {
296 4
                if (strpos($template[self::REPLY_TO], '{{') !== false) {
297 2
                    if (empty($email->getReplyTos())) {
298 1
                        throw new ValidationException('Reply to is templated but no value was given');
299
                    }
300
301 1
                    $inlineEmail->setReplyTos($email->getReplyTos()[0]);
302
                } else {
303 2
                    $inlineEmail->setReplyTos(Address::fromString($template[self::REPLY_TO]));
304
                }
305
            }
306
307 3
            $content = $this->buildSimpleContent($inlineEmail);
308
309
            // If the template AND content include headers, merge them
310
            // if only the template includes headers, then just use that
311 3
            if (array_key_exists(self::HEADERS, $template) && array_key_exists(self::HEADERS, $content)) {
312 3
                $content[self::HEADERS] = array_merge($template[self::HEADERS], $content[self::HEADERS]);
313
            } elseif (array_key_exists(self::HEADERS, $template)) {
314
                $content[self::HEADERS] = $template[self::HEADERS];
315
            }
316
        }
317
318 5
        return $content;
319
    }
320
321
    /**
322
     * @param Email $email
323
     *
324
     * @return array
325
     */
326 7
    protected function buildSimpleContent(Email $email): array
327
    {
328 7
        $replyTo = null;
329 7
        if (!empty($email->getReplyTos())) {
330
            // SparkPost only supports a single reply-to
331 4
            $replyTos = $email->getReplyTos();
332 4
            $first    = reset($replyTos);
333
334 4
            $replyTo = $first->toRfc2822();
335
        }
336
337
        /** @var Content\Contracts\SimpleContent $emailContent */
338 7
        $emailContent = $email->getContent();
339
340
        $content = [
341 7
            self::FROM          => [
342 7
                self::CONTACT_NAME  => $email->getFrom()->getName(),
343 7
                self::CONTACT_EMAIL => $email->getFrom()->getEmail(),
344
            ],
345 7
            self::SUBJECT       => $email->getSubject(),
346 7
            self::HTML          => $emailContent->getHtml() !== null ? $emailContent->getHtml()->getBody() : null,
347 7
            self::TEXT          => $emailContent->getText() !== null ? $emailContent->getText()->getBody() : null,
348 7
            self::ATTACHMENTS   => $this->buildAttachments($email),
349 7
            self::INLINE_IMAGES => $this->buildInlineAttachments($email),
350 7
            self::REPLY_TO      => $replyTo,
351
        ];
352
353 7
        $content[self::HEADERS] = $this->getContentHeaders($email);
354
355 7
        return $content;
356
    }
357
358 11
    protected function getContentHeaders(Email $email): array
359
    {
360 11
        $headers = [];
361
362 11
        if ($ccHeader = $this->buildCcHeader($email)) {
363 3
            $headers[self::CC_HEADER] = $ccHeader;
364
        }
365
366 11
        foreach ($email->getHeaders() as $header) {
367 1
            $headers[$header->getField()] = $header->getValue();
368
        }
369
370 11
        return $headers;
371
    }
372
373
    /**
374
     * Create the SimpleContent based on the SparkPost template data.
375
     *
376
     * @param array $template
377
     *
378
     * @return Content\SimpleContent
379
     */
380 4
    protected function getInlineContent(array $template): Content\SimpleContent
381
    {
382 4
        $htmlContent = null;
383 4
        if (array_key_exists(self::HTML, $template)) {
384 4
            $htmlContent = new Content\SimpleContent\Message($template[self::HTML]);
385
        }
386
387 4
        $textContent = null;
388 4
        if (array_key_exists(self::TEXT, $template)) {
389
            $textContent = new Content\SimpleContent\Message($template[self::TEXT]);
390
        }
391
392 4
        return new Content\SimpleContent($htmlContent, $textContent);
393
    }
394
395
    /**
396
     * Get the template content from SparkPost.
397
     *
398
     * @param Email $email
399
     *
400
     * @throws TransmissionException
401
     *
402
     * @return array
403
     */
404 5
    private function getTemplate(Email $email): array
405
    {
406
        try {
407 5
            $response = $this->sparkPost->syncRequest('GET', "templates/{$email->getContent()->getTemplateId()}");
408
409 4
            return $response->getBody()['results'][self::CONTENT];
410 1
        } catch (SparkPostException $e) {
411 1
            $this->logger->error(
412 1
                'Received status {code} from SparkPost while retrieving template with body: {body}',
413
                [
414 1
                    'code' => $e->getCode(),
415 1
                    'body' => $e->getBody(),
416
                ]
417
            );
418
419 1
            throw new TransmissionException($e->getCode(), $e);
420
        }
421
    }
422
423
    /**
424
     * @param Email $email
425
     *
426
     * @return array
427
     */
428 7
    private function buildAttachments(Email $email): array
429
    {
430 7
        $attachments = [];
431
432 7
        foreach ($email->getAttachments() as $attachment) {
433 4
            $attachments[] = [
434 4
                self::ATTACHMENT_NAME => $attachment->getName(),
435 4
                self::ATTACHMENT_TYPE => $attachment->getContentType(),
436 4
                self::ATTACHMENT_DATA => $attachment->getBase64Content(),
437
            ];
438
        }
439
440 7
        return $attachments;
441
    }
442
443
    /**
444
     * @param Email $email
445
     *
446
     * @return array
447
     */
448 7
    private function buildInlineAttachments(Email $email): array
449
    {
450 7
        $inlineAttachments = [];
451
452 7
        foreach ($email->getEmbedded() as $embedded) {
453 2
            $inlineAttachments[] = [
454 2
                self::ATTACHMENT_NAME => $embedded->getContentId(),
455 2
                self::ATTACHMENT_TYPE => $embedded->getContentType(),
456 2
                self::ATTACHMENT_DATA => $embedded->getBase64Content(),
457
            ];
458
        }
459
460 7
        return $inlineAttachments;
461
    }
462
463
    /**
464
     * @param Address $address
465
     * @param string  $headerTo
466
     *
467
     * @return array
468
     */
469 11
    private function createAddress(Address $address, string $headerTo): array
470
    {
471
        return [
472 11
            self::ADDRESS => [
473 11
                self::CONTACT_EMAIL => $address->getEmail(),
474 11
                self::HEADER_TO     => $headerTo,
475
            ],
476
        ];
477
    }
478
479
    /**
480
     * Build a string representing the header_to field of this email.
481
     *
482
     * @param Email $email
483
     *
484
     * @return string
485
     */
486
    private function buildHeaderTo(Email $email): string
487
    {
488 11
        return implode(',', array_map(function (Address $address) {
489 11
            return $address->toRfc2822();
490 11
        }, $email->getToRecipients()));
491
    }
492
493
    /**
494
     * Build a string representing the CC header for this email.
495
     *
496
     * @param Email $email
497
     *
498
     * @return string
499
     */
500
    private function buildCcHeader(Email $email): string
501
    {
502 11
        return implode(',', array_map(function (Address $address) {
503 3
            return $address->toRfc2822();
504 11
        }, $email->getCcRecipients()));
505
    }
506
}
507