Passed
Push — master ( 95b6e2...350bb5 )
by Thomas
13:37
created

SparkPostApiTransport::doSendApi()   B

Complexity

Conditions 7
Paths 48

Size

Total Lines 45
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 25
c 1
b 0
f 0
nc 48
nop 3
dl 0
loc 45
rs 8.5866
1
<?php
2
3
namespace LeKoala\SparkPost;
4
5
use Exception;
6
use Psr\Log\LoggerInterface;
7
use Symfony\Component\Mime\Email;
8
use SilverStripe\Control\Director;
9
use Symfony\Component\Mailer\Envelope;
10
use SilverStripe\Assets\FileNameFilter;
11
use Symfony\Component\Mailer\SentMessage;
12
use LeKoala\SparkPost\Api\SparkPostApiClient;
13
use Symfony\Component\Mime\Header\HeaderInterface;
14
use Symfony\Contracts\HttpClient\ResponseInterface;
15
use Symfony\Component\Mime\Header\UnstructuredHeader;
16
use Symfony\Contracts\HttpClient\HttpClientInterface;
17
use Symfony\Component\Mime\Header\ParameterizedHeader;
18
use Symfony\Component\HttpClient\Response\MockResponse;
19
use Symfony\Component\Mailer\Transport\AbstractApiTransport;
20
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
21
22
/**
23
 * We create our own class
24
 * We cannot extend easily due to private methods
25
 *
26
 * @link https://developers.sparkpost.com/api/transmissions/
27
 * @link https://github.com/gam6itko/sparkpost-mailer/blob/master/src/Transport/SparkPostApiTransport.php
28
 * @author LeKoala <[email protected]>
29
 */
30
class SparkPostApiTransport extends AbstractApiTransport
31
{
32
    private const HOST = 'api.sparkpost.com';
33
    private const EU_HOST = 'api.eu.sparkpost.com';
34
35
    /**
36
     * @var SparkPostApiClient
37
     */
38
    private $apiClient;
39
40
    /**
41
     * @var array<mixed>
42
     */
43
    private $apiResult;
44
45
    /**
46
     * @param SparkPostApiClient $apiClient
47
     * @param HttpClientInterface|null $client
48
     * @param EventDispatcherInterface|null $dispatcher
49
     * @param LoggerInterface|null $logger
50
     */
51
    public function __construct(SparkPostApiClient $apiClient, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null)
52
    {
53
        $this->apiClient = $apiClient;
54
55
        if ($apiClient->getEuEndpoint()) {
56
            $this->setHost(self::EU_HOST);
57
        } else {
58
            $this->setHost(self::HOST);
59
        }
60
61
        parent::__construct($client, $dispatcher, $logger);
62
    }
63
64
    public function __toString(): string
65
    {
66
        return sprintf('sparkpost+api://%s', $this->getEndpoint());
67
    }
68
69
    protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface
70
    {
71
        $disableSending = $email->getHeaders()->has('X-SendingDisabled') || !SparkPostHelper::getSendingEnabled();
72
73
        // We don't really care about the actual response
74
        $response = new MockResponse();
75
76
        $to = $email->getTo();
77
78
        // check .local addresses
79
        foreach ($to as $addr) {
80
            if (str_ends_with($addr->getAddress(), '.local')) {
81
                $disableSending = true;
82
            }
83
        }
84
85
        if ($disableSending) {
86
            $result = [
87
                'total_rejected_recipients' => 0,
88
                'total_accepted_recipients' => count($to),
89
                'id' => 'fake-' . uniqid(),
90
                'disabled' => true,
91
            ];
92
        } else {
93
            $payload = $this->getPayload($email, $envelope);
94
            $result = $this->apiClient->createTransmission($payload);
95
        }
96
97
        // Add email
98
        $result['email'] = implode('; ', array_map(function ($recipient) {
99
            return $recipient->toString();
100
        }, $to));
101
102
        $this->apiResult = $result;
103
104
        $messageId = $result['id'] ?? null;
105
        if ($messageId) {
106
            $sentMessage->setMessageId($messageId);
107
        }
108
109
        if (SparkPostHelper::getLoggingEnabled()) {
110
            $this->logMessageContent($email, $result);
111
        }
112
113
        return $response;
114
    }
115
116
    /**
117
     * @return array<mixed>
118
     */
119
    public function getApiResult(): array
120
    {
121
        return $this->apiResult;
122
    }
123
124
    /**
125
     * @return string
126
     */
127
    private function getEndpoint(): string
128
    {
129
        return ($this->host ?: self::HOST) . ($this->port ? ':' . $this->port : '');
130
    }
131
132
    /**
133
     * @param Email $email
134
     * @return array<array<mixed>>
135
     */
136
    private function buildAttachments(Email $email): array
137
    {
138
        $result = [];
139
        foreach ($email->getAttachments() as $attachment) {
140
            $preparedHeaders = $attachment->getPreparedHeaders();
141
            /** @var ParameterizedHeader $file */
142
            $file = $preparedHeaders->get('Content-Disposition');
143
            /** @var ParameterizedHeader $type */
144
            $type = $preparedHeaders->get('Content-Type');
145
146
            $result[] = [
147
                'name' => $file->getParameter('filename'),
148
                'type' => $type->getValue(),
149
                'data' => base64_encode($attachment->getBody()),
150
            ];
151
        }
152
153
        return $result;
154
    }
155
156
    /**
157
     * @param Email $email
158
     * @param Envelope $envelope
159
     * @return array<string,mixed>
160
     */
161
    public function getPayload(Email $email, Envelope $envelope): array
162
    {
163
        $from = $envelope->getSender();
164
165
        $fromFirstEmail = $from->getAddress();
166
        $fromFirstName = $from->getName();
167
        if (SparkPostHelper::config()->override_admin_email && SparkPostHelper::isAdminEmail($fromFirstEmail)) {
168
            $fromFirstEmail = SparkPostHelper::resolveDefaultFromEmail();
169
        }
170
        if (SparkPostHelper::getEnvForceSender()) {
171
            $fromFirstEmail = SparkPostHelper::getEnvForceSender();
172
        }
173
        if (!$fromFirstName) {
174
            $fromFirstName = EmailUtils::get_displayname_from_rfc_email($fromFirstEmail);
175
        }
176
177
        $toAddresses = [];
178
        $ccAddresses = [];
179
        $bccAddresses = [];
180
181
        foreach ($envelope->getRecipients() as $recipient) {
182
            $type = 'to';
183
            if (\in_array($recipient, $email->getBcc(), true)) {
184
                $type = 'bcc';
185
            } elseif (\in_array($recipient, $email->getCc(), true)) {
186
                $type = 'cc';
187
            }
188
189
            $recipientEmail = $recipient->getAddress();
190
191
            // This is always going to be empty because of Envelope:77
192
            // $this->recipients[] = new Address($recipient->getAddress());
193
            $recipientName = $recipient->getName();
194
            if (!$recipientName) {
195
                $recipientName = EmailUtils::get_displayname_from_rfc_email($recipientEmail);
196
            }
197
198
            switch ($type) {
199
                case 'to':
200
                    $toAddresses[$recipientEmail] = $recipientName;
201
                    break;
202
                case 'cc':
203
                    $ccAddresses[$recipientEmail] = $recipientName;
204
                    break;
205
                case 'bcc':
206
                    $bccAddresses[$recipientEmail] = $recipientName;
207
                    break;
208
            }
209
        }
210
211
        $recipients = [];
212
        $cc = [];
213
        $bcc = [];
214
        $headers = [];
215
        $tags = [];
216
        $metadata = [];
217
        $inlineCss = null;
218
219
        // Mandrill compatibility
220
        // Data is merged with transmission and removed from headers
221
        // @link https://mailchimp.com/developer/transactional/docs/tags-metadata/#tags
222
        $emailHeaders = $email->getHeaders();
223
        if ($emailHeaders->has('X-MC-Tags')) {
224
            $tagsHeader = $emailHeaders->get('X-MC-Tags');
225
            $tags = explode(',', self::getHeaderValue($tagsHeader));
226
            $emailHeaders->remove('X-MC-Tags');
227
        }
228
        if ($emailHeaders->has('X-MC-Metadata')) {
229
            $metadataHeader = $emailHeaders->get('X-MC-Metadata');
230
            $metadata = json_decode(self::getHeaderValue($metadataHeader), true);
231
            $emailHeaders->remove('X-MC-Metadata');
232
        }
233
        if ($emailHeaders->has('X-MC-InlineCSS')) {
234
            $inlineHeader = $emailHeaders->get('X-MC-InlineCSS');
235
            $inlineCss = self::getHeaderValue($inlineHeader);
236
            $emailHeaders->remove('X-MC-InlineCSS');
237
        }
238
239
        // Handle MSYS headers
240
        // Data is merge with transmission and removed from headers
241
        // @link https://developers.sparkpost.com/api/smtp-api.html
242
        $msysHeader = [];
243
        if ($emailHeaders->has('X-MSYS-API')) {
244
            $msysHeaderObj = $emailHeaders->get('X-MSYS-API');
245
            $msysHeader = json_decode(self::getHeaderValue($msysHeaderObj), true);
246
            if (!empty($msysHeader['tags'])) {
247
                $tags = array_merge($tags, $msysHeader['tags']);
248
            }
249
            if (!empty($msysHeader['metadata'])) {
250
                $metadata = array_merge($metadata, $msysHeader['metadata']);
251
            }
252
            $emailHeaders->remove('X-MSYS-API');
253
        }
254
255
        // Build recipients list
256
        // @link https://developers.sparkpost.com/api/recipient-lists.html
257
        $primaryEmail = null;
258
        foreach ($toAddresses as $toEmail => $toName) {
259
            if ($primaryEmail === null) {
260
                $primaryEmail = $toEmail;
261
            }
262
            if (!$toName) {
263
                $toName = $toEmail;
264
            }
265
            $recipient = array(
266
                'address' => array(
267
                    'email' => $toEmail,
268
                    'name' => $toName,
269
                )
270
            );
271
            if (!empty($tags)) {
272
                $recipient['tags'] = $tags;
273
            }
274
            // TODO: metadata are not valid?
275
            if (!empty($metadata)) {
276
                $recipient['metadata'] = $metadata;
277
            }
278
            $recipients[] = $recipient;
279
        }
280
281
        // @link https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
282
        foreach ($ccAddresses as $ccEmail => $ccName) {
283
            $cc[] = array(
284
                'email' => $ccEmail,
285
                'name' => $ccName,
286
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
287
            );
288
        }
289
290
        foreach ($bccAddresses as $bccEmail => $bccName) {
291
            $bcc[] = array(
292
                'email' => $bccEmail,
293
                'name' => $bccName,
294
                'header_to' => $primaryEmail ? $primaryEmail : $bccEmail,
295
            );
296
        }
297
298
        $bodyHtml = (string)$email->getHtmlBody();
299
        $bodyText = (string)$email->getTextBody();
300
301
        if ($bodyHtml) {
302
            // If we ask to provide plain, use our custom method instead of the provided one
303
            if (SparkPostHelper::config()->provide_plain) {
304
                $bodyText = EmailUtils::convert_html_to_text($bodyHtml);
305
            }
306
307
            // Should we inline css
308
            if (!$inlineCss && SparkPostHelper::config()->inline_styles) {
309
                $bodyHtml = EmailUtils::inline_styles($bodyHtml);
310
            }
311
        }
312
313
        // Custom unsubscribe list
314
        if ($emailHeaders->has('List-Unsubscribe')) {
315
            $unsubHeader  = $emailHeaders->get('List-Unsubscribe');
316
            $headers['List-Unsubscribe'] = self::getHeaderValue($unsubHeader);
317
        }
318
319
        $defaultParams = SparkPostHelper::config()->default_params;
320
        if ($inlineCss !== null) {
321
            $defaultParams['inline_css'] = $inlineCss;
322
        }
323
324
        // Build base transmission. Keep in mind that parameters are mapped by the sdk
325
        // @link @link https://developers.sparkpost.com/api/transmissions/#transmissions-post-send-inline-content
326
        $sparkPostMessage = [
327
            'recipients' => $recipients,
328
            'content' => [
329
                'from' => [
330
                    'name' => $fromFirstName,
331
                    'email' => $fromFirstEmail,
332
                ],
333
                'subject' => $email->getSubject(),
334
                'html' => $bodyHtml,
335
                'text' => $bodyText,
336
            ],
337
        ];
338
        if ($email->getReplyTo()) {
339
            $sparkPostMessage['reply_to'] = $email->getReplyTo();
340
        }
341
342
        // Add default params
343
        $sparkPostMessage = array_merge($defaultParams, $sparkPostMessage);
344
        if ($msysHeader) {
345
            $sparkPostMessage = array_merge($sparkPostMessage, $msysHeader);
346
        }
347
348
        // Add remaining elements
349
        if (!empty($cc)) {
350
            $sparkPostMessage['headers.CC'] = $cc;
351
        }
352
        if (!empty($headers)) {
353
            $sparkPostMessage['customHeaders'] = $headers;
354
        }
355
356
        $attachments = $this->buildAttachments($email);
357
        if (count($attachments) > 0) {
358
            $sparkPostMessage['attachments'] = $attachments;
359
        }
360
361
        return $sparkPostMessage;
362
    }
363
364
    /**
365
     * @param HeaderInterface|UnstructuredHeader|null $header
366
     * @return string
367
     */
368
    protected static function getHeaderValue(HeaderInterface $header = null)
369
    {
370
        if (!$header) {
371
            return '';
372
        }
373
        if ($header instanceof UnstructuredHeader) {
374
            return $header->getValue();
375
        }
376
        return $header->getBody();
377
    }
378
379
380
    /**
381
     * Log message content
382
     *
383
     * @param Email $message
384
     * @param array<mixed> $results Results from the api
385
     * @throws Exception
386
     * @return void
387
     */
388
    protected function logMessageContent(Email $message, $results = [])
389
    {
390
        // Folder not set
391
        $logFolder = SparkPostHelper::getLogFolder();
392
        if (!$logFolder) {
393
            return;
394
        }
395
        // Logging disabled
396
        if (!SparkPostHelper::getLoggingEnabled()) {
397
            return;
398
        }
399
400
        $logContent = "";
401
402
        $subject = $message->getSubject();
403
        if ($message->getHtmlBody()) {
404
            $logContent .= (string)$message->getHtmlBody();
405
        } else {
406
            $logContent .= $message->getBody()->toString();
407
        }
408
        $logContent .= "<hr/>";
409
        $emailHeaders = $message->getHeaders();
410
411
        // Append some extra information at the end
412
        $logContent .= '<pre>Headers:' . "\n\n";
413
        $logContent .= $emailHeaders->toString();
414
415
        // Store subsite
416
        if (class_exists(\SilverStripe\Subsites\Model\Subsite::class)) {
0 ignored issues
show
Bug introduced by
The type SilverStripe\Subsites\Model\Subsite was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
417
            $state = \SilverStripe\Subsites\State\SubsiteState::singleton();
0 ignored issues
show
Bug introduced by
The type SilverStripe\Subsites\State\SubsiteState was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
418
            $logContent .= "Subsite ID: " . $state->getSubsiteId() . "\n";
419
        }
420
421
        $logContent .= "\n" . 'Results:' . "\n";
422
        $logContent .= print_r($results, true) . "\n";
0 ignored issues
show
Bug introduced by
Are you sure print_r($results, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

422
        $logContent .= /** @scrutinizer ignore-type */ print_r($results, true) . "\n";
Loading history...
423
        $logContent .= '</pre>';
424
425
        // Generate filename
426
        $filter = new FileNameFilter();
427
        $title = substr($filter->filter($subject), 0, 35);
428
        $logName = date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '_' . $title;
429
430
        // Store attachments if any
431
        $attachments = $message->getAttachments();
432
        if (!empty($attachments)) {
433
            $logContent .= '<hr />';
434
            foreach ($attachments as $attachment) {
435
                $attachmentDestination = $logFolder . '/' . $logName . '_' . $attachment->getFilename();
436
                file_put_contents($attachmentDestination, $attachment->getBody());
437
                $logContent .= 'File : <a href="' . $attachmentDestination . '">' . $attachment->getFilename() . '</a><br/>';
438
            }
439
        }
440
441
        // Store it
442
        $r = file_put_contents($logFolder . '/' . $logName . '.html', $logContent);
443
444
        if (!$r && Director::isDev()) {
445
            throw new Exception('Failed to store email in ' . $logFolder);
446
        }
447
    }
448
}
449