Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
165 / 165
100.00% covered (success)
100.00%
36 / 36
CRAP
100.00% covered (success)
100.00%
1 / 1
Part
100.00% covered (success)
100.00%
165 / 165
100.00% covered (success)
100.00%
36 / 36
109
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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 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
 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
 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%
29 / 29
100.00% covered (success)
100.00%
1 / 1
24
 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%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 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
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Mime;
15
16use Pop\Mime\Part\Exception;
17
18/**
19 * MIME message part class
20 *
21 * @category   Pop
22 * @package    Pop\Mime
23 * @author     Nick Sagona, III <dev@nolainteractive.com>
24 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
25 * @license    http://www.popphp.org/license     New BSD License
26 * @version    2.0.0
27 */
28class Part
29{
30
31    /**
32     * Headers
33     * @var array
34     */
35    protected array $headers = [];
36
37    /**
38     * Body
39     * @var ?Part\Body
40     */
41    protected ?Part\Body $body = null;
42
43    /**
44     * Nested parts
45     * @var array
46     */
47    protected array $parts = [];
48
49    /**
50     * Subtype
51     * @var ?string
52     */
53    protected ?string $subType = null;
54
55    /**
56     * Boundary
57     * @var ?string
58     */
59    protected ?string $boundary = null;
60
61    /**
62     * Constructor
63     *
64     * Instantiate the mime part object
65     *
66     */
67    public function __construct()
68    {
69        $args = func_get_args();
70        foreach ($args as $arg) {
71            if (is_array($arg)) {
72                foreach ($arg as $a) {
73                    if ($a instanceof Part\Header) {
74                        $this->addHeader($a);
75                    } else if ($a instanceof Part) {
76                        $this->addPart($a);
77                    }
78                }
79            } else if ($arg instanceof Part\Header) {
80                $this->addHeader($arg);
81            } else if ($arg instanceof Part\Body) {
82                $this->setBody($arg);
83            }
84        }
85    }
86
87    /**
88     * Add a header
89     *
90     * @param  Part\Header|string  $header
91     * @param  ?string             $value
92     * @return Part
93     */
94    public function addHeader(Part\Header|string $header, ?string $value = null): Part
95    {
96        if ($header instanceof Part\Header) {
97            $this->headers[$header->getName()] = $header;
98        } else {
99            $this->headers[$header] = new Part\Header($header, $value);
100        }
101
102        return $this;
103    }
104
105    /**
106     * Add headers
107     *
108     * @param  array $headers
109     * @return Part
110     */
111    public function addHeaders(array $headers): Part
112    {
113        foreach ($headers as $header => $value) {
114            if ($value instanceof Part\Header) {
115                $this->addHeader($value);
116            } else {
117                $this->addHeader($header, $value);
118            }
119        }
120        return $this;
121    }
122
123    /**
124     * Get headers
125     *
126     * @return array
127     */
128    public function getHeaders(): array
129    {
130        return $this->headers;
131    }
132
133    /**
134     * Get headers as array
135     *
136     * @return array
137     */
138    public function getHeadersAsArray(): array
139    {
140        $headers = [];
141
142        foreach ($this->headers as $header) {
143            $headers[$header->getName()] = $header->getValuesAsStrings();
144        }
145
146        return $headers;
147    }
148
149    /**
150     * Get headers
151     *
152     * @param  string $name
153     * @return Part\Header|null
154     */
155    public function getHeader(string $name): Part\Header|null
156    {
157        return $this->headers[$name] ?? null;
158    }
159
160    /**
161     * Has header
162     *
163     * @param  string $name
164     * @return bool
165     */
166    public function hasHeader(string $name): bool
167    {
168        return (isset($this->headers[$name]));
169    }
170
171    /**
172     * Has headers
173     *
174     * @return bool
175     */
176    public function hasHeaders(): bool
177    {
178        return (count($this->headers) > 0);
179    }
180
181    /**
182     * Remove header
183     *
184     * @param  string $name
185     * @return Part
186     */
187    public function removeHeader(string $name): Part
188    {
189        if (isset($this->headers[$name])) {
190            unset($this->headers[$name]);
191        }
192        return $this;
193    }
194
195    /**
196     * Set body
197     *
198     * @param  Part\Body|string $body
199     * @return Part
200     */
201    public function setBody(Part\Body|string$body): Part
202    {
203        $this->body = ($body instanceof Part\Body) ? $body : new Part\Body($body);
204        return $this;
205    }
206
207    /**
208     * Add file as body
209     *
210     * @param string   $file
211     * @param string   $disposition
212     * @param string   $encoding
213     * @param int|bool $split
214     * @throws Exception
215     * @return Part
216     */
217    public function addFile(
218        string $file, string $disposition = 'attachment', string $encoding = Part\Body::BASE64, int|bool $split = true
219    ): Part
220    {
221        if ($disposition !== null) {
222            $header = new Part\Header('Content-Disposition');
223            $header->addValue($disposition, null, ['filename' => basename($file)]);
224            $this->addHeader($header);
225        }
226        $this->body = new Part\Body();
227        $this->body->setContentFromFile($file, $encoding, $split);
228        return $this;
229    }
230
231    /**
232     * Get body
233     *
234     * @return Part\Body
235     */
236    public function getBody(): Part\Body
237    {
238        return $this->body;
239    }
240
241    /**
242     * Has body
243     *
244     * @return bool
245     */
246    public function hasBody(): bool
247    {
248        return ($this->body !== null);
249    }
250
251    /**
252     * Add a nested part
253     *
254     * @param  Part $part
255     * @return Part
256     */
257    public function addPart(Part $part): Part
258    {
259        $this->parts[] = $part;
260        return $this;
261    }
262
263    /**
264     * Add nested parts
265     *
266     * @param  array $parts
267     * @return Part
268     */
269    public function addParts(array $parts): Part
270    {
271        foreach ($parts as $part) {
272            if (is_array($part)) {
273                $file = false;
274                foreach ($part as $p) {
275                    if (($p->hasBody()) && ($p->getBody()->isFile())) {
276                        $file = true;
277                    }
278                }
279                $subType  = ($file) ? 'mixed' : 'alternative';
280                $subParts = new Part();
281                $subParts->setSubType($subType);
282                $subParts->addParts($part);
283                $this->addPart($subParts);
284            } else {
285                $this->addPart($part);
286            }
287        }
288        return $this;
289    }
290
291    /**
292     * Get nested parts
293     *
294     * @return array
295     */
296    public function getParts(): array
297    {
298        return $this->parts;
299    }
300
301    /**
302     * Has nested parts
303     *
304     * @return bool
305     */
306    public function hasParts(): bool
307    {
308        return (count($this->parts) > 0);
309    }
310
311    /**
312     * Set subtype
313     *
314     * @param  string $subType
315     * @return Part
316     */
317    public function setSubType(string $subType): Part
318    {
319        $this->subType = $subType;
320        return $this;
321    }
322
323    /**
324     * Get subtype
325     *
326     * @return string
327     */
328    public function getSubType(): string
329    {
330        return $this->subType;
331    }
332
333    /**
334     * Has subtype
335     *
336     * @return bool
337     */
338    public function hasSubType(): bool
339    {
340        return ($this->subType !== null);
341    }
342
343    /**
344     * Set boundary
345     *
346     * @param  string $boundary
347     * @return Part
348     */
349    public function setBoundary(string $boundary): Part
350    {
351        $this->boundary = $boundary;
352        return $this;
353    }
354
355    /**
356     * Get boundary
357     *
358     * @return string
359     */
360    public function getBoundary(): string
361    {
362        return $this->boundary;
363    }
364
365    /**
366     * Has boundary
367     *
368     * @return bool
369     */
370    public function hasBoundary(): bool
371    {
372        return ($this->boundary !== null);
373    }
374
375    /**
376     * Generate boundary
377     *
378     * @return string
379     */
380    public function generateBoundary(): string
381    {
382        $this->setBoundary(sha1(uniqid()));
383        return $this->boundary;
384    }
385
386    /**
387     * Has attachment (check via a header)
388     *
389     * @return bool
390     */
391    public function hasAttachment(): bool
392    {
393        $result = false;
394
395        foreach ($this->headers as $header) {
396            if ($header->isAttachment()) {
397                $result = true;
398                break;
399            }
400        }
401
402        if ((!$result) && ($this->hasParts())) {
403            $result = $this->hasAttachments();
404        }
405
406        return $result;
407    }
408
409    /**
410     * Does message have attachments (check via parts)
411     *
412     * @return bool
413     */
414    public function hasAttachments(): bool
415    {
416        foreach ($this->parts as $part) {
417            if ($part->hasAttachment()) {
418                return true;
419            }
420        }
421        return false;
422    }
423
424    /**
425     * Get attachments
426     *
427     * @return array
428     */
429    public function getAttachments(): array
430    {
431        $attachments = [];
432
433        foreach ($this->parts as $part) {
434            if ($part->getBody()->isFile()) {
435                $attachments[] = $part;
436            }
437        }
438
439        return $attachments;
440    }
441
442    /**
443     * Get content-type
444     *
445     * @return string
446     */
447    public function getContentType(): string
448    {
449        $contentType = null;
450
451        if ($this->hasHeader('Content-Type') && (count($this->getHeader('Content-Type')->getValues()) == 1)) {
452            $contentType = (string)$this->getHeader('Content-Type')->getValue(0);
453        }
454
455        return $contentType;
456    }
457
458    /**
459     * Get attachment filename
460     *
461     * @return string|null
462     */
463    public function getFilename(): string|null
464    {
465        $filename = null;
466
467        if ($this->getBody()->isFile()) {
468            // Check Content-Disposition header (standard)
469            if ($this->hasHeader('Content-Disposition') && (count($this->getHeader('Content-Disposition')->getValues()) == 1)) {
470                $header = $this->getHeader('Content-Disposition');
471                if ($header->getValue(0)->hasParameter('filename')) {
472                    $filename = $header->getValue(0)->getParameter('filename');
473                } else if ($header->getValue(0)->hasParameter('name')) {
474                    $filename = $header->getValue(0)->getParameter('name');
475                }
476            }
477
478            // Else, check Content-Type header (non-standard)
479            if ($filename === null) {
480                if ($this->hasHeader('Content-Type') && (count($this->getHeader('Content-Type')->getValues()) == 1)) {
481                    $header = $this->getHeader('Content-Type');
482                    if ($header->getValue(0)->hasParameter('filename')) {
483                        $filename = $header->getValue(0)->getParameter('filename');
484                    } else if ($header->getValue(0)->hasParameter('name')) {
485                        $filename = $header->getValue(0)->getParameter('name');
486                    }
487                }
488            }
489
490            // Else, check Content-Description header (non-standard)
491            if ($filename === null) {
492                if ($this->hasHeader('Content-Description') && (count($this->getHeader('Content-Description')->getValues()) == 1)) {
493                    $header = $this->getHeader('Content-Description');
494                    if ($header->getValue(0)->hasParameter('filename')) {
495                        $filename = $header->getValue(0)->getParameter('filename');
496                    } else if ($header->getValue(0)->hasParameter('name')) {
497                        $filename = $header->getValue(0)->getParameter('name');
498                    }
499                }
500            }
501        }
502
503        // Decode filename, if encoded
504        if (($filename !== null) && (function_exists('imap_mime_header_decode')) &&
505            ((str_contains($filename, 'UTF')) || (str_contains($filename, 'ISO')) ||
506                (str_contains($filename, '?')) || (str_contains($filename, '=')))) {
507            $filenameAry = imap_mime_header_decode($filename);
508            if (isset($filenameAry[0]) && isset($filenameAry[0]->text)) {
509                $filename = $filenameAry[0]->text;
510            }
511        }
512
513        return $filename;
514    }
515
516    /**
517     * Get contents (decoded)
518     *
519     * @return mixed
520     */
521    public function getContents(): mixed
522    {
523        $content = $this->body->getContent();
524
525        if ($this->body->isEncoded()) {
526            if ($this->body->isBase64Encoding()) {
527                $content = base64_decode($content);
528            } else if ($this->body->isQuotedEncoding()) {
529                $content = quoted_printable_decode($content);
530            } else if ($this->body->isUrlEncoding()) {
531                $content = urldecode($content);
532            } else if ($this->body->isRawUrlEncoding()) {
533                $content = rawurldecode($content);
534            }
535        }
536
537        return $content;
538    }
539
540    /**
541     * Render the part headers
542     *
543     * @return string
544     */
545    public function renderHeaders(): string
546    {
547        return implode("\r\n", $this->headers) . "\r\n\r\n";
548    }
549
550    /**
551     * Render the parts
552     *
553     * @param  bool $preamble
554     * @param  bool $headers
555     * @return string
556     */
557    public function renderParts(bool $preamble = true, bool $headers = true): string
558    {
559        $parts = '';
560
561        $boundary = (!$this->hasBoundary()) ? $this->generateBoundary() : $this->boundary;
562        if (!($this->hasHeader('Content-Type')) && ($this->hasSubType())) {
563            $this->addHeader(
564                new Part\Header('Content-Type', new Part\Header\Value('multipart/' . $this->subType, null, ['boundary' =>  $boundary]))
565            );
566        }
567        if (($headers) && ($this->hasHeaders())) {
568            $parts .= $this->renderHeaders();
569        }
570        if ($preamble) {
571            $parts .= "This is a multi-part message in MIME format.\r\n";
572        }
573        foreach ($this->parts as $part) {
574            $parts .= "--" . $boundary . "\r\n" . $part . "\r\n";
575        }
576
577        $parts .= "--" . $boundary . "--\r\n";
578
579        return $parts;
580    }
581
582    /**
583     * Render the part body
584     *
585     * @return string
586     */
587    public function renderBody(): string
588    {
589        return $this->body->render();
590    }
591
592    /**
593     * Render the part
594     *
595     * @param  bool $preamble
596     * @return string
597     */
598    public function render(bool $preamble = true): string
599    {
600        $messagePart = '';
601
602        if ($this->hasParts()) {
603            $messagePart .= $this->renderParts($preamble);
604        } else if ($this->hasBody()) {
605            if ((!$this->hasHeader('Content-Transfer-Encoding')) && ($this->body->hasEncoding())) {
606                $encoding = null;
607                if ($this->body->isBase64Encoding()) {
608                    $encoding = 'base64';
609                } else if ($this->body->isQuotedEncoding()) {
610                    $encoding = 'quoted-printable';
611                }
612                if ($encoding !== null) {
613                    $this->addHeader(new Part\Header('Content-Transfer-Encoding', $encoding));
614                }
615            }
616            if ($this->hasHeaders()) {
617                $messagePart .= $this->renderHeaders();
618            }
619            $messagePart .= $this->renderBody();
620        }
621
622        return $messagePart;
623    }
624
625
626    /**
627     * Render the part raw (no headers or preamble)
628     *
629     * @return string
630     */
631    public function renderRaw(): string
632    {
633        return $this->renderParts(false, false);
634    }
635
636    /**
637     * Render the part
638     *
639     * @return string
640     */
641    public function __toString(): string
642    {
643        return $this->render();
644    }
645
646}