MessageBodyCollection::decodeMessageBody()   B
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 7.6393

Importance

Changes 0
Metric Value
dl 0
loc 21
ccs 9
cts 14
cp 0.6429
rs 8.9617
c 0
b 0
f 0
cc 6
nc 6
nop 1
crap 7.6393
1
<?php
2
declare(strict_types=1);
3
4
namespace Genkgo\Mail;
5
6
use Genkgo\Mail\Header\Cc;
7
use Genkgo\Mail\Header\ContentType;
8
use Genkgo\Mail\Header\GenericHeader;
9
use Genkgo\Mail\Header\HeaderName;
10
use Genkgo\Mail\Header\ParsedHeader;
11
use Genkgo\Mail\Header\Subject;
12
use Genkgo\Mail\Header\To;
13
use Genkgo\Mail\Mime\Boundary;
14
use Genkgo\Mail\Mime\EmbeddedImage;
15
use Genkgo\Mail\Mime\HtmlPart;
16
use Genkgo\Mail\Mime\MultiPart;
17
use Genkgo\Mail\Mime\MultiPartInterface;
18
use Genkgo\Mail\Mime\PartInterface;
19
use Genkgo\Mail\Mime\PlainTextPart;
20
use Genkgo\Mail\Mime\ResourceAttachment;
21
use Genkgo\Mail\Stream\Base64DecodedStream;
22
use Genkgo\Mail\Stream\MimeBodyDecodedStream;
23
use Genkgo\Mail\Stream\QuotedPrintableDecodedStream;
24
25
final class MessageBodyCollection
26
{
27
    private const DEFAULT_CHARSET = '<meta http-equiv="Content-Type" content="text/html; charset=%s"/>';
28
29
    /**
30
     * @var string
31
     */
32
    private $html = '';
33
34
    /**
35
     * @var AlternativeText
36
     */
37
    private $text;
38
39
    /**
40
     * @var array|PartInterface[]
41
     */
42
    private $attachments = [];
43
44
    /**
45
     * @var array|PartInterface[]
46
     */
47
    private $embedImages = [];
48
49
    /**
50
     * @param string $html
51
     */
52 39
    public function __construct(string $html = '')
53
    {
54 39
        $this->html = self::ensureHtmlCharset($html);
55 39
        $this->text = AlternativeText::fromHtml($this->html);
56 39
    }
57
58
    /**
59
     * @param string $html
60
     * @return MessageBodyCollection
61
     */
62 6
    public function withHtml(string $html): self
63
    {
64 6
        $clone = clone $this;
65 6
        $clone->html = self::ensureHtmlCharset($html);
66 6
        $clone->text = AlternativeText::fromHtml($clone->html);
67 6
        return $clone;
68
    }
69
70
    /**
71
     * @param string $html
72
     * @return MessageBodyCollection
73
     */
74 18
    public function withHtmlAndNoGeneratedAlternativeText(string $html): self
75
    {
76 18
        $clone = clone $this;
77 18
        $clone->html = $html;
78 18
        return $clone;
79
    }
80
81
    /**
82
     * @param AlternativeText $text
83
     * @return MessageBodyCollection
84
     */
85 19
    public function withAlternativeText(AlternativeText $text): self
86
    {
87 19
        $clone = clone $this;
88 19
        $clone->text = $text;
89 19
        return $clone;
90
    }
91
92
    /**
93
     * @param PartInterface $part
94
     * @return MessageBodyCollection
95
     */
96 10
    public function withAttachment(PartInterface $part): self
97
    {
98
        try {
99 10
            $disposition = $part->getHeader('Content-Disposition')->getValue()->getRaw();
100 9
            if ($disposition !== 'attachment') {
101 1
                throw new \InvalidArgumentException(
102 9
                    'An attachment must have Content-Disposition header with value `attachment`'
103
                );
104
            }
105 2
        } catch (\UnexpectedValueException $e) {
106 1
            throw new \InvalidArgumentException(
107 1
                'An attachment must have an Content-Disposition header'
108
            );
109
        }
110
111 8
        $clone = clone $this;
112 8
        $clone->attachments[] = $part;
113 8
        return $clone;
114
    }
115
116
    /**
117
     * @param EmbeddedImage $embeddedImage
118
     * @return MessageBodyCollection
119
     */
120 5
    public function withEmbeddedImage(EmbeddedImage $embeddedImage): self
121
    {
122 5
        $clone = clone $this;
123 5
        $clone->embedImages[] = $embeddedImage;
124 5
        return $clone;
125
    }
126
127
    /**
128
     * @param MessageInterface $message
129
     * @return MessageBodyCollection
130
     */
131 1
    public function withAttachedMessage(MessageInterface $message): self
132
    {
133
        return $this
134 1
            ->withAttachment(
135 1
                ResourceAttachment::fromString(
136 1
                    (string)$message,
137 1
                    \transliterator_transliterate(
138 1
                        'Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; [:Punctuation:] Remove;',
139 1
                        $this->extractSubject($message)
140 1
                    ) . '.eml',
141 1
                    new ContentType('message/rfc822')
142
                )
143
            );
144
    }
145
146
    /**
147
     * @param MessageInterface $message
148
     * @param QuotationInterface $quotation
149
     * @return MessageBodyCollection
150
     */
151 7
    public function withQuotedMessage(MessageInterface $message, QuotationInterface $quotation): self
152
    {
153 7
        return $quotation->quote($this, $message);
154
    }
155
156
    /**
157
     * @return string
158
     */
159 19
    public function getHtml(): string
160
    {
161 19
        return $this->html;
162
    }
163
164
    /**
165
     * @return AlternativeText
166
     */
167 19
    public function getText(): AlternativeText
168
    {
169 19
        return $this->text;
170
    }
171
172
    /**
173
     * @return array|PartInterface[]
174
     */
175 4
    public function getAttachments(): iterable
176
    {
177 4
        return $this->attachments;
178
    }
179
180
    /**
181
     * @return array|PartInterface[]
182
     */
183 4
    public function getEmbeddedImages(): iterable
184
    {
185 4
        return $this->embedImages;
186
    }
187
188
    /**
189
     * @return MessageInterface
190
     */
191 25
    public function createMessage(): MessageInterface
192
    {
193 25
        return (new MimeMessageFactory())->createMessage($this->createMessageRoot());
194
    }
195
196
    /**
197
     * @param MessageInterface $message
198
     * @return MessageInterface
199
     */
200 1
    public function attachToMessage(MessageInterface $message): MessageInterface
201
    {
202 1
        $newMessage = $this->createMessage();
203
204
        /** @var HeaderInterface[] $headers */
205 1
        foreach ($newMessage->getHeaders() as $headers) {
206 1
            foreach ($headers as $header) {
207 1
                $message = $message->withHeader($header);
208
            }
209
        }
210
211 1
        return $message->withBody($newMessage->getBody());
212
    }
213
214
    /**
215
     * @param MessageInterface $originalMessage
216
     * @return MessageInterface
217
     */
218 6
    public function inReplyTo(MessageInterface $originalMessage): MessageInterface
219
    {
220 6
        return $this->newReply(
221 6
            $originalMessage,
222 6
            $originalMessage->hasHeader('Reply-To') ? ['Reply-To'] : ['From']
223
        );
224
    }
225
226
    /**
227
     * @param MessageInterface $originalMessage
228
     * @return MessageInterface
229
     */
230 2
    public function inReplyToAll(MessageInterface $originalMessage): MessageInterface
231
    {
232 2
        return $this->newReply(
233 2
            $originalMessage,
234 2
            $originalMessage->hasHeader('Reply-To') ? ['Reply-To'] : ['From', 'Cc']
235
        );
236
    }
237
238
    /**
239
     * @param MessageInterface $originalMessage
240
     * @param array<int, string> $replyRecipientHeaderNames
241
     * @return MessageInterface
242
     */
243 8
    private function newReply(MessageInterface $originalMessage, array $replyRecipientHeaderNames): MessageInterface
244
    {
245 8
        $extractedSubject = $this->extractSubject($originalMessage);
246
247
        $reply = $this
248 8
            ->createReferencedMessage($originalMessage)
249 8
            ->withHeader(
250 8
                new Subject(
251 8
                    \substr($extractedSubject, 0, 3) === 'Re:' ? $extractedSubject : 'Re: ' . $extractedSubject
252
                )
253
            );
254
255 8
        foreach ($replyRecipientHeaderNames as $replyRecipientHeaderName) {
256 8
            foreach ($originalMessage->getHeader($replyRecipientHeaderName) as $recipientHeader) {
257 5
                $reply = $reply->withHeader($this->determineReplyHeader($recipientHeader));
258
            }
259
        }
260
261 8
        return $reply;
262
    }
263
264
    /**
265
     * @param MessageInterface $originalMessage
266
     * @return MessageInterface
267
     */
268 3
    public function asForwardTo(MessageInterface $originalMessage): MessageInterface
269
    {
270 3
        $extractedSubject = $this->extractSubject($originalMessage);
271
272
        return $this
273 3
            ->createReferencedMessage($originalMessage)
274 3
            ->withHeader(
275 3
                new Subject(
276 3
                    \substr($extractedSubject, 0, 4) === 'Fwd:' ? $extractedSubject : 'Fwd: ' . $extractedSubject
277
                )
278
            );
279
    }
280
281
    /**
282
     * @return PartInterface
283
     */
284 25
    private function createMessageRoot(): PartInterface
285
    {
286 25
        if (!empty($this->attachments)) {
287 6
            return (new MultiPart(
288 6
                Boundary::newRandom(),
289 6
                new ContentType('multipart/mixed')
290
            ))
291 6
                ->withPart($this->createMessageHumanReadable())
292 6
                ->withParts($this->attachments);
293
        }
294
295 19
        return $this->createMessageHumanReadable();
296
    }
297
298
    /**
299
     * @return PartInterface
300
     */
301 25
    private function createMessageHumanReadable(): PartInterface
302
    {
303 25
        if (!empty($this->embedImages)) {
304 3
            return (new MultiPart(
305 3
                Boundary::newRandom(),
306 3
                new ContentType('multipart/related')
307
            ))
308 3
                ->withPart($this->createMessageText())
309 3
                ->withParts($this->embedImages);
310
        }
311
312 22
        return $this->createMessageText();
313
    }
314
315
    /**
316
     * @return PartInterface
317
     */
318 25
    private function createMessageText(): PartInterface
319
    {
320 25
        if ($this->text->isEmpty() && $this->html === '') {
321 8
            return new PlainTextPart('', 'us-ascii');
322
        }
323
324 17
        if ($this->text->isEmpty()) {
325 2
            return new HtmlPart($this->html);
326
        }
327
328 15
        if ($this->html === '') {
329 1
            return new PlainTextPart((string)$this->text, 'UTF-8');
330
        }
331
332 14
        return (new MultiPart(
333 14
            Boundary::newRandom(),
334 14
            new ContentType('multipart/alternative')
335
        ))
336 14
            ->withPart(new PlainTextPart((string)$this->text, 'UTF-8'))
337 14
            ->withPart(new HtmlPart($this->html));
338
    }
339
340
    /**
341
     * @param MessageInterface $message
342
     * @return MessageBodyCollection
343
     */
344 20
    public static function extract(MessageInterface $message): MessageBodyCollection
345
    {
346 20
        $collection = new self();
347
348
        try {
349 20
            $collection->extractFromMimePart(MultiPart::fromMessage($message));
350 4
        } catch (\InvalidArgumentException $e) {
351 4
            foreach ($message->getHeader('Content-Type') as $header) {
352 4
                $contentType = $header->getValue()->getRaw();
353
                try {
354 4
                    $charset = $header->getValue()->getParameter('charset')->getValue();
355
                } catch (\UnexpectedValueException $e) {
356
                    $charset = '';
357
                }
358
359 4
                if ($contentType === 'text/html') {
360 1
                    $collection->html = self::ensureHtmlCharset(
361 1
                        \rtrim((string)self::decodeMessageBody($message)),
362
                        $charset
363
                    );
364
                }
365
366 4
                if ($contentType === 'text/plain') {
367 3
                    $collection->text = AlternativeText::fromEncodedText(
368 3
                        \rtrim((string)self::decodeMessageBody($message)),
369 3
                        $charset
370
                    );
371
                }
372
            }
373
        }
374
375 20
        return $collection;
376
    }
377
378
    /**
379
     * @param MultiPartInterface $parts
380
     */
381 16
    private function extractFromMimePart(MultiPartInterface $parts): void
382
    {
383 16
        foreach ($parts->getParts() as $part) {
384 16
            $header = $part->getHeader('Content-Type');
385 16
            $contentType = $header->getValue()->getRaw();
386
387
            try {
388 16
                $charset = $header->getValue()->getParameter('charset')->getValue();
389 2
            } catch (\UnexpectedValueException $e) {
390 2
                $charset = '';
391
            }
392
393 16
            try {
394
                $disposition = $part->getHeader('Content-Disposition')->getValue()->getRaw();
395 16
            } catch (\UnexpectedValueException $e) {
396 16
                $disposition = 'inline';
397 16
            }
398
399
            if ($contentType === 'text/html' && $disposition === 'inline') {
400 16
                $this->html = self::ensureHtmlCharset(
401
                    (string)new MimeBodyDecodedStream($part),
402
                    $charset
403 16
                );
404 16
                continue;
405 16
            }
406
407
            if ($contentType === 'text/plain' && $disposition === 'inline') {
408 2
                $this->text = AlternativeText::fromEncodedText((string)new MimeBodyDecodedStream($part), $charset);
409 2
                continue;
410
            }
411 2
412 2
            if ($disposition === 'attachment') {
413 2
                $this->attachments[] = $part;
414
                continue;
415
            }
416 2
417 2
            if ($disposition === 'inline' && \substr($contentType, 0, 6) === 'image/' && $part->hasHeader('Content-ID')) {
418 2
                $this->embedImages[] = $part;
419
                continue;
420
            }
421
422 2
            if ($part instanceof MultiPartInterface) {
423 2
                $this->extractFromMimePart($part);
424
            }
425
        }
426 16
    }
427
428
    /**
429
     * @param MessageInterface $message
430
     * @return string
431
     */
432 12
    private function extractSubject(MessageInterface $message): string
433
    {
434 12
        foreach ($message->getHeader('Subject') as $header) {
435 11
            return $header->getValue()->getRaw();
436
        }
437
438 1
        return '';
439
    }
440
441
    /**
442
     * @param HeaderInterface $header
443
     * @return HeaderInterface
444
     */
445 5
    private function determineReplyHeader(HeaderInterface $header): HeaderInterface
446
    {
447 5
        $headerName = $header->getName();
448 5
        if ($headerName->equals(new HeaderName('Reply-To')) || $headerName->equals(new HeaderName('From'))) {
0 ignored issues
show
Documentation introduced by
new \Genkgo\Mail\Header\HeaderName('Reply-To') is of type object<Genkgo\Mail\Header\HeaderName>, but the function expects a object<self>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Documentation introduced by
new \Genkgo\Mail\Header\HeaderName('From') is of type object<Genkgo\Mail\Header\HeaderName>, but the function expects a object<self>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
449 5
            return new To(AddressList::fromString((string)$header->getValue()));
450
        }
451
452 1
        return new Cc(AddressList::fromString((string)$header->getValue()));
453
    }
454
455
    /**
456
     * @param MessageInterface $originalMessage
457
     * @return MessageInterface
458
     */
459 11
    private function createReferencedMessage(MessageInterface $originalMessage): MessageInterface
460
    {
461 11
        $reply = $this->createMessage();
462
463 11
        foreach ($originalMessage->getHeader('Message-ID') as $messageIdHeader) {
464 2
            $references = $messageIdHeader->getValue()->getRaw();
465 2
            foreach ($originalMessage->getHeader('References') as $referenceHeader) {
466 1
                $references = $referenceHeader->getValue()->getRaw() . ', ' . $references;
467
            }
468
469
            return $reply
470 2
                ->withHeader(
471 2
                    new ParsedHeader(
472 2
                        new HeaderName('In-Reply-To'),
473 2
                        $messageIdHeader->getValue()
474
                    )
475
                )
476 2
                ->withHeader(new GenericHeader('References', $references));
477
        }
478
479 9
        return $reply;
480
    }
481
482
    /**
483
     * @param MessageInterface $message
484
     * @return StreamInterface
485
     */
486 4
    private static function decodeMessageBody(MessageInterface $message): StreamInterface
487
    {
488 4
        foreach ($message->getHeader('Content-Transfer-Encoding') as $header) {
489 2
            $encoding = $header->getValue();
490 2
            switch ($encoding) {
491 2
                case 'quoted-printable':
492
                    return QuotedPrintableDecodedStream::fromString((string)$message->getBody());
493 2
                case 'base64':
494
                    return Base64DecodedStream::fromString((string)$message->getBody());
495 2
                case '7bit':
496
                case '8bit':
497 2
                    return $message->getBody();
498
                default:
499
                    throw new \UnexpectedValueException(
500
                        'Cannot decode message body, unknown transfer encoding ' . $encoding
501
                    );
502
            }
503
        }
504
505 2
        return $message->getBody();
506
    }
507
508
    /**
509
     * @param string $html
510
     * @param string $charset
511
     * @return string
512
     */
513 39
    private static function ensureHtmlCharset(string $html, string $charset = 'UTF-8'): string
514
    {
515 39
        if ($html === '') {
516 38
            return '';
517
        }
518
519 23
        if (\strpos($html, 'content="text/html') !== false || \strpos($html, 'charset="') !== false) {
520 12
            return $html;
521
        }
522
523 21
        $headCloseStart = \strpos($html, '</head>');
524 21
        if ($headCloseStart !== false) {
525 5
            return \substr_replace($html, \sprintf(self::DEFAULT_CHARSET, $charset), $headCloseStart, 0);
526
        }
527
528 19
        $bodyOpenStart = \strpos($html, '<body');
529 19
        if ($bodyOpenStart !== false) {
530 15
            return \substr_replace($html, '<head>' . \sprintf(self::DEFAULT_CHARSET, $charset) . '</head>', $bodyOpenStart, 0);
531
        }
532
533 4
        return '<html><head>' . \sprintf(self::DEFAULT_CHARSET, $charset) . '</head><body>' . $html . '</body></html>';
534
    }
535
}
536