Passed
Push — master ( 418dab...364f8f )
by Thomas
03:49 queued 50s
created

SparkPostApiTransport::buildAttachments()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 17
rs 9.9666
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\Mailer\Header\TagHeader;
14
use Symfony\Component\Mime\Header\HeaderInterface;
15
use Symfony\Component\Mailer\Header\MetadataHeader;
16
use Symfony\Contracts\HttpClient\ResponseInterface;
17
use Symfony\Component\Mime\Header\UnstructuredHeader;
18
use Symfony\Contracts\HttpClient\HttpClientInterface;
19
use Symfony\Component\HttpClient\Response\MockResponse;
20
use Symfony\Component\Mailer\Transport\AbstractApiTransport;
21
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
22
23
/**
24
 * We create our own class
25
 * We cannot extend easily due to private methods
26
 *
27
 * @link https://developers.sparkpost.com/api/transmissions/
28
 * @link https://github.com/gam6itko/sparkpost-mailer/blob/master/src/Transport/SparkPostApiTransport.php
29
 * @author LeKoala <[email protected]>
30
 */
31
class SparkPostApiTransport extends AbstractApiTransport
32
{
33
    private const HOST = 'api.sparkpost.com';
34
    private const EU_HOST = 'api.eu.sparkpost.com';
35
36
    /**
37
     * @var SparkPostApiClient
38
     */
39
    private $apiClient;
40
41
    private $apiResult;
42
43
    public function __construct(SparkPostApiClient $apiClient, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null)
44
    {
45
        $this->apiClient = $apiClient;
46
47
        if ($apiClient->getEuEndpoint()) {
48
            $this->setHost(self::EU_HOST);
49
        } else {
50
            $this->setHost(self::HOST);
51
        }
52
53
        parent::__construct($client, $dispatcher, $logger);
54
    }
55
56
    public function __toString(): string
57
    {
58
        return sprintf('sparkpost+api://%s', $this->getEndpoint());
59
    }
60
61
    protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface
62
    {
63
        $disableSending = $email->getHeaders()->has('X-SendingDisabled') || !SparkPostHelper::getSendingEnabled();
64
65
        // We don't really care about the actual response
66
        $response = new MockResponse();
67
68
        $to = $email->getTo();
69
70
        if ($disableSending) {
71
            $result = [
72
                'total_rejected_recipients' => 0,
73
                'total_accepted_recipients' => count($to),
74
                'id' => uniqid(),
75
                'disabled' => true,
76
            ];
77
        } else {
78
            $payload = $this->getPayload($email, $envelope);
79
            $result = $this->apiClient->createTransmission($payload);
80
        }
81
82
        // Add email
83
        $result['email'] = implode('; ', array_map(function ($recipient) {
84
            return $recipient->toString();
85
        }, $to));
86
87
        $this->apiResult = $result;
88
89
        $messageId = $result['id'] ?? null;
90
        if ($messageId) {
91
            $sentMessage->setMessageId($messageId);
92
        }
93
94
        if (SparkPostHelper::getLoggingEnabled()) {
95
            $this->logMessageContent($email, $result);
96
        }
97
98
        return $response;
99
    }
100
101
    public function getApiResult(): array
102
    {
103
        return $this->apiResult;
104
    }
105
106
    private function getEndpoint(): ?string
107
    {
108
        return ($this->host ?: self::HOST) . ($this->port ? ':' . $this->port : '');
109
    }
110
111
    private function buildAttachments(Email $email): array
112
    {
113
        $result = [];
114
        foreach ($email->getAttachments() as $attachment) {
115
            /** @var ParameterizedHeader $file */
116
            $file = $attachment->getPreparedHeaders()->get('Content-Disposition');
117
            /** @var ParameterizedHeader $type */
118
            $type = $attachment->getPreparedHeaders()->get('Content-Type');
119
120
            $result[] = [
121
                'name' => $file->getParameter('filename'),
122
                'type' => $type->getValue(),
123
                'data' => base64_encode($attachment->getBody()),
124
            ];
125
        }
126
127
        return $result;
128
    }
129
130
    /**
131
     * @param Email $email
132
     * @param Envelope $envelope
133
     * @return array
134
     */
135
    private function getPayload(Email $email, Envelope $envelope): array
136
    {
137
        $from = $envelope->getSender();
138
139
        $fromFirstEmail = $from->getAddress();
140
        $fromFirstName = $from->getName();
141
        if (SparkPostHelper::config()->override_admin_email && SparkPostHelper::isAdminEmail($fromFirstEmail)) {
142
            $fromFirstEmail = SparkPostHelper::resolveDefaultFromEmail();
143
        }
144
        if (SparkPostHelper::getEnvForceSender()) {
145
            $fromFirstEmail = SparkPostHelper::getEnvForceSender();
146
        }
147
        if (!$fromFirstName) {
148
            $fromFirstName = EmailUtils::get_displayname_from_rfc_email($fromFirstEmail);
149
        }
150
151
        $toAddresses = [];
152
        $ccAddresses = [];
153
        $bccAddresses = [];
154
155
        foreach ($envelope->getRecipients() as $recipient) {
156
            $type = 'to';
157
            if (\in_array($recipient, $email->getBcc(), true)) {
158
                $type = 'bcc';
159
            } elseif (\in_array($recipient, $email->getCc(), true)) {
160
                $type = 'cc';
161
            }
162
163
            $recipientEmail = $recipient->getAddress();
164
            $recipientName = $recipient->getName();
165
            if (!$recipientName) {
166
                $recipientName = EmailUtils::get_displayname_from_rfc_email($recipientEmail);
167
            }
168
169
            switch ($type) {
170
                case 'to':
171
                    $toAddresses[$recipientEmail] = $recipientName;
172
                    break;
173
                case 'cc':
174
                    $ccAddresses[$recipientEmail] = $recipientName;
175
                    break;
176
                case 'bcc':
177
                    $bccAddresses[$recipientEmail] = $recipientName;
178
                    break;
179
            }
180
        }
181
182
        $recipients = [];
183
        $cc = [];
184
        $bcc = [];
185
        $headers = [];
186
        $tags = [];
187
        $metadata = [];
188
        $inlineCss = null;
189
190
        // Mandrill compatibility
191
        // Data is merged with transmission and removed from headers
192
        // @link https://mailchimp.com/developer/transactional/docs/tags-metadata/#tags
193
        $emailHeaders = $email->getHeaders();
194
        if ($emailHeaders->has('X-MC-Tags')) {
195
            $tagsHeader = $emailHeaders->get('X-MC-Tags');
196
            $tags = explode(',', self::getHeaderValue($tagsHeader));
197
            $emailHeaders->remove('X-MC-Tags');
198
        }
199
        if ($emailHeaders->has('X-MC-Metadata')) {
200
            $metadataHeader = $emailHeaders->get('X-MC-Metadata');
201
            $metadata = json_decode(self::getHeaderValue($metadataHeader), JSON_OBJECT_AS_ARRAY);
0 ignored issues
show
Bug introduced by
LeKoala\SparkPost\JSON_OBJECT_AS_ARRAY of type integer is incompatible with the type boolean|null expected by parameter $associative of json_decode(). ( Ignorable by Annotation )

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

201
            $metadata = json_decode(self::getHeaderValue($metadataHeader), /** @scrutinizer ignore-type */ JSON_OBJECT_AS_ARRAY);
Loading history...
202
            $emailHeaders->remove('X-MC-Metadata');
203
        }
204
        if ($emailHeaders->has('X-MC-InlineCSS')) {
205
            $inlineHeader = $emailHeaders->get('X-MC-InlineCSS');
206
            $inlineCss = self::getHeaderValue($inlineHeader);
207
            $emailHeaders->remove('X-MC-InlineCSS');
208
        }
209
210
        // Handle MSYS headers
211
        // Data is merge with transmission and removed from headers
212
        // @link https://developers.sparkpost.com/api/smtp-api.html
213
        $msysHeader = [];
214
        if ($emailHeaders->has('X-MSYS-API')) {
215
            $msysHeaderObj = $emailHeaders->get('X-MSYS-API');
216
            $msysHeader = json_decode(self::getHeaderValue($msysHeaderObj), JSON_OBJECT_AS_ARRAY);
217
            if (!empty($msysHeader['tags'])) {
218
                $tags = array_merge($tags, $msysHeader['tags']);
219
            }
220
            if (!empty($msysHeader['metadata'])) {
221
                $metadata = array_merge($metadata, $msysHeader['metadata']);
222
            }
223
            $emailHeaders->remove('X-MSYS-API');
224
        }
225
226
        // Build recipients list
227
        // @link https://developers.sparkpost.com/api/recipient-lists.html
228
        $primaryEmail = null;
229
        foreach ($toAddresses as $toEmail => $toName) {
230
            if ($primaryEmail === null) {
231
                $primaryEmail = $toEmail;
232
            }
233
            if (!$toName) {
234
                $toName = $toEmail;
235
            }
236
            $recipient = array(
237
                'address' => array(
238
                    'email' => $toEmail,
239
                    'name' => $toName,
240
                )
241
            );
242
            if (!empty($tags)) {
243
                $recipient['tags'] = $tags;
244
            }
245
            // TODO: metadata are not valid?
246
            if (!empty($metadata)) {
247
                $recipient['metadata'] = $metadata;
248
            }
249
            $recipients[] = $recipient;
250
        }
251
252
        // @link https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
253
        foreach ($ccAddresses as $ccEmail => $ccName) {
254
            $cc[] = array(
255
                'email' => $ccEmail,
256
                'name' => $ccName,
257
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
258
            );
259
        }
260
261
        foreach ($bccAddresses as $bccEmail => $bccName) {
262
            $bcc[] = array(
263
                'email' => $bccEmail,
264
                'name' => $bccName,
265
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $ccEmail seems to be defined by a foreach iteration on line 253. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
266
            );
267
        }
268
269
        $bodyHtml = $email->getHtmlBody();
270
        $bodyText = $email->getTextBody();
271
272
273
        // If we ask to provide plain, use our custom method instead of the provided one
274
        if ($bodyHtml && SparkPostHelper::config()->provide_plain) {
275
            $bodyText = EmailUtils::convert_html_to_text($bodyHtml);
0 ignored issues
show
Bug introduced by
It seems like $bodyHtml can also be of type resource; however, parameter $content of LeKoala\SparkPost\EmailU...:convert_html_to_text() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

275
            $bodyText = EmailUtils::convert_html_to_text(/** @scrutinizer ignore-type */ $bodyHtml);
Loading history...
276
        }
277
278
        // Should we inline css
279
        if (!$inlineCss && SparkPostHelper::config()->inline_styles) {
280
            $bodyHtml = EmailUtils::inline_styles($bodyHtml);
0 ignored issues
show
Bug introduced by
It seems like $bodyHtml can also be of type resource; however, parameter $html of LeKoala\SparkPost\EmailUtils::inline_styles() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

280
            $bodyHtml = EmailUtils::inline_styles(/** @scrutinizer ignore-type */ $bodyHtml);
Loading history...
281
        }
282
283
        // Custom unsubscribe list
284
        if ($emailHeaders->has('List-Unsubscribe')) {
285
            $unsubHeader  = $emailHeaders->get('List-Unsubscribe');
286
            $headers['List-Unsubscribe'] = self::getHeaderValue($unsubHeader);
287
        }
288
289
        $defaultParams = SparkPostHelper::config()->default_params;
290
        if ($inlineCss !== null) {
291
            $defaultParams['inline_css'] = $inlineCss;
292
        }
293
294
        // Build base transmission. Keep in mind that parameters are mapped by the sdk
295
        // @link @link https://developers.sparkpost.com/api/transmissions/#transmissions-post-send-inline-content
296
        $sparkPostMessage = [
297
            'recipients' => $recipients,
298
            'content' => [
299
                'from' => [
300
                    'name' => $fromFirstName,
301
                    'email' => $fromFirstEmail,
302
                ],
303
                'subject' => $email->getSubject(),
304
                'html' => $bodyHtml,
305
                'text' => $bodyText,
306
            ],
307
        ];
308
        if ($email->getReplyTo()) {
309
            $sparkPostMessage['reply_to'] = $email->getReplyTo();
310
        }
311
312
        // Add default params
313
        $sparkPostMessage = array_merge($defaultParams, $sparkPostMessage);
314
        if ($msysHeader) {
315
            $sparkPostMessage = array_merge($sparkPostMessage, $msysHeader);
316
        }
317
318
        // Add remaining elements
319
        if (!empty($cc)) {
320
            $sparkPostMessage['headers.CC'] = $cc;
321
        }
322
        if (!empty($headers)) {
323
            $sparkPostMessage['customHeaders'] = $headers;
324
        }
325
326
        $attachments = $this->buildAttachments($email);
327
        if (count($attachments) > 0) {
328
            $sparkPostMessage['attachments'] = $attachments;
329
        }
330
331
        return $sparkPostMessage;
332
    }
333
334
    /**
335
     * @param HeaderInterface|UnstructuredHeader|null $header
336
     * @return string
337
     */
338
    protected static function getHeaderValue(HeaderInterface $header = null)
339
    {
340
        if (!$header) {
341
            return '';
342
        }
343
        if ($header instanceof UnstructuredHeader) {
344
            return $header->getValue();
345
        }
346
        return $header->getBody();
347
    }
348
349
350
    /**
351
     * Log message content
352
     *
353
     * @param Email $message
354
     * @param array $results Results from the api
355
     * @throws Exception
356
     */
357
    protected function logMessageContent(Email $message, $results = [])
358
    {
359
        // Folder not set
360
        $logFolder = SparkPostHelper::getLogFolder();
361
        if (!$logFolder) {
362
            return;
363
        }
364
        // Logging disabled
365
        if (!SparkPostHelper::getLoggingEnabled()) {
366
            return;
367
        }
368
369
        $subject = $message->getSubject();
370
        $body = $message->getBody();
371
        $contentType = $message->getHtmlBody() !== null ? "text/html" : "text";
372
373
        $logContent = $body;
374
        $emailHeaders = $message->getHeaders();
375
376
        // Append some extra information at the end
377
        $logContent .= '<hr><pre>Debug infos:' . "\n\n";
378
        $logContent .= 'To : ' . print_r($message->getTo(), true) . "\n";
0 ignored issues
show
Bug introduced by
Are you sure print_r($message->getTo(), 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

378
        $logContent .= 'To : ' . /** @scrutinizer ignore-type */ print_r($message->getTo(), true) . "\n";
Loading history...
379
        $logContent .= 'Subject : ' . $subject . "\n";
380
        $logContent .= 'From : ' . print_r($message->getFrom(), true) . "\n";
0 ignored issues
show
Bug introduced by
Are you sure print_r($message->getFrom(), 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

380
        $logContent .= 'From : ' . /** @scrutinizer ignore-type */ print_r($message->getFrom(), true) . "\n";
Loading history...
381
        $logContent .= 'Headers:' . "\n" . $emailHeaders->toString() . "\n";
382
        if (!empty($params['recipients'])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $params seems to never exist and therefore empty should always be true.
Loading history...
383
            $logContent .= 'Recipients : ' . print_r($message->getTo(), true) . "\n";
384
        }
385
        $logContent .= 'Results:' . "\n";
386
        $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

386
        $logContent .= /** @scrutinizer ignore-type */ print_r($results, true) . "\n";
Loading history...
387
        $logContent .= '</pre>';
388
389
        // Generate filename
390
        $filter = new FileNameFilter();
391
        $title = substr($filter->filter($subject), 0, 35);
392
        $logName = date('Ymd_His') . '_' . $title;
393
394
        // Store attachments if any
395
        $attachments = $message->getAttachments();
396
        if (!empty($attachments)) {
397
            $logContent .= '<hr />';
398
            foreach ($attachments as $attachment) {
399
                $attachmentDestination = $logFolder . '/' . $logName . '_' . $attachment->getFilename();
400
                file_put_contents($attachmentDestination, $attachment->getBody());
401
                $logContent .= 'File : <a href="' . $attachmentDestination . '">' . $attachment->getFilename() . '</a><br/>';
402
            }
403
        }
404
405
        // Store it
406
        $ext = ($contentType == 'text/html') ? 'html' : 'txt';
407
        $r = file_put_contents($logFolder . '/' . $logName . '.' . $ext, $logContent);
408
409
        if (!$r && Director::isDev()) {
410
            throw new Exception('Failed to store email in ' . $logFolder);
411
        }
412
    }
413
}
414