Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
44 / 44
CRAP
100.00% covered (success)
100.00%
1 / 1
Part
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
44 / 44
119
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 addHeader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addHeaders
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeadersAsArray
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 addFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 detectContentType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 text
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 html
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 attachment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 attachmentFromContent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addPart
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addParts
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getParts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasParts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSubType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSubType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSubType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 inferSubType
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
12
 setBoundary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBoundary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasBoundary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateBoundary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setContentId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasAttachment
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 hasAttachments
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getAttachments
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getContentType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getFilename
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
17
 getContents
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 renderHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderParts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 renderBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 renderRaw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Mime;
16
17use Pop\Mime\Part\Exception;
18use Pop\Utils\File;
19
20/**
21 * MIME message part class
22 *
23 * @category   Pop
24 * @package    Pop\Mime
25 * @author     Nick Sagona, III <dev@noladev.com>
26 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    3.0.0
29 */
30class Part
31{
32
33    /**
34     * Headers
35     * @var array
36     */
37    protected array $headers = [];
38
39    /**
40     * Body
41     * @var ?Part\Body
42     */
43    protected ?Part\Body $body = null;
44
45    /**
46     * Nested parts
47     * @var array
48     */
49    protected array $parts = [];
50
51    /**
52     * Subtype
53     * @var ?string
54     */
55    protected ?string $subType = null;
56
57    /**
58     * Boundary
59     * @var ?string
60     */
61    protected ?string $boundary = null;
62
63    /**
64     * Constructor
65     *
66     * Instantiate the mime part object
67     *
68     */
69    public function __construct()
70    {
71        $args = func_get_args();
72        foreach ($args as $arg) {
73            if (is_array($arg)) {
74                foreach ($arg as $a) {
75                    if ($a instanceof Part\Header) {
76                        $this->addHeader($a);
77                    } else if ($a instanceof Part) {
78                        $this->addPart($a);
79                    }
80                }
81            } else if ($arg instanceof Part\Header) {
82                $this->addHeader($arg);
83            } else if ($arg instanceof Part\Body) {
84                $this->setBody($arg);
85            }
86        }
87    }
88
89    /**
90     * Add a header
91     *
92     * @param  Part\Header|string  $header
93     * @param  ?string             $value
94     * @return Part
95     */
96    public function addHeader(Part\Header|string $header, ?string $value = null): Part
97    {
98        if ($header instanceof Part\Header) {
99            $this->headers[$header->getName()] = $header;
100        } else {
101            $this->headers[$header] = new Part\Header($header, $value);
102        }
103
104        return $this;
105    }
106
107    /**
108     * Add headers
109     *
110     * @param  array $headers
111     * @return Part
112     */
113    public function addHeaders(array $headers): Part
114    {
115        foreach ($headers as $header => $value) {
116            if ($value instanceof Part\Header) {
117                $this->addHeader($value);
118            } else {
119                $this->addHeader($header, $value);
120            }
121        }
122        return $this;
123    }
124
125    /**
126     * Get headers
127     *
128     * @return array
129     */
130    public function getHeaders(): array
131    {
132        return $this->headers;
133    }
134
135    /**
136     * Get headers as array
137     *
138     * @return array
139     */
140    public function getHeadersAsArray(): array
141    {
142        $headers = [];
143
144        foreach ($this->headers as $header) {
145            $headers[$header->getName()] = $header->getValuesAsStrings();
146        }
147
148        return $headers;
149    }
150
151    /**
152     * Get headers
153     *
154     * @param  string $name
155     * @return Part\Header|null
156     */
157    public function getHeader(string $name): Part\Header|null
158    {
159        return $this->headers[$name] ?? null;
160    }
161
162    /**
163     * Has header
164     *
165     * @param  string $name
166     * @return bool
167     */
168    public function hasHeader(string $name): bool
169    {
170        return (isset($this->headers[$name]));
171    }
172
173    /**
174     * Has headers
175     *
176     * @return bool
177     */
178    public function hasHeaders(): bool
179    {
180        return (count($this->headers) > 0);
181    }
182
183    /**
184     * Remove header
185     *
186     * @param  string $name
187     * @return Part
188     */
189    public function removeHeader(string $name): Part
190    {
191        if (isset($this->headers[$name])) {
192            unset($this->headers[$name]);
193        }
194        return $this;
195    }
196
197    /**
198     * Set body
199     *
200     * @param  Part\Body|string $body
201     * @return Part
202     */
203    public function setBody(Part\Body|string$body): Part
204    {
205        $this->body = ($body instanceof Part\Body) ? $body : new Part\Body($body);
206        return $this;
207    }
208
209    /**
210     * Add file as body
211     *
212     * @param string             $file
213     * @param string             $disposition
214     * @param Part\Body\Encoding $encoding
215     * @param int|bool           $split
216     * @throws Exception
217     * @return Part
218     */
219    public function addFile(
220        string $file, string $disposition = 'attachment', Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64, int|bool $split = true
221    ): Part
222    {
223        $header = new Part\Header('Content-Disposition');
224        $header->addValue($disposition, null, ['filename' => basename($file)]);
225        $this->addHeader($header);
226
227        $this->body = new Part\Body();
228        $this->body->setContentFromFile($file, $encoding, $split);
229        return $this;
230    }
231
232    /**
233     * Detect a content type from a filename's extension
234     *
235     * @param  string $filename
236     * @return string
237     */
238    protected static function detectContentType(string $filename): string
239    {
240        if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) === 'md') {
241            return 'text/plain';
242        }
243        return File::getFileMimeType($filename) ?? 'application/octet-stream';
244    }
245
246    /**
247     * Create a text/plain part
248     *
249     * @param  string $content
250     * @return static
251     */
252    public static function text(string $content): static
253    {
254        $part = new static();
255        $part->addHeader('Content-Type', 'text/plain');
256        $part->setBody($content);
257        return $part;
258    }
259
260    /**
261     * Create a text/html part
262     *
263     * @param  string $content
264     * @return static
265     */
266    public static function html(string $content): static
267    {
268        $part = new static();
269        $part->addHeader('Content-Type', 'text/html');
270        $part->setBody($content);
271        return $part;
272    }
273
274    /**
275     * Create an attachment part from a file on disk
276     *
277     * @param  string             $file
278     * @param  ?string            $contentType
279     * @param  string             $disposition
280     * @param  Part\Body\Encoding $encoding
281     * @param  int|bool           $split
282     * @return static
283     */
284    public static function attachment(
285        string $file, ?string $contentType = null, string $disposition = 'attachment',
286        Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64, int|bool $split = true
287    ): static
288    {
289        $part = new static();
290        $part->addHeader('Content-Type', $contentType ?? static::detectContentType($file));
291        $part->addFile($file, $disposition, $encoding, $split);
292        return $part;
293    }
294
295    /**
296     * Create an attachment part from in-memory content
297     *
298     * @param  string             $content
299     * @param  string             $filename
300     * @param  ?string            $contentType
301     * @param  string             $disposition
302     * @param  Part\Body\Encoding $encoding
303     * @param  int|bool           $split
304     * @return static
305     */
306    public static function attachmentFromContent(
307        string $content, string $filename, ?string $contentType = null, string $disposition = 'attachment',
308        Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64, int|bool $split = true
309    ): static
310    {
311        $part = new static();
312        $part->addHeader('Content-Type', $contentType ?? static::detectContentType($filename));
313
314        $header = new Part\Header('Content-Disposition');
315        $header->addValue($disposition, null, ['filename' => basename($filename)]);
316        $part->addHeader($header);
317
318        $body = new Part\Body($content, $encoding, $split);
319        $body->setAsFile(true);
320        $part->setBody($body);
321
322        return $part;
323    }
324
325    /**
326     * Get body
327     *
328     * @return Part\Body
329     */
330    public function getBody(): Part\Body
331    {
332        return $this->body;
333    }
334
335    /**
336     * Has body
337     *
338     * @return bool
339     */
340    public function hasBody(): bool
341    {
342        return ($this->body !== null);
343    }
344
345    /**
346     * Add a nested part
347     *
348     * @param  Part $part
349     * @return Part
350     */
351    public function addPart(Part $part): Part
352    {
353        $this->parts[] = $part;
354        return $this;
355    }
356
357    /**
358     * Add nested parts
359     *
360     * @param  array $parts
361     * @return Part
362     */
363    public function addParts(array $parts): Part
364    {
365        foreach ($parts as $part) {
366            if (is_array($part)) {
367                $file = false;
368                foreach ($part as $p) {
369                    if (($p->hasBody()) && ($p->getBody()->isFile())) {
370                        $file = true;
371                    }
372                }
373                $subType  = ($file) ? 'mixed' : 'alternative';
374                $subParts = new Part();
375                $subParts->setSubType($subType);
376                $subParts->addParts($part);
377                $this->addPart($subParts);
378            } else {
379                $this->addPart($part);
380            }
381        }
382        return $this;
383    }
384
385    /**
386     * Get nested parts
387     *
388     * @return array
389     */
390    public function getParts(): array
391    {
392        return $this->parts;
393    }
394
395    /**
396     * Has nested parts
397     *
398     * @return bool
399     */
400    public function hasParts(): bool
401    {
402        return (count($this->parts) > 0);
403    }
404
405    /**
406     * Set subtype
407     *
408     * @param  string $subType
409     * @return Part
410     */
411    public function setSubType(string $subType): Part
412    {
413        $this->subType = $subType;
414        return $this;
415    }
416
417    /**
418     * Get subtype
419     *
420     * @return string|null
421     */
422    public function getSubType(): ?string
423    {
424        return $this->subType;
425    }
426
427    /**
428     * Has subtype
429     *
430     * @return bool
431     */
432    public function hasSubType(): bool
433    {
434        return ($this->subType !== null);
435    }
436
437    /**
438     * Infer and set the subtype (alternative/mixed) from the current nested parts
439     *
440     * @return Part
441     */
442    public function inferSubType(): Part
443    {
444        $hasText = false;
445        $hasHtml = false;
446        $hasFile = false;
447
448        foreach ($this->parts as $part) {
449            if ($part->hasBody() && $part->getBody()->isFile()) {
450                $hasFile = true;
451                continue;
452            }
453            $contentType = $part->hasHeader('Content-Type') ? $part->getContentType() : null;
454            if (($contentType !== null) && str_starts_with($contentType, 'text/plain')) {
455                $hasText = true;
456            } else if (($contentType !== null) && str_starts_with($contentType, 'text/html')) {
457                $hasHtml = true;
458            }
459        }
460
461        if ($hasFile) {
462            $this->setSubType('mixed');
463        } else if ($hasText && $hasHtml) {
464            $this->setSubType('alternative');
465        }
466
467        return $this;
468    }
469
470    /**
471     * Set boundary
472     *
473     * @param  string $boundary
474     * @return Part
475     */
476    public function setBoundary(string $boundary): Part
477    {
478        $this->boundary = $boundary;
479        return $this;
480    }
481
482    /**
483     * Get boundary
484     *
485     * @return string
486     */
487    public function getBoundary(): string
488    {
489        return $this->boundary;
490    }
491
492    /**
493     * Has boundary
494     *
495     * @return bool
496     */
497    public function hasBoundary(): bool
498    {
499        return ($this->boundary !== null);
500    }
501
502    /**
503     * Generate boundary
504     *
505     * @return string
506     */
507    public function generateBoundary(): string
508    {
509        $this->setBoundary(sha1(uniqid()));
510        return $this->boundary;
511    }
512
513    /**
514     * Generate a message/content ID
515     *
516     * @param  ?string $domain
517     * @return string
518     */
519    public function generateId(?string $domain = null): string
520    {
521        $left   = md5(getmypid() . '.' . time() . '.' . uniqid((string)mt_rand(), true));
522        $domain = $domain ?? ($_SERVER['SERVER_NAME'] ?? 'localhost');
523        return '<' . $left . '@' . $domain . '>';
524    }
525
526    /**
527     * Set the Content-ID header
528     *
529     * @param  ?string $id
530     * @param  ?string $domain
531     * @return Part
532     */
533    public function setContentId(?string $id = null, ?string $domain = null): Part
534    {
535        $this->addHeader('Content-ID', $id ?? $this->generateId($domain));
536        return $this;
537    }
538
539    /**
540     * Has attachment (check via a header)
541     *
542     * @return bool
543     */
544    public function hasAttachment(): bool
545    {
546        $result = false;
547
548        foreach ($this->headers as $header) {
549            if ($header->isAttachment()) {
550                $result = true;
551                break;
552            }
553        }
554
555        if ((!$result) && ($this->hasParts())) {
556            $result = $this->hasAttachments();
557        }
558
559        return $result;
560    }
561
562    /**
563     * Does message have attachments (check via parts)
564     *
565     * @return bool
566     */
567    public function hasAttachments(): bool
568    {
569        foreach ($this->parts as $part) {
570            if ($part->hasAttachment()) {
571                return true;
572            }
573        }
574        return false;
575    }
576
577    /**
578     * Get attachments
579     *
580     * @return array
581     */
582    public function getAttachments(): array
583    {
584        $attachments = [];
585
586        foreach ($this->parts as $part) {
587            if ($part->getBody()->isFile()) {
588                $attachments[] = $part;
589            }
590        }
591
592        return $attachments;
593    }
594
595    /**
596     * Get content-type
597     *
598     * @return string|null
599     */
600    public function getContentType(): ?string
601    {
602        $contentType = null;
603
604        if ($this->hasHeader('Content-Type') && (count($this->getHeader('Content-Type')->getValues()) == 1)) {
605            $contentType = (string)$this->getHeader('Content-Type')->getValue(0);
606        }
607
608        return $contentType;
609    }
610
611    /**
612     * Get attachment filename
613     *
614     * @return string|null
615     */
616    public function getFilename(): string|null
617    {
618        $filename = null;
619
620        if ($this->getBody()->isFile()) {
621            // Check Content-Disposition header (standard)
622            if ($this->hasHeader('Content-Disposition') && (count($this->getHeader('Content-Disposition')->getValues()) == 1)) {
623                $header = $this->getHeader('Content-Disposition');
624                if ($header->getValue(0)->hasParameter('filename')) {
625                    $filename = $header->getValue(0)->getParameter('filename');
626                } else if ($header->getValue(0)->hasParameter('name')) {
627                    $filename = $header->getValue(0)->getParameter('name');
628                }
629            }
630
631            // Else, check Content-Type header (non-standard)
632            if ($filename === null) {
633                if ($this->hasHeader('Content-Type') && (count($this->getHeader('Content-Type')->getValues()) == 1)) {
634                    $header = $this->getHeader('Content-Type');
635                    if ($header->getValue(0)->hasParameter('filename')) {
636                        $filename = $header->getValue(0)->getParameter('filename');
637                    } else if ($header->getValue(0)->hasParameter('name')) {
638                        $filename = $header->getValue(0)->getParameter('name');
639                    }
640                }
641            }
642
643            // Else, check Content-Description header (non-standard)
644            if ($filename === null) {
645                if ($this->hasHeader('Content-Description') && (count($this->getHeader('Content-Description')->getValues()) == 1)) {
646                    $header = $this->getHeader('Content-Description');
647                    if ($header->getValue(0)->hasParameter('filename')) {
648                        $filename = $header->getValue(0)->getParameter('filename');
649                    } else if ($header->getValue(0)->hasParameter('name')) {
650                        $filename = $header->getValue(0)->getParameter('name');
651                    }
652                }
653            }
654        }
655
656        // Decode filename, if encoded
657        if ($filename !== null) {
658            $filename = Part\Header\EncodedWord::decode($filename);
659        }
660
661        return $filename;
662    }
663
664    /**
665     * Get contents (decoded)
666     *
667     * @return mixed
668     */
669    public function getContents(): mixed
670    {
671        $content = $this->body->getContent();
672
673        if ($this->body->isEncoded()) {
674            if ($this->body->isBase64Encoding()) {
675                $content = base64_decode($content);
676            } else if ($this->body->isQuotedPrintableEncoding()) {
677                $content = quoted_printable_decode($content);
678            } else if ($this->body->isUrlEncoding()) {
679                $content = urldecode($content);
680            } else if ($this->body->isRawUrlEncoding()) {
681                $content = rawurldecode($content);
682            }
683            // Binary/7bit/8bit are identity encodings - content is already correct as-is.
684        }
685
686        return $content;
687    }
688
689    /**
690     * Render the part headers
691     *
692     * @return string
693     */
694    public function renderHeaders(): string
695    {
696        return implode("\r\n", $this->headers) . "\r\n\r\n";
697    }
698
699    /**
700     * Render the parts
701     *
702     * @param  bool $preamble
703     * @param  bool $headers
704     * @return string
705     */
706    public function renderParts(bool $preamble = true, bool $headers = true): string
707    {
708        $parts = '';
709
710        $boundary = (!$this->hasBoundary()) ? $this->generateBoundary() : $this->boundary;
711        if (!($this->hasHeader('Content-Type')) && ($this->hasSubType())) {
712            $this->addHeader(
713                new Part\Header('Content-Type', new Part\Header\Value('multipart/' . $this->subType, null, ['boundary' =>  $boundary]))
714            );
715        }
716        if (($headers) && ($this->hasHeaders())) {
717            $parts .= $this->renderHeaders();
718        }
719        if ($preamble) {
720            $parts .= "This is a multi-part message in MIME format.\r\n";
721        }
722        foreach ($this->parts as $part) {
723            $parts .= "--" . $boundary . "\r\n" . $part . "\r\n";
724        }
725
726        $parts .= "--" . $boundary . "--\r\n";
727
728        return $parts;
729    }
730
731    /**
732     * Render the part body
733     *
734     * @return string
735     */
736    public function renderBody(): string
737    {
738        return $this->body->render();
739    }
740
741    /**
742     * Render the part
743     *
744     * @param  bool $preamble
745     * @return string
746     */
747    public function render(bool $preamble = true): string
748    {
749        $messagePart = '';
750
751        if ($this->hasParts()) {
752            $messagePart .= $this->renderParts($preamble);
753        } else if ($this->hasBody()) {
754            if ((!$this->hasHeader('Content-Transfer-Encoding')) && ($this->body->hasEncoding())) {
755                $encoding = $this->body->getEncoding()?->toHeaderValue();
756                if ($encoding !== null) {
757                    $this->addHeader(new Part\Header('Content-Transfer-Encoding', $encoding));
758                }
759            }
760            if ($this->hasHeaders()) {
761                $messagePart .= $this->renderHeaders();
762            }
763            $messagePart .= $this->renderBody();
764        }
765
766        return $messagePart;
767    }
768
769
770    /**
771     * Render the part raw (no headers or preamble)
772     *
773     * @return string
774     */
775    public function renderRaw(): string
776    {
777        return $this->renderParts(false, false);
778    }
779
780    /**
781     * Render the part
782     *
783     * @return string
784     */
785    public function __toString(): string
786    {
787        return $this->render();
788    }
789
790}