Issues (39)

src/SparkPostApiTransport.php (4 issues)

Labels
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
use Symfony\Component\Mailer\Event\MessageEvent;
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
    /**
42
     * @var array<mixed>
43
     */
44
    private $apiResult;
45
46
    /**
47
     * @var EventDispatcherInterface
48
     */
49
    private $dispatcher = null;
50
51
    /**
52
     * @param SparkPostApiClient $apiClient
53
     * @param HttpClientInterface|null $client
54
     * @param EventDispatcherInterface|null $dispatcher
55
     * @param LoggerInterface|null $logger
56
     */
57
    public function __construct(SparkPostApiClient $apiClient, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null)
58
    {
59
        $this->apiClient = $apiClient;
60
61
        if ($apiClient->getEuEndpoint()) {
62
            $this->setHost(self::EU_HOST);
63
        } else {
64
            $this->setHost(self::HOST);
65
        }
66
67
        // We need our own reference
68
        $this->dispatcher = $dispatcher;
69
        parent::__construct($client, $dispatcher, $logger);
70
    }
71
72
    public function __toString(): string
73
    {
74
        return sprintf('sparkpost+api://%s', $this->getEndpoint());
75
    }
76
77
    private function dispatchEvent(Email $email, Envelope $envelope = null): void
78
    {
79
        if (!$this->dispatcher) {
80
            return;
81
        }
82
        $sender = $email->getSender()[0] ?? $email->getFrom()[0] ?? null;
83
        $recipients = $email->getTo();
84
        $envelope ??= new Envelope($sender, $recipients);
0 ignored issues
show
It seems like $sender can also be of type null; however, parameter $sender of Symfony\Component\Mailer\Envelope::__construct() does only seem to accept Symfony\Component\Mime\Address, 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

84
        $envelope ??= new Envelope(/** @scrutinizer ignore-type */ $sender, $recipients);
Loading history...
85
        $event = new MessageEvent($email, $envelope, $this);
86
        $this->dispatcher->dispatch($event);
87
    }
88
89
    protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface
90
    {
91
        $this->dispatchEvent($email, $envelope);
92
93
        $disableSending = $email->getHeaders()->has('X-SendingDisabled') || !SparkPostHelper::getSendingEnabled();
94
95
        // We don't really care about the actual response
96
        $response = new MockResponse();
97
98
        $to = $email->getTo();
99
100
        // check .local addresses
101
        foreach ($to as $addr) {
102
            if (str_ends_with($addr->getAddress(), '.local')) {
103
                $disableSending = true;
104
            }
105
        }
106
107
        if ($disableSending) {
108
            $result = [
109
                'total_rejected_recipients' => 0,
110
                'total_accepted_recipients' => count($to),
111
                'id' => 'fake-' . uniqid(),
112
                'disabled' => true,
113
            ];
114
        } else {
115
            $payload = $this->getPayload($email, $envelope);
116
            $result = $this->apiClient->createTransmission($payload);
117
        }
118
119
        // Add email
120
        $result['email'] = implode('; ', array_map(function ($recipient) {
121
            return $recipient->toString();
122
        }, $to));
123
124
        $this->apiResult = $result;
125
126
        $messageId = $result['id'] ?? null;
127
        if ($messageId) {
128
            $sentMessage->setMessageId($messageId);
129
        }
130
131
        if (SparkPostHelper::getLoggingEnabled()) {
132
            $this->logMessageContent($email, $result);
133
        }
134
135
        return $response;
136
    }
137
138
    /**
139
     * @return array<mixed>
140
     */
141
    public function getApiResult(): array
142
    {
143
        return $this->apiResult;
144
    }
145
146
    /**
147
     * @return string
148
     */
149
    private function getEndpoint(): string
150
    {
151
        return ($this->host ?: self::HOST) . ($this->port ? ':' . $this->port : '');
152
    }
153
154
    /**
155
     * @param Email $email
156
     * @return array<array<mixed>>
157
     */
158
    private function buildAttachments(Email $email): array
159
    {
160
        $result = [];
161
        foreach ($email->getAttachments() as $attachment) {
162
            $preparedHeaders = $attachment->getPreparedHeaders();
163
            /** @var ParameterizedHeader $file */
164
            $file = $preparedHeaders->get('Content-Disposition');
165
            /** @var ParameterizedHeader $type */
166
            $type = $preparedHeaders->get('Content-Type');
167
168
            $result[] = [
169
                'name' => $file->getParameter('filename'),
170
                'type' => $type->getValue(),
171
                'data' => base64_encode($attachment->getBody()),
172
            ];
173
        }
174
175
        return $result;
176
    }
177
178
    /**
179
     * @param Email $email
180
     * @param Envelope $envelope
181
     * @return array<string,mixed>
182
     */
183
    public function getPayload(Email $email, Envelope $envelope): array
184
    {
185
        $from = $envelope->getSender();
186
187
        $fromFirstEmail = $from->getAddress();
188
        $fromFirstName = $from->getName();
189
        if (SparkPostHelper::config()->override_admin_email && SparkPostHelper::isAdminEmail($fromFirstEmail)) {
190
            $fromFirstEmail = SparkPostHelper::resolveDefaultFromEmail();
191
        }
192
        if (SparkPostHelper::getEnvForceSender()) {
193
            $fromFirstEmail = SparkPostHelper::getEnvForceSender();
194
        }
195
        if (!$fromFirstName) {
196
            $fromFirstName = EmailUtils::get_displayname_from_rfc_email($fromFirstEmail);
197
        }
198
199
        $toAddresses = [];
200
        $ccAddresses = [];
201
        $bccAddresses = [];
202
203
        foreach ($envelope->getRecipients() as $recipient) {
204
            $type = 'to';
205
            if (\in_array($recipient, $email->getBcc(), true)) {
206
                $type = 'bcc';
207
            } elseif (\in_array($recipient, $email->getCc(), true)) {
208
                $type = 'cc';
209
            }
210
211
            $recipientEmail = $recipient->getAddress();
212
213
            // This is always going to be empty because of Envelope:77
214
            // $this->recipients[] = new Address($recipient->getAddress());
215
            $recipientName = $recipient->getName();
216
            if (!$recipientName) {
217
                $recipientName = EmailUtils::get_displayname_from_rfc_email($recipientEmail);
218
            }
219
220
            switch ($type) {
221
                case 'to':
222
                    $toAddresses[$recipientEmail] = $recipientName;
223
                    break;
224
                case 'cc':
225
                    $ccAddresses[$recipientEmail] = $recipientName;
226
                    break;
227
                case 'bcc':
228
                    $bccAddresses[$recipientEmail] = $recipientName;
229
                    break;
230
            }
231
        }
232
233
        $recipients = [];
234
        $cc = [];
235
        $bcc = [];
236
        $headers = [];
237
        $tags = [];
238
        $metadata = [];
239
        $inlineCss = null;
240
241
        // Mandrill compatibility
242
        // Data is merged with transmission and removed from headers
243
        // @link https://mailchimp.com/developer/transactional/docs/tags-metadata/#tags
244
        $emailHeaders = $email->getHeaders();
245
        if ($emailHeaders->has('X-MC-Tags')) {
246
            $tagsHeader = $emailHeaders->get('X-MC-Tags');
247
            $tags = explode(',', self::getHeaderValue($tagsHeader));
248
            $emailHeaders->remove('X-MC-Tags');
249
        }
250
        if ($emailHeaders->has('X-MC-Metadata')) {
251
            $metadataHeader = $emailHeaders->get('X-MC-Metadata');
252
            $metadata = json_decode(self::getHeaderValue($metadataHeader), true);
253
            $emailHeaders->remove('X-MC-Metadata');
254
        }
255
        if ($emailHeaders->has('X-MC-InlineCSS')) {
256
            $inlineHeader = $emailHeaders->get('X-MC-InlineCSS');
257
            $inlineCss = self::getHeaderValue($inlineHeader);
258
            $emailHeaders->remove('X-MC-InlineCSS');
259
        }
260
261
        // Handle MSYS headers
262
        // Data is merge with transmission and removed from headers
263
        // @link https://developers.sparkpost.com/api/smtp-api.html
264
        $msysHeader = [];
265
        if ($emailHeaders->has('X-MSYS-API')) {
266
            $msysHeaderObj = $emailHeaders->get('X-MSYS-API');
267
            $msysHeader = json_decode(self::getHeaderValue($msysHeaderObj), true);
268
            if (!empty($msysHeader['tags'])) {
269
                $tags = array_merge($tags, $msysHeader['tags']);
270
            }
271
            if (!empty($msysHeader['metadata'])) {
272
                $metadata = array_merge($metadata, $msysHeader['metadata']);
273
            }
274
            $emailHeaders->remove('X-MSYS-API');
275
        }
276
277
        // Build recipients list
278
        // @link https://developers.sparkpost.com/api/recipient-lists.html
279
        $primaryEmail = null;
280
        foreach ($toAddresses as $toEmail => $toName) {
281
            if ($primaryEmail === null) {
282
                $primaryEmail = $toEmail;
283
            }
284
            if (!$toName) {
285
                $toName = $toEmail;
286
            }
287
            $recipient = array(
288
                'address' => array(
289
                    'email' => $toEmail,
290
                    'name' => $toName,
291
                )
292
            );
293
            if (!empty($tags)) {
294
                $recipient['tags'] = $tags;
295
            }
296
            if (!empty($metadata)) {
297
                // For each recipient, you can specify metadata and substitution data to personalize each email
298
                // Recipient metadata takes precedence over transmission metadata
299
                $recipient['metadata'] = $metadata;
300
            }
301
            $recipients[] = $recipient;
302
        }
303
304
        // @link https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
305
        foreach ($ccAddresses as $ccEmail => $ccName) {
306
            $cc[] = array(
307
                'email' => $ccEmail,
308
                'name' => $ccName,
309
                'header_to' => $primaryEmail ? $primaryEmail : $ccEmail,
310
            );
311
        }
312
313
        foreach ($bccAddresses as $bccEmail => $bccName) {
314
            $bcc[] = array(
315
                'email' => $bccEmail,
316
                'name' => $bccName,
317
                'header_to' => $primaryEmail ? $primaryEmail : $bccEmail,
318
            );
319
        }
320
321
        $bodyHtml = (string)$email->getHtmlBody();
322
        $bodyText = (string)$email->getTextBody();
323
324
        if ($bodyHtml) {
325
            // If we ask to provide plain, use our custom method instead of the provided one
326
            if (SparkPostHelper::config()->provide_plain) {
327
                $bodyText = EmailUtils::convert_html_to_text($bodyHtml);
328
            }
329
330
            // Should we inline css
331
            if (!$inlineCss && SparkPostHelper::config()->inline_styles) {
332
                $bodyHtml = EmailUtils::inline_styles($bodyHtml);
333
            }
334
        }
335
336
        // Custom unsubscribe list
337
        if ($emailHeaders->has('List-Unsubscribe')) {
338
            $unsubHeader  = $emailHeaders->get('List-Unsubscribe');
339
            $headers['List-Unsubscribe'] = self::getHeaderValue($unsubHeader);
340
        }
341
342
        $defaultParams = SparkPostHelper::config()->default_params;
343
        if ($inlineCss !== null) {
344
            $defaultParams['inline_css'] = $inlineCss;
345
        }
346
347
        // Build base transmission. Keep in mind that parameters are mapped by the sdk
348
        // @link @link https://developers.sparkpost.com/api/transmissions/#transmissions-post-send-inline-content
349
        $sparkPostMessage = [
350
            'recipients' => $recipients,
351
            'content' => [
352
                'from' => [
353
                    'name' => $fromFirstName,
354
                    'email' => $fromFirstEmail,
355
                ],
356
                'subject' => $email->getSubject(),
357
                'html' => $bodyHtml,
358
                'text' => $bodyText,
359
            ],
360
        ];
361
        if ($email->getReplyTo()) {
362
            $sparkPostMessage['reply_to'] = $email->getReplyTo();
363
        }
364
365
        // Add default params
366
        $sparkPostMessage = array_merge($defaultParams, $sparkPostMessage);
367
        if ($msysHeader) {
368
            $sparkPostMessage = array_merge($sparkPostMessage, $msysHeader);
369
        }
370
371
        // Add remaining elements
372
        if (!empty($cc)) {
373
            $sparkPostMessage['headers.CC'] = $cc;
374
        }
375
        if (!empty($headers)) {
376
            $sparkPostMessage['customHeaders'] = $headers;
377
        }
378
379
        $attachments = $this->buildAttachments($email);
380
        if (count($attachments) > 0) {
381
            $sparkPostMessage['attachments'] = $attachments;
382
        }
383
384
        return $sparkPostMessage;
385
    }
386
387
    /**
388
     * @param HeaderInterface|UnstructuredHeader|null $header
389
     * @return string
390
     */
391
    protected static function getHeaderValue(HeaderInterface $header = null)
392
    {
393
        if (!$header) {
394
            return '';
395
        }
396
        if ($header instanceof UnstructuredHeader) {
397
            return $header->getValue();
398
        }
399
        return $header->getBody();
400
    }
401
402
    /**
403
     * Log message content
404
     *
405
     * @param Email $message
406
     * @param array<mixed> $results Results from the api
407
     * @throws Exception
408
     * @return void
409
     */
410
    protected function logMessageContent(Email $message, $results = [])
411
    {
412
        // Folder not set
413
        $logFolder = SparkPostHelper::getLogFolder();
414
        if (!$logFolder) {
415
            return;
416
        }
417
        // Logging disabled
418
        if (!SparkPostHelper::getLoggingEnabled()) {
419
            return;
420
        }
421
422
        $logContent = "";
423
424
        $subject = $message->getSubject();
425
        if ($message->getHtmlBody()) {
426
            $logContent .= (string)$message->getHtmlBody();
427
        } else {
428
            $logContent .= $message->getBody()->toString();
429
        }
430
        $logContent .= "<hr/>";
431
        $emailHeaders = $message->getHeaders();
432
433
        // Append some extra information at the end
434
        $logContent .= '<pre>Headers:' . "\n\n";
435
        $logContent .= $emailHeaders->toString();
436
437
        // Store subsite
438
        if (class_exists(\SilverStripe\Subsites\Model\Subsite::class)) {
0 ignored issues
show
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...
439
            $state = \SilverStripe\Subsites\State\SubsiteState::singleton();
0 ignored issues
show
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...
440
            $logContent .= "Subsite ID: " . $state->getSubsiteId() . "\n";
441
        }
442
443
        $logContent .= "\n" . 'Results:' . "\n";
444
        $logContent .= print_r($results, true) . "\n";
0 ignored issues
show
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

444
        $logContent .= /** @scrutinizer ignore-type */ print_r($results, true) . "\n";
Loading history...
445
        $logContent .= '</pre>';
446
447
        // Generate filename
448
        $filter = new FileNameFilter();
449
        $title = substr($filter->filter($subject), 0, 35);
450
        $logName = date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '_' . $title;
451
452
        // Store attachments if any
453
        $attachments = $message->getAttachments();
454
        if (!empty($attachments)) {
455
            $logContent .= '<hr />';
456
            foreach ($attachments as $attachment) {
457
                $attachmentDestination = $logFolder . '/' . $logName . '_' . $attachment->getFilename();
458
                file_put_contents($attachmentDestination, $attachment->getBody());
459
                $logContent .= 'File : <a href="' . $attachmentDestination . '">' . $attachment->getFilename() . '</a><br/>';
460
            }
461
        }
462
463
        // Store it
464
        $r = file_put_contents($logFolder . '/' . $logName . '.html', $logContent);
465
466
        if (!$r && Director::isDev()) {
467
            throw new Exception('Failed to store email in ' . $logFolder);
468
        }
469
    }
470
}
471