Completed
Pull Request — master (#80)
by Frederik
02:01
created

MessageBodyCollection::extract()   A

Complexity

Conditions 5
Paths 2

Size

Total Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5

Importance

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