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
![]() |
|||
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 |