quartzy /
courier-sparkpost
| 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
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 |