Passed
Push — master ( eb1258...b22374 )
by Chris
03:04
created

SparkPostCourier::deliver()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 1
dl 0
loc 12
ccs 7
cts 7
cp 1
crap 2
rs 10
c 0
b 0
f 0
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\SavesReceipts;
11
use PhpEmail\Address;
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 SparkPostTemplates
67
     */
68
    private $templates;
69
70
    /**
71
     * @var LoggerInterface
72
     */
73
    private $logger;
74
75
    /**
76
     * @param SparkPost       $sparkPost
77
     * @param LoggerInterface $logger
78
     */
79 15
    public function __construct(SparkPost $sparkPost, LoggerInterface $logger = null)
80
    {
81 15
        $this->sparkPost = $sparkPost;
82 15
        $this->logger    = $logger ?: new NullLogger();
83 15
        $this->templates = new SparkPostTemplates($sparkPost, $this->logger);
84
    }
85
86 15
    public function deliver(Email $email): void
87
    {
88 15
        if (!$this->supportsContent($email->getContent())) {
89 1
            throw new UnsupportedContentException($email->getContent());
90
        }
91
92 14
        $mail = $this->prepareEmail($email);
93 14
        $mail = $this->prepareContent($email, $mail);
94
95 12
        $response = $this->send($mail);
96
97 11
        $this->saveReceipt($email, $response->getBody()['results']['id']);
98
    }
99
100
    /**
101
     * @param array $mail
102
     *
103
     * @return SparkPostResponse
104
     */
105 12
    protected function send(array $mail): SparkPostResponse
106
    {
107 12
        $promise = $this->sparkPost->transmissions->post($mail);
108
109
        try {
110 12
            return $promise->wait();
111 1
        } catch (SparkPostException $e) {
112 1
            $this->logger->error(
113 1
                'Received status {code} from SparkPost with body: {body}',
114
                [
115 1
                    'code' => $e->getCode(),
116 1
                    'body' => $e->getBody(),
117
                ]
118
            );
119
120 1
            throw new TransmissionException($e->getCode(), $e);
121
        }
122
    }
123
124
    /**
125
     * @return array
126
     */
127 15
    protected function supportedContent(): array
128
    {
129
        return [
130 15
            Content\Contracts\SimpleContent::class,
131
            Content\Contracts\TemplatedContent::class,
132
        ];
133
    }
134
135
    /**
136
     * Determine if the content is supported by this courier.
137
     *
138
     * @param Content $content
139
     *
140
     * @return bool
141
     */
142 15
    protected function supportsContent(Content $content): bool
143
    {
144 15
        foreach ($this->supportedContent() as $contentType) {
145 15
            if ($content instanceof $contentType) {
146 14
                return true;
147
            }
148
        }
149
150 1
        return false;
151
    }
152
153
    /**
154
     * @param Email $email
155
     *
156
     * @return array
157
     */
158 14
    protected function prepareEmail(Email $email): array
159
    {
160 14
        $message  = [];
161 14
        $headerTo = $this->buildHeaderTo($email);
162
163 14
        $message[self::RECIPIENTS] = [];
164
165 14
        foreach ($email->getToRecipients() as $recipient) {
166 14
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
167
        }
168
169 14
        foreach ($email->getCcRecipients() as $recipient) {
170 6
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
171
        }
172
173 14
        foreach ($email->getBccRecipients() as $recipient) {
174 4
            $message[self::RECIPIENTS][] = $this->createAddress($recipient, $headerTo);
175
        }
176
177 14
        return $message;
178
    }
179
180
    /**
181
     * @param Email $email
182
     * @param array $message
183
     *
184
     * @return array
185
     */
186 14
    protected function prepareContent(Email $email, array $message): array
187
    {
188
        switch (true) {
189 14
            case $email->getContent() instanceof Content\Contracts\TemplatedContent:
190 9
                $message[self::CONTENT]           = $this->buildTemplateContent($email);
191 7
                $message[self::SUBSTITUTION_DATA] = $this->buildTemplateData($email);
192
193 7
                break;
194
195 5
            case $email->getContent() instanceof Content\SimpleContent:
196 5
                $message[self::CONTENT] = $this->buildSimpleContent($email);
197
        }
198
199 12
        return $message;
200
    }
201
202
    /**
203
     * Attempt to create template data using the from, subject and reply to, which SparkPost considers to be
204
     * part of the templates substitutable content.
205
     *
206
     * @param Email $email
207
     *
208
     * @return array
209
     */
210 7
    protected function buildTemplateData(Email $email): array
211
    {
212
        /** @var Content\TemplatedContent $emailContent */
213 7
        $emailContent = $email->getContent();
214 7
        $templateData = $emailContent->getTemplateData();
215
216 7
        if ($email->getReplyTos()) {
217 5
            $replyTos = $email->getReplyTos();
218 5
            $first    = reset($replyTos);
219
220 5
            if (!array_key_exists('replyTo', $templateData)) {
221 5
                $templateData['replyTo'] = $first->toRfc2822();
222
            }
223
        }
224
225 7
        if (!array_key_exists('fromName', $templateData)) {
226 7
            $templateData['fromName'] = $email->getFrom()->getName();
227
        }
228
229 7
        if (!array_key_exists('fromAddress', $templateData)) {
230 7
            $templateData['fromAddress'] = $email->getFrom()->getEmail();
231
        }
232
233
        // Deprecated: fromEmail will be removed in later releases of the SparkPostCourier in favor of fromAddress
234 7
        if (!array_key_exists('fromEmail', $templateData)) {
235 7
            $templateData['fromEmail'] = explode('@', $email->getFrom()->getEmail())[0];
236
        }
237
238
        // Deprecated: fromDomain will be removed in later releases of the SparkPostCourier in favor of fromAddress
239 7
        if (!array_key_exists('fromDomain', $templateData)) {
240 7
            $templateData['fromDomain'] = explode('@', $email->getFrom()->getEmail())[1];
241
        }
242
243 7
        if (!array_key_exists('subject', $templateData)) {
244 7
            $templateData['subject'] = $email->getSubject();
245
        }
246
247
        // @TODO Remove this variable once SparkPost CC headers work properly for templates
248 7
        if (!array_key_exists('ccHeader', $templateData)) {
249 7
            if ($header = $this->buildCcHeader($email)) {
250 4
                $templateData['ccHeader'] = $header;
251
            }
252
        }
253
254 7
        return $templateData;
255
    }
256
257
    /**
258
     * @param Email $email
259
     *
260
     * @return array
261
     */
262 9
    protected function buildTemplateContent(Email $email): array
263
    {
264
        // SparkPost does not currently support templated emails with attachments, so it must be converted to a
265
        // SimpleContent message instead.
266 9
        if ($email->getAttachments()) {
267 6
            return $this->buildSimpleContent($this->templates->convertTemplatedEmail($email));
268
        }
269
270
        $content = [
271 3
            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 3
        if ($headers = $this->getContentHeaders($email)) {
275 2
            $content[self::HEADERS] = $headers;
276
        }
277
278 3
        return $content;
279
    }
280
281
    /**
282
     * @param Email $email
283
     *
284
     * @return array
285
     */
286 9
    protected function buildSimpleContent(Email $email): array
287
    {
288 9
        $replyTo = null;
289 9
        if (!empty($email->getReplyTos())) {
290
            // SparkPost only supports a single reply-to
291 5
            $replyTos = $email->getReplyTos();
292 5
            $first    = reset($replyTos);
293
294 5
            $replyTo = $first->toRfc2822();
295
        }
296
297
        /** @var Content\Contracts\SimpleContent $emailContent */
298 9
        $emailContent = $email->getContent();
299
300
        $content = [
301 9
            self::FROM          => [
302 9
                self::CONTACT_NAME  => $email->getFrom()->getName(),
303 9
                self::CONTACT_EMAIL => $email->getFrom()->getEmail(),
304
            ],
305 9
            self::SUBJECT       => $email->getSubject(),
306 9
            self::HTML          => $emailContent->getHtml() !== null ? $emailContent->getHtml()->getBody() : null,
307 9
            self::TEXT          => $emailContent->getText() !== null ? $emailContent->getText()->getBody() : null,
308 9
            self::ATTACHMENTS   => $this->buildAttachments($email),
309 9
            self::INLINE_IMAGES => $this->buildInlineAttachments($email),
310 9
            self::REPLY_TO      => $replyTo,
311
        ];
312
313 9
        if ($headers = $this->getContentHeaders($email)) {
314 6
            $content[self::HEADERS] = $headers;
315
        }
316
317 9
        return $content;
318
    }
319
320 12
    protected function getContentHeaders(Email $email): array
321
    {
322 12
        $headers = [];
323
324 12
        foreach ($email->getHeaders() as $header) {
325 7
            $headers[$header->getField()] = $header->getValue();
326
        }
327
328 12
        if ($ccHeader = $this->buildCcHeader($email)) {
329 6
            $headers[self::CC_HEADER] = $ccHeader;
330
        } else {
331
            // If this was set on a template in SparkPost, we will remove it, because there are no
332
            // CCs defined on the email itself.
333 6
            unset($headers[self::CC_HEADER]);
334
        }
335
336 12
        return $headers;
337
    }
338
339
    /**
340
     * @param Email $email
341
     *
342
     * @return array
343
     */
344 9
    private function buildAttachments(Email $email): array
345
    {
346 9
        $attachments = [];
347
348 9
        foreach ($email->getAttachments() as $attachment) {
349 6
            $attachments[] = [
350 6
                self::ATTACHMENT_NAME => $attachment->getName(),
351 6
                self::ATTACHMENT_TYPE => $attachment->getRfc2822ContentType(),
352 6
                self::ATTACHMENT_DATA => $attachment->getBase64Content(),
353
            ];
354
        }
355
356 9
        return $attachments;
357
    }
358
359
    /**
360
     * @param Email $email
361
     *
362
     * @return array
363
     */
364 9
    private function buildInlineAttachments(Email $email): array
365
    {
366 9
        $inlineAttachments = [];
367
368 9
        foreach ($email->getEmbedded() as $embedded) {
369 4
            $inlineAttachments[] = [
370 4
                self::ATTACHMENT_NAME => $embedded->getContentId(),
371 4
                self::ATTACHMENT_TYPE => $embedded->getRfc2822ContentType(),
372 4
                self::ATTACHMENT_DATA => $embedded->getBase64Content(),
373
            ];
374
        }
375
376 9
        return $inlineAttachments;
377
    }
378
379
    /**
380
     * @param Address $address
381
     * @param string  $headerTo
382
     *
383
     * @return array
384
     */
385 14
    private function createAddress(Address $address, string $headerTo): array
386
    {
387
        return [
388 14
            self::ADDRESS => [
389 14
                self::CONTACT_EMAIL => $address->getEmail(),
390 14
                self::HEADER_TO     => $headerTo,
391
            ],
392
        ];
393
    }
394
395
    /**
396
     * Build a string representing the header_to field of this email.
397
     *
398
     * @param Email $email
399
     *
400
     * @return string
401
     */
402
    private function buildHeaderTo(Email $email): string
403
    {
404 14
        return implode(',', array_map(function (Address $address) {
405 14
            return $address->toRfc2822();
406 14
        }, $email->getToRecipients()));
407
    }
408
409
    /**
410
     * Build a string representing the CC header for this email.
411
     *
412
     * @param Email $email
413
     *
414
     * @return string
415
     */
416
    private function buildCcHeader(Email $email): string
417
    {
418 12
        return implode(',', array_map(function (Address $address) {
419 6
            return $address->toRfc2822();
420 12
        }, $email->getCcRecipients()));
421
    }
422
}
423