Issues (3627)

Swiftmailer/Transport/SparkpostTransport.php (1 issue)

1
<?php
2
3
/*
4
 * @copyright   2016 Mautic Contributors. All rights reserved
5
 * @author      Mautic
6
 *
7
 * @link        http://mautic.org
8
 *
9
 * @license     GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
10
 *
11
 * @see         https://github.com/SlowProg/SparkPostSwiftMailer/blob/master/SwiftMailer/SparkPostTransport.php for additional source reference
12
 */
13
14
namespace Mautic\EmailBundle\Swiftmailer\Transport;
15
16
use Mautic\EmailBundle\Model\TransportCallback;
17
use Mautic\EmailBundle\Swiftmailer\Sparkpost\SparkpostFactoryInterface;
18
use Mautic\LeadBundle\Entity\DoNotContact;
19
use Psr\Log\LoggerInterface;
20
use SparkPost\SparkPost;
21
use Symfony\Component\HttpFoundation\Request;
22
use Symfony\Component\Translation\TranslatorInterface;
23
24
class SparkpostTransport extends AbstractTokenArrayTransport implements \Swift_Transport, TokenTransportInterface, CallbackTransportInterface
25
{
26
    /**
27
     * @var string|null
28
     */
29
    private $apiKey;
30
31
    /**
32
     * @var TranslatorInterface
33
     */
34
    private $translator;
35
36
    /**
37
     * @var TransportCallback
38
     */
39
    private $transportCallback;
40
41
    /**
42
     * @var SparkpostFactoryInterface
43
     */
44
    private $sparkpostFactory;
45
46
    /**
47
     * @var LoggerInterface
48
     */
49
    private $logger;
50
51
    /**
52
     * @param string $apiKey
53
     */
54
    public function __construct(
55
        $apiKey,
56
        TranslatorInterface $translator,
57
        TransportCallback $transportCallback,
58
        SparkpostFactoryInterface $sparkpostFactory,
59
        LoggerInterface $logger
60
    ) {
61
        $this->setApiKey($apiKey);
62
63
        $this->translator        = $translator;
64
        $this->transportCallback = $transportCallback;
65
        $this->sparkpostFactory  = $sparkpostFactory;
66
        $this->logger            = $logger;
67
    }
68
69
    /**
70
     * @param string $apiKey
71
     */
72
    public function setApiKey($apiKey)
73
    {
74
        $this->apiKey = $apiKey;
75
    }
76
77
    /**
78
     * @return string|null
79
     */
80
    public function getApiKey()
81
    {
82
        return $this->apiKey;
83
    }
84
85
    /**
86
     * Start this Transport mechanism.
87
     */
88
    public function start()
89
    {
90
        if (empty($this->apiKey)) {
91
            $this->throwException($this->translator->trans('mautic.email.api_key_required', [], 'validators'));
92
        }
93
94
        $this->started = true;
95
    }
96
97
    /**
98
     * Creates new SparkPost HTTP client.
99
     * If no API key is provided then the default one is used.
100
     *
101
     * @param string $apiKey
102
     *
103
     * @return SparkPost
104
     */
105
    protected function createSparkPost($apiKey = null)
106
    {
107
        if (null === $apiKey) {
108
            $apiKey = $this->apiKey;
109
        }
110
111
        return $this->sparkpostFactory->create('', $apiKey);
112
    }
113
114
    /**
115
     * @param null $failedRecipients
116
     *
117
     * @return int Number of messages sent
118
     *
119
     * @throws \Exception
120
     */
121
    public function send(\Swift_Mime_SimpleMessage $message, &$failedRecipients = null)
122
    {
123
        $sendCount = 0;
124
        if ($event = $this->getDispatcher()->createSendEvent($this, $message)) {
125
            $this->getDispatcher()->dispatchEvent($event, 'beforeSendPerformed');
126
            if ($event->bubbleCancelled()) {
127
                return 0;
128
            }
129
        }
130
131
        try {
132
            $sparkPostMessage = $this->getSparkPostMessage($message);
133
            $sparkPostClient  = $this->createSparkPost();
134
135
            $this->checkTemplateIsValid($sparkPostClient, $sparkPostMessage);
136
137
            $promise  = $sparkPostClient->transmissions->post($sparkPostMessage);
138
            $response = $promise->wait();
139
            $body     = $response->getBody();
140
141
            if ($errorMessage = $this->getErrorMessageFromResponseBody($body)) {
142
                $this->processImmediateSendFeedback($sparkPostMessage, $body);
143
                throw new \Exception($errorMessage);
144
            }
145
146
            $sendCount = $body['results']['total_accepted_recipients'];
147
        } catch (\Exception $e) {
148
            $this->throwException($e->getMessage());
149
        }
150
151
        if ($event) {
0 ignored issues
show
$event is of type Swift_Events_SendEvent, thus it always evaluated to true.
Loading history...
152
            if ($sendCount > 0) {
153
                $event->setResult(\Swift_Events_SendEvent::RESULT_SUCCESS);
154
            } else {
155
                $event->setResult(\Swift_Events_SendEvent::RESULT_FAILED);
156
            }
157
            $this->getDispatcher()->dispatchEvent($event, 'sendPerformed');
158
        }
159
160
        return $sendCount;
161
    }
162
163
    /**
164
     * https://jsapi.apiary.io/apis/sparkpostapi/introduction/subaccounts-coming-to-an-api-near-you-in-april!.html.
165
     *
166
     * @return array SparkPost Send Message
167
     *
168
     * @throws \Exception
169
     */
170
    public function getSparkPostMessage(\Swift_Mime_SimpleMessage $message)
171
    {
172
        $tags      = [];
173
        $inlineCss = null;
174
175
        $this->message = $message;
176
        $metadata      = $this->getMetadata();
177
        $mauticTokens  = $mergeVars = $mergeVarPlaceholders = [];
178
        $campaignId    = '';
179
180
        // Sparkpost uses {{ name }} for tokens so Mautic's need to be converted; although using their {{{ }}} syntax to prevent HTML escaping
181
        if (!empty($metadata)) {
182
            $metadataSet  = reset($metadata);
183
            $tokens       = (!empty($metadataSet['tokens'])) ? $metadataSet['tokens'] : [];
184
            $mauticTokens = array_keys($tokens);
185
186
            $mergeVars = $mergeVarPlaceholders = [];
187
            foreach ($mauticTokens as $token) {
188
                $mergeVars[$token]            = strtoupper(preg_replace('/[^a-z0-9]+/i', '', $token));
189
                $mergeVarPlaceholders[$token] = '{{{ '.$mergeVars[$token].' }}}';
190
            }
191
192
            $campaignId = $this->extractCampaignId($metadataSet);
193
        }
194
195
        $message = $this->messageToArray($mauticTokens, $mergeVarPlaceholders, true);
196
197
        // Sparkpost requires a subject
198
        if (empty($message['subject'])) {
199
            throw new \Exception($this->translator->trans('mautic.email.subject.notblank', [], 'validators'));
200
        }
201
202
        if (isset($message['headers']['X-MC-InlineCSS'])) {
203
            $inlineCss = $message['headers']['X-MC-InlineCSS'];
204
        }
205
        if (isset($message['headers']['X-MC-Tags'])) {
206
            $tags = explode(',', $message['headers']['X-MC-Tags']);
207
        }
208
209
        $recipients = [];
210
        foreach ($message['recipients']['to'] as $to) {
211
            $recipient = [
212
                'address'           => $to,
213
                'substitution_data' => [],
214
                'metadata'          => [],
215
            ];
216
217
            if (isset($metadata[$to['email']]['tokens'])) {
218
                foreach ($metadata[$to['email']]['tokens'] as $token => $value) {
219
                    $recipient['substitution_data'][$mergeVars[$token]] = $value;
220
                }
221
222
                unset($metadata[$to['email']]['tokens']);
223
                $recipient['metadata'] = $metadata[$to['email']];
224
            }
225
226
            // Sparkpost requires substitution_data which can be byspassed by using MailHelper::setTo() rather than a Lead via MailHelper::setLead()
227
            // Without it, Sparkpost returns the error: "field 'substitution_data' is required"
228
            // But, it can't be an empty array or Sparkpost will return error: field 'substitution_data' is of type 'json_array', but needs to be of type 'json_object'
229
            if (empty($recipient['substitution_data'])) {
230
                $recipient['substitution_data'] = new \stdClass();
231
            }
232
233
            // Sparkpost doesn't like empty metadata
234
            if (empty($recipient['metadata'])) {
235
                unset($recipient['metadata']);
236
            }
237
238
            $recipients[] = $recipient;
239
240
            // CC and BCC fields need to be included as a normal TO address with token duplication
241
            // https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/ - token duplication is not mentioned here
242
            // See test for CC and BCC too
243
            foreach (['cc', 'bcc'] as $copyType) {
244
                if (!empty($message['recipients'][$copyType])) {
245
                    foreach ($message['recipients'][$copyType] as $email => $content) {
246
                        $copyRecipient = [
247
                            'address'   => ['email' => $email],
248
                            'header_to' => $to['email'],
249
                        ];
250
251
                        if (!empty($recipient['substitution_data'])) {
252
                            $copyRecipient['substitution_data'] = $recipient['substitution_data'];
253
                        }
254
255
                        $recipients[] = $copyRecipient;
256
                    }
257
                }
258
            }
259
        }
260
261
        $content = [
262
            'from'    => (!empty($message['from']['name'])) ? $message['from']['name'].' <'.$message['from']['email'].'>'
263
                : $message['from']['email'],
264
            'subject' => $message['subject'],
265
        ];
266
267
        if (!empty($message['headers'])) {
268
            $content['headers'] = array_map('strval', $message['headers']);
269
        }
270
271
        // Sparkpost will set parts regardless if they are empty or not
272
        if (!empty($message['html'])) {
273
            $content['html'] = $message['html'];
274
        }
275
276
        if (!empty($message['text'])) {
277
            $content['text'] = $message['text'];
278
        }
279
280
        // Add Reply To
281
        if (isset($message['replyTo'])) {
282
            $content['reply_to'] = $message['replyTo']['email'];
283
        }
284
285
        $encoder = new \Swift_Mime_ContentEncoder_Base64ContentEncoder();
286
        foreach ($this->message->getChildren() as $child) {
287
            if ($child instanceof \Swift_Image) {
288
                $content['inline_images'][] = [
289
                    'type' => $child->getContentType(),
290
                    'name' => $child->getId(),
291
                    'data' => $encoder->encodeString($child->getBody()),
292
                ];
293
            }
294
        }
295
296
        $sparkPostMessage = [
297
            'content'     => $content,
298
            'recipients'  => $recipients,
299
            'inline_css'  => $inlineCss,
300
            'tags'        => $tags,
301
            'campaign_id' => $campaignId,
302
        ];
303
304
        if (!empty($message['attachments'])) {
305
            foreach ($message['attachments'] as $key => $attachment) {
306
                $message['attachments'][$key]['data'] = $attachment['content'];
307
                unset($message['attachments'][$key]['content']);
308
            }
309
            $sparkPostMessage['content']['attachments'] = $message['attachments'];
310
        }
311
312
        $sparkPostMessage['options'] = [
313
            'open_tracking'  => false,
314
            'click_tracking' => false,
315
        ];
316
317
        return $sparkPostMessage;
318
    }
319
320
    /**
321
     * @return int
322
     */
323
    public function getMaxBatchLimit()
324
    {
325
        return 5000;
326
    }
327
328
    /**
329
     * @param int    $toBeAdded
330
     * @param string $type
331
     */
332
    public function getBatchRecipientCount(\Swift_Message $message, $toBeAdded = 1, $type = 'to'): int
333
    {
334
        // These getters could return null
335
        $toCount  = $message->getTo() ? count($message->getTo()) : 0;
336
        $ccCount  = $message->getCc() ? count($message->getCc()) : 0;
337
        $bccCount = $message->getBcc() ? count($message->getBcc()) : 0;
338
339
        return $toCount + $ccCount + $bccCount + $toBeAdded;
340
    }
341
342
    /**
343
     * Returns a "transport" string to match the URL path /mailer/{transport}/callback.
344
     *
345
     * @return mixed
346
     */
347
    public function getCallbackPath()
348
    {
349
        return 'sparkpost';
350
    }
351
352
    /**
353
     * Handle response.
354
     */
355
    public function processCallbackRequest(Request $request)
356
    {
357
        $payload = $request->request->all();
358
359
        foreach ($payload as $msys) {
360
            $msys = $msys['msys'];
361
            if (isset($msys['message_event'])) {
362
                $event = $msys['message_event'];
363
            } elseif (isset($msys['unsubscribe_event'])) {
364
                $event = $msys['unsubscribe_event'];
365
            } else {
366
                continue;
367
            }
368
369
            if (isset($event['rcpt_type']) && 'to' !== $event['rcpt_type']) {
370
                // Ignore cc/bcc
371
372
                continue;
373
            }
374
375
            if ('bounce' === $event['type'] && !in_array((int) $event['bounce_class'], [10, 30, 50, 51, 52, 53, 54, 90])) {
376
                // Only parse hard bounces - https://support.sparkpost.com/customer/portal/articles/1929896-bounce-classification-codes
377
                continue;
378
            }
379
380
            if (isset($event['rcpt_meta']['hashId']) && $hashId = $event['rcpt_meta']['hashId']) {
381
                $this->processCallbackByHashId($hashId, $event);
382
383
                continue;
384
            }
385
386
            $this->processCallbackByEmailAddress($event['rcpt_to'], $event);
387
        }
388
    }
389
390
    /**
391
     * Checks with Sparkpost whether the email template is valid.
392
     * Substitution data are taken from the first recipient.
393
     *
394
     * @throws \UnexpectedValueException
395
     */
396
    protected function checkTemplateIsValid(Sparkpost $sparkPostClient, array $sparkPostMessage)
397
    {
398
        // Take substitution_data from the first recipient.
399
        if (empty($sparkPostMessage['substitution_data']) && isset($sparkPostMessage['recipients'][0]['substitution_data'])) {
400
            $sparkPostMessage['substitution_data'] = $sparkPostMessage['recipients'][0]['substitution_data'];
401
            unset($sparkPostMessage['recipients']);
402
        }
403
404
        $promise  = $sparkPostClient->request('POST', 'utils/content-previewer', $sparkPostMessage);
405
        $response = $promise->wait();
406
        $body     = $response->getBody();
407
408
        if (403 === $response->getStatusCode()) {
409
            // We cannot fail as it would be a BC break. Throw a warning and continue.
410
            $this->logger->warning("The permission 'Templates: Preview' is not enabled. Enable it to let Mautic check email template validity before send.");
411
412
            return;
413
        }
414
415
        if ($errorMessage = $this->getErrorMessageFromResponseBody($body)) {
416
            throw new \UnexpectedValueException($errorMessage);
417
        }
418
    }
419
420
    /**
421
     * Check for SparkPost rejection for immediate error messages.
422
     */
423
    private function processImmediateSendFeedback(array $message, array $response)
424
    {
425
        if (!empty($response['errors'][0]['code']) && 1902 == (int) $response['errors'][0]['code']) {
426
            $comments     = $this->getErrorMessageFromResponseBody($response);
427
            $emailAddress = $message['recipients'][0]['address']['email'];
428
            $metadata     = $this->getMetadata();
429
430
            if (isset($metadata[$emailAddress]) && isset($metadata[$emailAddress]['leadId'])) {
431
                $emailId = (!empty($metadata[$emailAddress]['emailId'])) ? $metadata[$emailAddress]['emailId'] : null;
432
                $this->transportCallback->addFailureByContactId($metadata[$emailAddress]['leadId'], $comments, DoNotContact::BOUNCED, $emailId);
433
            }
434
        }
435
    }
436
437
    /**
438
     * Sparkpost renamed the error message property name from 'description' to 'message'.
439
     * Ensure that we get the error message before and after the change is made.
440
     *
441
     * @see https://www.sparkpost.com/blog/error-handling-transmissions-api
442
     *
443
     * @return string
444
     */
445
    private function getErrorMessageFromResponseBody(array $response)
446
    {
447
        if (isset($response['errors'][0]['description'])) {
448
            return $response['errors'][0]['description'];
449
        } elseif (isset($response['errors'][0]['message'])) {
450
            return $response['errors'][0]['message'];
451
        }
452
453
        return null;
454
    }
455
456
    /**
457
     * @param $hashId
458
     */
459
    private function processCallbackByHashId($hashId, array $event)
460
    {
461
        switch ($event['type']) {
462
            case 'bounce':
463
                $this->transportCallback->addFailureByHashId($hashId, $event['raw_reason']);
464
                break;
465
            case 'spam_complaint':
466
                $this->transportCallback->addFailureByHashId($hashId, $event['fbtype'], DoNotContact::UNSUBSCRIBED);
467
                break;
468
            case 'out_of_band':
469
            case 'policy_rejection':
470
                $this->transportCallback->addFailureByHashId($hashId, $event['raw_reason']);
471
                break;
472
            case 'list_unsubscribe':
473
            case 'link_unsubscribe':
474
                $this->transportCallback->addFailureByHashId($hashId, 'unsubscribed', DoNotContact::UNSUBSCRIBED);
475
                break;
476
        }
477
    }
478
479
    /**
480
     * @param $email
481
     */
482
    private function processCallbackByEmailAddress($email, array $event)
483
    {
484
        switch ($event['type']) {
485
            case 'bounce':
486
                $this->transportCallback->addFailureByAddress($email, $event['raw_reason']);
487
                break;
488
            case 'spam_complaint':
489
                $this->transportCallback->addFailureByAddress($email, $event['fbtype'], DoNotContact::UNSUBSCRIBED);
490
                break;
491
            case 'out_of_band':
492
            case 'policy_rejection':
493
                $this->transportCallback->addFailureByAddress($email, $event['raw_reason']);
494
                break;
495
            case 'list_unsubscribe':
496
            case 'link_unsubscribe':
497
                $this->transportCallback->addFailureByAddress($email, 'unsubscribed', DoNotContact::UNSUBSCRIBED);
498
                break;
499
        }
500
    }
501
502
    /**
503
     * Extract and build a campaign ID from the metadata sample.
504
     *
505
     * @return string
506
     */
507
    private function extractCampaignId(array $metadataSet)
508
    {
509
        $id = '';
510
511
        if (!empty($metadataSet['utmTags']['utmCampaign'])) {
512
            $id = $metadataSet['utmTags']['utmCampaign'];
513
        } elseif (!empty($metadataSet['emailId']) && !empty($metadataSet['emailName'])) {
514
            $id = $metadataSet['emailId'].':'.$metadataSet['emailName'];
515
        } elseif (!empty($metadataSet['emailId'])) {
516
            $id = $metadataSet['emailId'];
517
        }
518
519
        return substr($id, 0, 64);
520
    }
521
522
    /**
523
     * @return bool
524
     */
525
    public function ping()
526
    {
527
        return true;
528
    }
529
}
530