Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.82% covered (success)
96.82%
213 / 220
96.08% covered (success)
96.08%
49 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
Message
96.82% covered (success)
96.82%
213 / 220
96.08% covered (success)
96.08%
49 / 51
134
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHeaders
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getHeaderValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getHeaderAsString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getHeadersAsString
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 getBoundary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getBodyContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 generateId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderAsLines
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setSubject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTo
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setCc
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setBcc
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setFrom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setReplyTo
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setSender
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setReturnPath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 addressListToArray
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 arrayToAddressString
76.92% covered (success)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
15.08
 setBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addText
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 addHtml
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 attachFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attachFileFromStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addPart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 removeHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBcc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFrom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReplyTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSender
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReturnPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasBcc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasFrom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasReplyTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSender
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasReturnPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toByteStream
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parseFromFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parse
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
23
 decodeText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __clone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
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\Mail;
16
17use Pop\Mime\Part;
18use Pop\Mime\Message as MimeMessage;
19use Pop\Mail\Message\Text;
20use Pop\Mail\Message\Html;
21use Pop\Mail\Message\Attachment;
22use Pop\Mail\Message\CharsetAwareTrait;
23use Pop\Mail\Message\Part as MailPart;
24use Pop\Mime\Part\Header\AddressList;
25use Pop\Mime\Part\Header\Value;
26use Pop\Mime\Part\Header\EncodedWord;
27
28/**
29 * Message class
30 *
31 * @category   Pop
32 * @package    Pop\Mail
33 * @author     Nick Sagona, III <dev@noladev.com>
34 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
35 * @license    https://www.popphp.org/license     New BSD License
36 * @version    5.0.0
37 */
38class Message extends MimeMessage
39{
40    use CharsetAwareTrait;
41
42    /**
43     * Message newline constant
44     * @var string
45     */
46    const CRLF = "\r\n";
47
48    /**
49     * Message addresses
50     * @var array
51     */
52    protected array $addresses = [
53        'To'          => [],
54        'CC'          => [],
55        'BCC'         => [],
56        'From'        => [],
57        'Reply-To'    => [],
58        'Sender'      => [],
59        'Return-Path' => []
60    ];
61
62    /**
63     * Constructor
64     *
65     * Instantiate the message object
66     *
67     * @param ?string $subject
68     */
69    public function __construct(?string $subject = null)
70    {
71        parent::__construct();
72
73        if ($subject !== null) {
74            $this->setSubject($subject);
75        }
76    }
77
78    /**
79     * Get headers (flat name => string map)
80     *
81     * @return array
82     */
83    public function getHeaders(): array
84    {
85        $headers = [];
86        foreach (parent::getHeaders() as $name => $header) {
87            if (count($header->getValues()) === 1) {
88                $headers[$name] = $header->getValue(0)->render($name);
89            }
90        }
91        return $headers;
92    }
93
94    /**
95     * Get header value as a string
96     *
97     * Named getHeaderValue() rather than getHeader() because Pop\Mime\Part already
98     * declares a public getHeader(string $name): ?Header. A same-named override
99     * returning ?string here is not legal (return-type covariance requires a
100     * subtype of Header|null, and string is unrelated) - PHP fatals at class-load
101     * time for every `new Message()`. getHeader() is therefore left un-overridden
102     * and inherited as-is (returns the raw Part\Header object, consistent with how
103     * Text/Html/Attachment already expose it); this method is the flat-string
104     * convenience accessor instead.
105     *
106     * @param  string $name
107     * @return ?string
108     */
109    public function getHeaderValue(string $name): ?string
110    {
111        $header = parent::getHeader($name);
112        if (($header === null) || (count($header->getValues()) !== 1)) {
113            return null;
114        }
115        return $header->getValue(0)->render($name);
116    }
117
118    /**
119     * Get a single header rendered as a full "Name: Value" string
120     *
121     * @param  string $name
122     * @return ?string
123     */
124    public function getHeaderAsString(string $name): ?string
125    {
126        $header = parent::getHeader($name);
127        return ($header !== null) ? rtrim((string)$header) : null;
128    }
129
130    /**
131     * Get all headers rendered as a string, one per line
132     *
133     * @param  array $omitHeaders
134     * @return string
135     */
136    public function getHeadersAsString(array $omitHeaders = []): string
137    {
138        $result = '';
139        foreach (parent::getHeaders() as $name => $header) {
140            if (in_array($name, $omitHeaders, true)) {
141                continue;
142            }
143            if ((count($header->getValues()) === 1) && (trim((string)$header->getValue(0)) === '')) {
144                continue;
145            }
146            $line = (string)$header;
147            if (($name === 'Content-Type') && ($this->getCharSet() !== null) && !str_contains(strtolower($line), 'charset')) {
148                $line .= '; charset="' . $this->getCharSet() . '"';
149            }
150            $result .= $line . self::CRLF;
151        }
152        return $result;
153    }
154
155    /**
156     * Get message MIME boundary (auto-generating and setting MIME-Version if needed)
157     *
158     * @return string
159     */
160    public function getBoundary(): string
161    {
162        if (!$this->hasBoundary()) {
163            $this->generateBoundary();
164            $this->addHeader('MIME-Version', '1.0');
165        }
166        return parent::getBoundary();
167    }
168
169    /**
170     * Get the rendered body content
171     *
172     * @return ?string
173     */
174    public function getBodyContent(): ?string
175    {
176        if ($this->hasParts()) {
177            return $this->renderParts(true, false);
178        }
179        return $this->hasBody() ? $this->renderBody() : null;
180    }
181
182    /**
183     * Render the message
184     *
185     * Overrides Part::render() to explicitly call getBoundary() first (when
186     * parts are present), which is what actually generates the MIME-Version
187     * header as a side effect. Part::renderParts() (called internally by the
188     * inherited render()) reads $this->boundary/generateBoundary() directly
189     * and never routes through this class's getBoundary() override, so
190     * without this, MIME-Version was never emitted on real rendered output.
191     * Routing through getHeadersAsString() also picks up the charset-append
192     * logic that the inherited renderHeaders() doesn't have.
193     *
194     * Body must be rendered BEFORE headers are stringified: renderParts()
195     * (invoked by getBodyContent()) is what lazily synthesizes the
196     * Content-Type: multipart/...; boundary=... header as a side effect when
197     * one isn't already present. Calling getHeadersAsString() first would
198     * stringify the header set before that side effect fires, silently
199     * dropping Content-Type from the rendered output.
200     *
201     * @param  bool $preamble
202     * @return string
203     */
204    public function render(bool $preamble = true): string
205    {
206        if ($this->hasParts()) {
207            $this->getBoundary();
208        }
209        $body = $this->getBodyContent();
210        return $this->getHeadersAsString() . self::CRLF . $body;
211    }
212
213    /**
214     * Generate a Message-ID and set it as a side effect
215     *
216     * This overrides Part::generateId(?string $domain = null): string (same
217     * signature, legal). Note: MimeMessage::setMessageId() internally does
218     * `$id ?? $this->generateId($domain)` when no $id is given - calling
219     * setMessageId(null, $domain) from inside this override would resolve
220     * $this->generateId() polymorphically back to THIS method, recursing
221     * forever. Generating the id via parent::generateId() first and passing
222     * it explicitly avoids that.
223     *
224     * @param  ?string $domain
225     * @return string
226     */
227    public function generateId(?string $domain = null): string
228    {
229        $this->setMessageId(parent::generateId($domain), $domain);
230        return (string)$this->getHeaderValue('Message-ID');
231    }
232
233    /**
234     * Get message part
235     *
236     * @param  int $i
237     * @return ?Part
238     */
239    public function getPart(int $i): ?Part
240    {
241        return $this->getParts()[$i] ?? null;
242    }
243
244    /**
245     * Render as an array of lines
246     *
247     * @return array
248     */
249    public function renderAsLines(): array
250    {
251        $lines = explode(self::CRLF, $this->render());
252        return array_map('trim', $lines);
253    }
254
255    /**
256     * Load a message from a string source or file on disk
257     *
258     * @param  string $message
259     * @throws Exception
260     * @return Message
261     */
262    public static function load(string $message): Message
263    {
264        if (str_contains($message, 'Subject:')) {
265            return self::parse($message);
266        } else if (file_exists($message)) {
267            return self::parseFromFile($message);
268        } else {
269            throw new Exception('Error: Unable to parse message content');
270        }
271    }
272
273    /**
274     * Set Subject
275     *
276     * @param  string $subject
277     * @return Message
278     */
279    public function setSubject(string $subject): Message
280    {
281        $this->addHeader('Subject', $subject);
282        return $this;
283    }
284
285    /**
286     * Set To
287     *
288     * @param  mixed $to
289     * @return Message
290     */
291    public function setTo(mixed $to): Message
292    {
293        if ($to instanceof Value) {
294            $to = (string)$to;
295        } else if (is_array($to)) {
296            $to = self::arrayToAddressString($to);
297        }
298        $list = AddressList::parse((string)$to);
299        $this->addresses['To'] = self::addressListToArray($list);
300        $this->addHeader('To', $list->render());
301        return $this;
302    }
303
304    /**
305     * Set CC
306     *
307     * @param  mixed $cc
308     * @return Message
309     */
310    public function setCc(mixed $cc): Message
311    {
312        if ($cc instanceof Value) {
313            $cc = (string)$cc;
314        } else if (is_array($cc)) {
315            $cc = self::arrayToAddressString($cc);
316        }
317        $list = AddressList::parse((string)$cc);
318        $this->addresses['CC'] = self::addressListToArray($list);
319        $this->addHeader('CC', $list->render());
320        return $this;
321    }
322
323    /**
324     * Set BCC
325     *
326     * @param  mixed $bcc
327     * @return Message
328     */
329    public function setBcc(mixed $bcc): Message
330    {
331        if ($bcc instanceof Value) {
332            $bcc = (string)$bcc;
333        } else if (is_array($bcc)) {
334            $bcc = self::arrayToAddressString($bcc);
335        }
336        $list = AddressList::parse((string)$bcc);
337        $this->addresses['BCC'] = self::addressListToArray($list);
338        $this->addHeader('BCC', $list->render());
339        return $this;
340    }
341
342    /**
343     * Set From
344     *
345     * @param  mixed $from
346     * @return Message
347     */
348    public function setFrom(mixed $from): Message
349    {
350        if ($from instanceof Value) {
351            $from = (string)$from;
352        } else if (is_array($from)) {
353            $from = self::arrayToAddressString($from);
354        }
355        $list = AddressList::parse((string)$from);
356        $this->addresses['From'] = self::addressListToArray($list);
357        $this->addHeader('From', $list->render());
358        return $this;
359    }
360
361    /**
362     * Set Reply-To
363     *
364     * @param  mixed $replyTo
365     * @return Message
366     */
367    public function setReplyTo(mixed $replyTo): Message
368    {
369        if ($replyTo instanceof Value) {
370            $replyTo = (string)$replyTo;
371        } else if (is_array($replyTo)) {
372            $replyTo = self::arrayToAddressString($replyTo);
373        }
374        $list = AddressList::parse((string)$replyTo);
375        $this->addresses['Reply-To'] = self::addressListToArray($list);
376        $this->addHeader('Reply-To', $list->render());
377        return $this;
378    }
379
380    /**
381     * Set Sender
382     *
383     * @param  mixed $sender
384     * @return Message
385     */
386    public function setSender(mixed $sender): Message
387    {
388        if ($sender instanceof Value) {
389            $sender = (string)$sender;
390        } else if (is_array($sender)) {
391            $sender = self::arrayToAddressString($sender);
392        }
393        $list = AddressList::parse((string)$sender);
394        $this->addresses['Sender'] = self::addressListToArray($list);
395        $this->addHeader('Sender', $list->render());
396        return $this;
397    }
398
399    /**
400     * Set Return-Path
401     *
402     * @param  mixed $returnPath
403     * @return Message
404     */
405    public function setReturnPath(mixed $returnPath): Message
406    {
407        if ($returnPath instanceof Value) {
408            $returnPath = (string)$returnPath;
409        } else if (is_array($returnPath)) {
410            $returnPath = self::arrayToAddressString($returnPath);
411        }
412        $list = AddressList::parse((string)$returnPath);
413        $this->addresses['Return-Path'] = self::addressListToArray($list);
414        $this->addHeader('Return-Path', $list->render());
415        return $this;
416    }
417
418    /**
419     * Convert an AddressList into an email => ?name array
420     *
421     * @param  AddressList $list
422     * @return array
423     */
424    private static function addressListToArray(AddressList $list): array
425    {
426        $emails = [];
427        foreach ($list->getAddresses() as $address) {
428            $emails[$address->getAddress()] = $address->getName();
429        }
430        return $emails;
431    }
432
433    /**
434     * Convert an array of addresses into a comma-joined address string
435     *
436     * Supports:
437     *   - ['email@example.com' => 'Name', ...]         (key is email)
438     *   - ['Name' => 'email@example.com', ...]         (value is email)
439     *   - [0 => 'email@example.com', ...]               (plain list)
440     *   - [0 => stdClass{mailbox, host}, ...]
441     *
442     * @param  array $addresses
443     * @return string
444     */
445    private static function arrayToAddressString(array $addresses): string
446    {
447        $formatted = [];
448
449        foreach ($addresses as $key => $value) {
450            if (($value instanceof \stdClass) && isset($value->mailbox) && isset($value->host)) {
451                $formatted[] = $value->mailbox . '@' . $value->host;
452            } else {
453                // $key is email
454                if (is_string($key) && str_contains($key, '@')) {
455                    if (!empty($value) && !is_numeric($value)) {
456                        $formatted[] = '"' . $value . '" <' . $key . '>';
457                    } else {
458                        $formatted[] = $key;
459                    }
460                // $value is email
461                } else if (is_string($value) && str_contains($value, '@')) {
462                    if (!empty($key) && !is_numeric($key)) {
463                        $formatted[] = '"' . $key . '" <' . $value . '>';
464                    } else {
465                        $formatted[] = $value;
466                    }
467                }
468            }
469        }
470
471        return implode(', ', $formatted);
472    }
473
474    /**
475     * Set body
476     *
477     * @param  mixed $body
478     * @return Message
479     */
480    public function setBody(mixed $body): Message
481    {
482        if (!($body instanceof Part)) {
483            $body = Text::create($body);
484        }
485        return $this->addPart($body);
486    }
487
488    /**
489     * Add text message part
490     *
491     * @param  mixed $text
492     * @return Message
493     */
494    public function addText(mixed $text): Message
495    {
496        if (!($text instanceof Text) && is_string($text)) {
497            $text = Text::create($text);
498        }
499        return $this->addPart($text);
500    }
501
502    /**
503     * Add HTML message part
504     *
505     * @param  mixed $html
506     * @return Message
507     */
508    public function addHtml(mixed $html): Message
509    {
510        if (!($html instanceof Html) && is_string($html)) {
511            $html = Html::create($html);
512        }
513        return $this->addPart($html);
514    }
515
516    /**
517     * Attach file message part
518     *
519     * @param  string             $file
520     * @param  Part\Body\Encoding $encoding
521     * @return Message
522     */
523    public function attachFile(string $file, Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64): Message
524    {
525        return $this->addPart(Attachment::create($file, null, 'attachment', $encoding));
526    }
527
528    /**
529     * Attach file message part from stream
530     *
531     * @param  string             $stream
532     * @param  string             $basename
533     * @param  Part\Body\Encoding $encoding
534     * @return Message
535     */
536    public function attachFileFromStream(
537        string $stream, string $basename = 'file.tmp', Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64
538    ): Message
539    {
540        return $this->addPart(Attachment::createFromContent($stream, $basename, null, 'attachment', $encoding));
541    }
542
543    /**
544     * Add message part
545     *
546     * Auto-infers the multipart subtype (alternative/mixed) from the nested
547     * parts via Part::inferSubType(); when parts exist but no subtype could
548     * be inferred (e.g. a single text-only part), defaults to 'mixed' per
549     * the "Single-part rendering" spec resolution.
550     *
551     * @param  Part $part
552     * @return Message
553     */
554    public function addPart(Part $part): Message
555    {
556        parent::addPart($part);
557        $this->inferSubType();
558        if ($this->hasParts() && !$this->hasSubType()) {
559            $this->setSubType('mixed');
560        }
561        if ($this->hasParts() && $this->hasHeader('Content-Type')) {
562            $this->removeHeader('Content-Type');
563        }
564        return $this;
565    }
566
567    /**
568     * Remove header
569     *
570     * @param  string $header
571     * @return Message
572     */
573    public function removeHeader(string $header): Message
574    {
575        if (isset($this->headers[$header])) {
576            unset($this->headers[$header]);
577        }
578        return $this;
579    }
580
581    /**
582     * Get subject
583     *
584     * @return ?string
585     */
586    public function getSubject(): ?string
587    {
588        return $this->getHeaderValue('Subject');
589    }
590
591    /**
592     * Get To
593     *
594     * @return array
595     */
596    public function getTo(): array
597    {
598        return $this->addresses['To'];
599    }
600
601    /**
602     * Get CC
603     *
604     * @return array
605     */
606    public function getCc(): array
607    {
608        return $this->addresses['CC'];
609    }
610
611    /**
612     * Get BCC
613     *
614     * @return array
615     */
616    public function getBcc(): array
617    {
618        return $this->addresses['BCC'];
619    }
620
621    /**
622     * Get From
623     *
624     * @return array
625     */
626    public function getFrom(): array
627    {
628        return $this->addresses['From'];
629    }
630
631    /**
632     * Get Reply-To
633     *
634     * @return array
635     */
636    public function getReplyTo(): array
637    {
638        return $this->addresses['Reply-To'];
639    }
640
641    /**
642     * Get Sender
643     *
644     * @return array
645     */
646    public function getSender(): array
647    {
648        return $this->addresses['Sender'];
649    }
650
651    /**
652     * Get Return-Path
653     *
654     * @return array
655     */
656    public function getReturnPath(): array
657    {
658        return $this->addresses['Return-Path'];
659    }
660
661    /**
662     * Has To
663     *
664     * @return bool
665     */
666    public function hasTo(): bool
667    {
668        return !empty($this->addresses['To']);
669    }
670
671    /**
672     * Has CC
673     *
674     * @return bool
675     */
676    public function hasCc(): bool
677    {
678        return !empty($this->addresses['CC']);
679    }
680
681    /**
682     * Has BCC
683     *
684     * @return bool
685     */
686    public function hasBcc(): bool
687    {
688        return !empty($this->addresses['BCC']);
689    }
690
691    /**
692     * Has From
693     *
694     * @return bool
695     */
696    public function hasFrom(): bool
697    {
698        return !empty($this->addresses['From']);
699    }
700
701    /**
702     * Has Reply-To
703     *
704     * @return bool
705     */
706    public function hasReplyTo(): bool
707    {
708        return !empty($this->addresses['Reply-To']);
709    }
710
711    /**
712     * Has Sender
713     *
714     * @return bool
715     */
716    public function hasSender(): bool
717    {
718        return !empty($this->addresses['Sender']);
719    }
720
721    /**
722     * Has Return-Path
723     *
724     * @return bool
725     */
726    public function hasReturnPath(): bool
727    {
728        return !empty($this->addresses['Return-Path']);
729    }
730
731    /**
732     * Get message parts
733     *
734     * @return array
735     */
736    public function getParts(): array
737    {
738        return $this->parts;
739    }
740
741    /**
742     * Save message to file on disk
743     *
744     * @param  string $to
745     * @return void
746     */
747    public function save(string $to): void
748    {
749        file_put_contents($to, $this->render());
750    }
751
752    /**
753     * Write this entire entity to a buffer
754     *
755     * @param  Transport\Smtp\Stream\BufferInterface $is
756     * @return void
757     */
758    public function toByteStream(Transport\Smtp\Stream\BufferInterface $is): void
759    {
760        $lines = $this->renderAsLines();
761        foreach ($lines as $line) {
762            $is->write($line . self::CRLF);
763        }
764        $is->commit();
765    }
766
767    /**
768     * Parse message from file
769     *
770     * @param  string $file
771     * @throws Exception
772     * @return Message
773     */
774    public static function parseFromFile(string $file): Message
775    {
776        if (!file_exists($file)) {
777            throw new Exception("Error: The file '" . $file . "' does not exist.");
778        }
779
780        return self::parse(file_get_contents($file));
781    }
782
783    /**
784     * Parse message from string
785     *
786     * @param  string $stream
787     * @throws Exception
788     * @return Message
789     */
790    public static function parse(string $stream): Message
791    {
792        $parsedMessage = \Pop\Mime\Message::parseMessage($stream);
793        $message       = new self();
794
795        if ($parsedMessage->hasHeaders()) {
796            $headers = $parsedMessage->getHeaders();
797            foreach ($headers as $header => $value) {
798                if (count($value->getValues()) == 1) {
799                    switch (strtolower($header)) {
800                        case 'subject':
801                            $message->setSubject($value->getValueAsString(0));
802                            break;
803                        case 'to':
804                            $message->setTo($value->getValue(0));
805                            break;
806                        case 'cc':
807                            $message->setCc($value->getValue(0));
808                            break;
809                        case 'bcc':
810                            $message->setBcc($value->getValue(0));
811                            break;
812                        case 'from':
813                            $message->setFrom($value->getValue(0));
814                            break;
815                        case 'reply-to':
816                            $message->setReplyTo($value->getValue(0));
817                            break;
818                        case 'sender':
819                            $message->setSender($value->getValue(0));
820                            break;
821                        case 'return-path':
822                            $message->setReturnPath($value->getValue(0));
823                            break;
824                        default:
825                            $message->addHeader($header, $value->getValueAsString(0));
826                    }
827                }
828            }
829        }
830
831        if (empty($message->getSubject())) {
832            throw new Exception('Error: There is no subject in the message contents');
833        }
834
835        if (empty($message->getTo())) {
836            throw new Exception('Error: There is no to address in the message contents');
837        }
838
839        if ($parsedMessage->hasParts()) {
840            $parts = MailPart::parseParts($parsedMessage->getParts());
841
842            foreach ($parts as $part) {
843                if ($part->attachment) {
844                    $message->addPart(Attachment::createFromContent($part->content, $part->basename ?? 'file.tmp', $part->type));
845                } else if (!empty($part->type) && (stripos($part->type, 'html') !== false)) {
846                    $message->addPart(Html::create($part->content));
847                } else if (!empty($part->type) && (stripos($part->type, 'text') !== false)) {
848                    $message->addPart(Text::create($part->content));
849                } else {
850                    $plain = Text::create($part->content);
851                    if (!empty($part->type)) {
852                        $plain->addHeader('Content-Type', $part->type);
853                    }
854                    $message->addPart($plain);
855                }
856            }
857        }
858
859        return $message;
860    }
861
862    /**
863     * Decode text
864     *
865     * @param  string $text
866     * @return string
867     */
868    public static function decodeText(string $text): string
869    {
870        return EncodedWord::decode($text);
871    }
872
873    /**
874     * Perform a "deep" clone of a message object
875     *
876     * @return void
877     */
878    public function __clone(): void
879    {
880        foreach (get_object_vars($this) as $key => $val) {
881            if (is_object($val) || (is_array($val))) {
882                $this->{$key} = unserialize(serialize($val));
883            }
884        }
885    }
886
887}