Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.65% covered (success)
98.65%
219 / 222
98.04% covered (success)
98.04%
50 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
Message
98.65% covered (success)
98.65%
219 / 222
98.04% covered (success)
98.04%
50 / 51
137
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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%
52 / 52
100.00% covered (success)
100.00%
1 / 1
26
 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 <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
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 <nick@popphp.org>
34 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
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     * $body may be passed in to reuse an already-rendered body (e.g. when
202     * only the headers differ across repeated renders of the same message,
203     * such as looping Bcc transactions) instead of re-running renderParts()
204     * (and re-encoding any attachments) on every call.
205     *
206     * @param  bool    $preamble
207     * @param  ?string $body
208     * @return string
209     */
210    public function render(bool $preamble = true, ?string $body = null): string
211    {
212        if ($this->hasParts()) {
213            $this->getBoundary();
214        }
215        $body ??= $this->getBodyContent();
216        return $this->getHeadersAsString() . self::CRLF . $body;
217    }
218
219    /**
220     * Generate a Message-ID and set it as a side effect
221     *
222     * This overrides Part::generateId(?string $domain = null): string (same
223     * signature, legal). Note: MimeMessage::setMessageId() internally does
224     * `$id ?? $this->generateId($domain)` when no $id is given - calling
225     * setMessageId(null, $domain) from inside this override would resolve
226     * $this->generateId() polymorphically back to THIS method, recursing
227     * forever. Generating the id via parent::generateId() first and passing
228     * it explicitly avoids that.
229     *
230     * @param  ?string $domain
231     * @return string
232     */
233    public function generateId(?string $domain = null): string
234    {
235        $this->setMessageId(parent::generateId($domain), $domain);
236        return (string)$this->getHeaderValue('Message-ID');
237    }
238
239    /**
240     * Get message part
241     *
242     * @param  int $i
243     * @return ?Part
244     */
245    public function getPart(int $i): ?Part
246    {
247        return $this->getParts()[$i] ?? null;
248    }
249
250    /**
251     * Render as an array of lines
252     *
253     * @param  ?string $body pre-rendered body to reuse (see render())
254     * @return array
255     */
256    public function renderAsLines(?string $body = null): array
257    {
258        $lines = explode(self::CRLF, $this->render(true, $body));
259        return array_map('trim', $lines);
260    }
261
262    /**
263     * Load a message from a string source or file on disk
264     *
265     * @param  string $message
266     * @throws Exception
267     * @return Message
268     */
269    public static function load(string $message): Message
270    {
271        if (str_contains($message, 'Subject:')) {
272            return self::parse($message);
273        } else if (file_exists($message)) {
274            return self::parseFromFile($message);
275        } else {
276            throw new Exception('Error: Unable to parse message content');
277        }
278    }
279
280    /**
281     * Set Subject
282     *
283     * @param  string $subject
284     * @return Message
285     */
286    public function setSubject(string $subject): Message
287    {
288        $this->addHeader('Subject', $subject);
289        return $this;
290    }
291
292    /**
293     * Set To
294     *
295     * @param  mixed $to
296     * @return Message
297     */
298    public function setTo(mixed $to): Message
299    {
300        if ($to instanceof Value) {
301            $to = (string)$to;
302        } else if (is_array($to)) {
303            $to = self::arrayToAddressString($to);
304        }
305        $list = AddressList::parse((string)$to);
306        $this->addresses['To'] = self::addressListToArray($list);
307        $this->addHeader('To', $list->render());
308        return $this;
309    }
310
311    /**
312     * Set CC
313     *
314     * @param  mixed $cc
315     * @return Message
316     */
317    public function setCc(mixed $cc): Message
318    {
319        if ($cc instanceof Value) {
320            $cc = (string)$cc;
321        } else if (is_array($cc)) {
322            $cc = self::arrayToAddressString($cc);
323        }
324        $list = AddressList::parse((string)$cc);
325        $this->addresses['CC'] = self::addressListToArray($list);
326        $this->addHeader('CC', $list->render());
327        return $this;
328    }
329
330    /**
331     * Set BCC
332     *
333     * @param  mixed $bcc
334     * @return Message
335     */
336    public function setBcc(mixed $bcc): Message
337    {
338        if ($bcc instanceof Value) {
339            $bcc = (string)$bcc;
340        } else if (is_array($bcc)) {
341            $bcc = self::arrayToAddressString($bcc);
342        }
343        $list = AddressList::parse((string)$bcc);
344        $this->addresses['BCC'] = self::addressListToArray($list);
345        $this->addHeader('BCC', $list->render());
346        return $this;
347    }
348
349    /**
350     * Set From
351     *
352     * @param  mixed $from
353     * @return Message
354     */
355    public function setFrom(mixed $from): Message
356    {
357        if ($from instanceof Value) {
358            $from = (string)$from;
359        } else if (is_array($from)) {
360            $from = self::arrayToAddressString($from);
361        }
362        $list = AddressList::parse((string)$from);
363        $this->addresses['From'] = self::addressListToArray($list);
364        $this->addHeader('From', $list->render());
365        return $this;
366    }
367
368    /**
369     * Set Reply-To
370     *
371     * @param  mixed $replyTo
372     * @return Message
373     */
374    public function setReplyTo(mixed $replyTo): Message
375    {
376        if ($replyTo instanceof Value) {
377            $replyTo = (string)$replyTo;
378        } else if (is_array($replyTo)) {
379            $replyTo = self::arrayToAddressString($replyTo);
380        }
381        $list = AddressList::parse((string)$replyTo);
382        $this->addresses['Reply-To'] = self::addressListToArray($list);
383        $this->addHeader('Reply-To', $list->render());
384        return $this;
385    }
386
387    /**
388     * Set Sender
389     *
390     * @param  mixed $sender
391     * @return Message
392     */
393    public function setSender(mixed $sender): Message
394    {
395        if ($sender instanceof Value) {
396            $sender = (string)$sender;
397        } else if (is_array($sender)) {
398            $sender = self::arrayToAddressString($sender);
399        }
400        $list = AddressList::parse((string)$sender);
401        $this->addresses['Sender'] = self::addressListToArray($list);
402        $this->addHeader('Sender', $list->render());
403        return $this;
404    }
405
406    /**
407     * Set Return-Path
408     *
409     * @param  mixed $returnPath
410     * @return Message
411     */
412    public function setReturnPath(mixed $returnPath): Message
413    {
414        if ($returnPath instanceof Value) {
415            $returnPath = (string)$returnPath;
416        } else if (is_array($returnPath)) {
417            $returnPath = self::arrayToAddressString($returnPath);
418        }
419        $list = AddressList::parse((string)$returnPath);
420        $this->addresses['Return-Path'] = self::addressListToArray($list);
421        $this->addHeader('Return-Path', $list->render());
422        return $this;
423    }
424
425    /**
426     * Convert an AddressList into an email => ?name array
427     *
428     * @param  AddressList $list
429     * @return array
430     */
431    private static function addressListToArray(AddressList $list): array
432    {
433        $emails = [];
434        foreach ($list->getAddresses() as $address) {
435            $emails[$address->getAddress()] = $address->getName();
436        }
437        return $emails;
438    }
439
440    /**
441     * Convert an array of addresses into a comma-joined address string
442     *
443     * Supports:
444     *   - ['email@example.com' => 'Name', ...]         (key is email)
445     *   - ['Name' => 'email@example.com', ...]         (value is email)
446     *   - [0 => 'email@example.com', ...]               (plain list)
447     *   - [0 => stdClass{mailbox, host}, ...]
448     *
449     * @param  array $addresses
450     * @return string
451     */
452    private static function arrayToAddressString(array $addresses): string
453    {
454        $formatted = [];
455
456        foreach ($addresses as $key => $value) {
457            if (($value instanceof \stdClass) && isset($value->mailbox) && isset($value->host)) {
458                $formatted[] = $value->mailbox . '@' . $value->host;
459            } else {
460                // $key is email
461                if (is_string($key) && str_contains($key, '@')) {
462                    if (!empty($value) && !is_numeric($value)) {
463                        $formatted[] = '"' . $value . '" <' . $key . '>';
464                    } else {
465                        $formatted[] = $key;
466                    }
467                // $value is email
468                } else if (is_string($value) && str_contains($value, '@')) {
469                    if (!empty($key) && !is_numeric($key)) {
470                        $formatted[] = '"' . $key . '" <' . $value . '>';
471                    } else {
472                        $formatted[] = $value;
473                    }
474                }
475            }
476        }
477
478        return implode(', ', $formatted);
479    }
480
481    /**
482     * Set body
483     *
484     * @param  mixed $body
485     * @return Message
486     */
487    public function setBody(mixed $body): Message
488    {
489        if (!($body instanceof Part)) {
490            $body = Text::create($body);
491        }
492        return $this->addPart($body);
493    }
494
495    /**
496     * Add text message part
497     *
498     * @param  mixed $text
499     * @return Message
500     */
501    public function addText(mixed $text): Message
502    {
503        if (!($text instanceof Text) && is_string($text)) {
504            $text = Text::create($text);
505        }
506        return $this->addPart($text);
507    }
508
509    /**
510     * Add HTML message part
511     *
512     * @param  mixed $html
513     * @return Message
514     */
515    public function addHtml(mixed $html): Message
516    {
517        if (!($html instanceof Html) && is_string($html)) {
518            $html = Html::create($html);
519        }
520        return $this->addPart($html);
521    }
522
523    /**
524     * Attach file message part
525     *
526     * @param  string             $file
527     * @param  Part\Body\Encoding $encoding
528     * @return Message
529     */
530    public function attachFile(string $file, Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64): Message
531    {
532        return $this->addPart(Attachment::create($file, null, 'attachment', $encoding));
533    }
534
535    /**
536     * Attach file message part from stream
537     *
538     * @param  string             $stream
539     * @param  string             $basename
540     * @param  Part\Body\Encoding $encoding
541     * @return Message
542     */
543    public function attachFileFromStream(
544        string $stream, string $basename = 'file.tmp', Part\Body\Encoding $encoding = Part\Body\Encoding::BASE64
545    ): Message
546    {
547        return $this->addPart(Attachment::createFromContent($stream, $basename, null, 'attachment', $encoding));
548    }
549
550    /**
551     * Add message part
552     *
553     * Auto-infers the multipart subtype (alternative/mixed) from the nested
554     * parts via Part::inferSubType(); when parts exist but no subtype could
555     * be inferred (e.g. a single text-only part), defaults to 'mixed' per
556     * the "Single-part rendering" spec resolution.
557     *
558     * @param  Part $part
559     * @return Message
560     */
561    public function addPart(Part $part): Message
562    {
563        parent::addPart($part);
564        $this->inferSubType();
565        if ($this->hasParts() && !$this->hasSubType()) {
566            $this->setSubType('mixed');
567        }
568        if ($this->hasParts() && $this->hasHeader('Content-Type')) {
569            $this->removeHeader('Content-Type');
570        }
571        return $this;
572    }
573
574    /**
575     * Remove header
576     *
577     * @param  string $header
578     * @return Message
579     */
580    public function removeHeader(string $header): Message
581    {
582        if (isset($this->headers[$header])) {
583            unset($this->headers[$header]);
584        }
585        return $this;
586    }
587
588    /**
589     * Get subject
590     *
591     * @return ?string
592     */
593    public function getSubject(): ?string
594    {
595        return $this->getHeaderValue('Subject');
596    }
597
598    /**
599     * Get To
600     *
601     * @return array
602     */
603    public function getTo(): array
604    {
605        return $this->addresses['To'];
606    }
607
608    /**
609     * Get CC
610     *
611     * @return array
612     */
613    public function getCc(): array
614    {
615        return $this->addresses['CC'];
616    }
617
618    /**
619     * Get BCC
620     *
621     * @return array
622     */
623    public function getBcc(): array
624    {
625        return $this->addresses['BCC'];
626    }
627
628    /**
629     * Get From
630     *
631     * @return array
632     */
633    public function getFrom(): array
634    {
635        return $this->addresses['From'];
636    }
637
638    /**
639     * Get Reply-To
640     *
641     * @return array
642     */
643    public function getReplyTo(): array
644    {
645        return $this->addresses['Reply-To'];
646    }
647
648    /**
649     * Get Sender
650     *
651     * @return array
652     */
653    public function getSender(): array
654    {
655        return $this->addresses['Sender'];
656    }
657
658    /**
659     * Get Return-Path
660     *
661     * @return array
662     */
663    public function getReturnPath(): array
664    {
665        return $this->addresses['Return-Path'];
666    }
667
668    /**
669     * Has To
670     *
671     * @return bool
672     */
673    public function hasTo(): bool
674    {
675        return !empty($this->addresses['To']);
676    }
677
678    /**
679     * Has CC
680     *
681     * @return bool
682     */
683    public function hasCc(): bool
684    {
685        return !empty($this->addresses['CC']);
686    }
687
688    /**
689     * Has BCC
690     *
691     * @return bool
692     */
693    public function hasBcc(): bool
694    {
695        return !empty($this->addresses['BCC']);
696    }
697
698    /**
699     * Has From
700     *
701     * @return bool
702     */
703    public function hasFrom(): bool
704    {
705        return !empty($this->addresses['From']);
706    }
707
708    /**
709     * Has Reply-To
710     *
711     * @return bool
712     */
713    public function hasReplyTo(): bool
714    {
715        return !empty($this->addresses['Reply-To']);
716    }
717
718    /**
719     * Has Sender
720     *
721     * @return bool
722     */
723    public function hasSender(): bool
724    {
725        return !empty($this->addresses['Sender']);
726    }
727
728    /**
729     * Has Return-Path
730     *
731     * @return bool
732     */
733    public function hasReturnPath(): bool
734    {
735        return !empty($this->addresses['Return-Path']);
736    }
737
738    /**
739     * Get message parts
740     *
741     * @return array
742     */
743    public function getParts(): array
744    {
745        return $this->parts;
746    }
747
748    /**
749     * Save message to file on disk
750     *
751     * @param  string $to
752     * @return void
753     */
754    public function save(string $to): void
755    {
756        file_put_contents($to, $this->render());
757    }
758
759    /**
760     * Write this entire entity to a buffer
761     *
762     * @param  Transport\Smtp\Stream\BufferInterface $is
763     * @param  ?string                                $body pre-rendered body to reuse (see render())
764     * @return void
765     */
766    public function toByteStream(Transport\Smtp\Stream\BufferInterface $is, ?string $body = null): void
767    {
768        $lines = $this->renderAsLines($body);
769        foreach ($lines as $line) {
770            $is->write($line . self::CRLF);
771        }
772        $is->commit();
773    }
774
775    /**
776     * Parse message from file
777     *
778     * @param  string $file
779     * @throws Exception
780     * @return Message
781     */
782    public static function parseFromFile(string $file): Message
783    {
784        if (!file_exists($file)) {
785            throw new Exception("Error: The file '" . $file . "' does not exist.");
786        }
787
788        return self::parse(file_get_contents($file));
789    }
790
791    /**
792     * Parse message from string
793     *
794     * @param  string $stream
795     * @throws Exception
796     * @return Message
797     */
798    public static function parse(string $stream): Message
799    {
800        if (!str_contains($stream, "\r\n\r\n")) {
801            throw new Exception('Error: The message contents are malformed and contain no header/body delimiter');
802        }
803
804        $parsedMessage = \Pop\Mime\Message::parseMessage($stream);
805        $message       = new self();
806
807        if ($parsedMessage->hasHeaders()) {
808            $headers = $parsedMessage->getHeaders();
809            foreach ($headers as $header => $value) {
810                if (count($value->getValues()) == 1) {
811                    switch (strtolower($header)) {
812                        case 'subject':
813                            $message->setSubject($value->getValueAsString(0));
814                            break;
815                        case 'to':
816                            $message->setTo($value->getValue(0));
817                            break;
818                        case 'cc':
819                            $message->setCc($value->getValue(0));
820                            break;
821                        case 'bcc':
822                            $message->setBcc($value->getValue(0));
823                            break;
824                        case 'from':
825                            $message->setFrom($value->getValue(0));
826                            break;
827                        case 'reply-to':
828                            $message->setReplyTo($value->getValue(0));
829                            break;
830                        case 'sender':
831                            $message->setSender($value->getValue(0));
832                            break;
833                        case 'return-path':
834                            $message->setReturnPath($value->getValue(0));
835                            break;
836                        default:
837                            $message->addHeader($header, $value->getValueAsString(0));
838                    }
839                }
840            }
841        }
842
843        if (empty($message->getSubject())) {
844            throw new Exception('Error: There is no subject in the message contents');
845        }
846
847        if (empty($message->getTo())) {
848            throw new Exception('Error: There is no to address in the message contents');
849        }
850
851        if ($parsedMessage->hasParts() || $parsedMessage->hasBody()) {
852            // A non-multipart message has no nested parts - pop-mime's parseMessage()
853            // sets its body directly on $parsedMessage instead (see pop-mime's
854            // docs/POP-MIME.md "Gotchas" section)
855            $parts = MailPart::parseParts($parsedMessage->hasParts() ? $parsedMessage->getParts() : [$parsedMessage]);
856
857            foreach ($parts as $part) {
858                if ($part->attachment) {
859                    $message->addPart(Attachment::createFromContent($part->content, $part->basename ?? 'file.tmp', $part->type));
860                } else if (!empty($part->type) && (stripos($part->type, 'html') !== false)) {
861                    $message->addPart(Html::create($part->content));
862                } else if (!empty($part->type) && (stripos($part->type, 'text') !== false)) {
863                    $message->addPart(Text::create($part->content));
864                } else {
865                    $plain = Text::create($part->content);
866                    if (!empty($part->type)) {
867                        $plain->addHeader('Content-Type', $part->type);
868                    }
869                    $message->addPart($plain);
870                }
871            }
872        }
873
874        return $message;
875    }
876
877    /**
878     * Decode text
879     *
880     * @param  string $text
881     * @return string
882     */
883    public static function decodeText(string $text): string
884    {
885        return EncodedWord::decode($text);
886    }
887
888    /**
889     * Perform a "deep" clone of a message object
890     *
891     * @return void
892     */
893    public function __clone(): void
894    {
895        foreach (get_object_vars($this) as $key => $val) {
896            if (is_object($val) || (is_array($val))) {
897                $this->{$key} = unserialize(serialize($val));
898            }
899        }
900    }
901
902}