Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
198 / 204
95.24% covered (success)
95.24%
40 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Message
97.06% covered (success)
97.06%
198 / 204
95.24% covered (success)
95.24%
40 / 42
110
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
 load
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 setSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBcc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFrom
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setReplyTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSender
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setReturnPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 attachFileFromStream
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addPart
100.00% covered (success)
100.00%
3 / 3
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
 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
 getBody
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 getBoundary
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getParts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setBoundary
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateBoundary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
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
 renderAsLines
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 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%
49 / 49
100.00% covered (success)
100.00%
1 / 1
21
 decodeText
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseAddresses
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
13
 parseNameAndEmail
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 validateContentType
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 __clone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
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-2023 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\Mail;
15
16use Pop\Mail\Message\AbstractPart;
17
18/**
19 * Message class
20 *
21 * @category   Pop
22 * @package    Pop\Mail
23 * @author     Nick Sagona, III <dev@nolainteractive.com>
24 * @copyright  Copyright (c) 2009-2023 NOLA Interactive, LLC. (http://www.nolainteractive.com)
25 * @license    http://www.popphp.org/license     New BSD License
26 * @version    3.6.0
27 */
28class Message extends Message\AbstractMessage
29{
30
31    /**
32     * Message newline constant
33     * @var string
34     */
35    const CRLF = "\r\n";
36
37    /**
38     * Message parts
39     * @var array
40     */
41    protected $parts = [];
42
43    /**
44     * Message addresses
45     * @var array
46     */
47    protected $addresses = [
48        'To'          => [],
49        'CC'          => [],
50        'BCC'         => [],
51        'From'        => [],
52        'Reply-To'    => [],
53        'Sender'      => [],
54        'Return-Path' => []
55    ];
56
57    /**
58     * Message boundary
59     * @var string
60     */
61    protected $boundary = null;
62
63    /**
64     * Constructor
65     *
66     * Instantiate the message object
67     *
68     * @param  string $subject
69     */
70    public function __construct($subject = null)
71    {
72        parent::__construct();
73
74        if (null !== $subject) {
75            $this->setSubject($subject);
76        }
77    }
78
79    /**
80     * Load a message from a string source or file on disk
81     *
82     * @param  string $message
83     * @throws Exception
84     * @return Message
85     */
86    public static function load($message)
87    {
88        if (is_string($message) && (strpos($message, 'Subject:') !== false)) {
89            return self::parse($message);
90        } else if (file_exists($message)) {
91            return self::parseFromFile($message);
92        } else {
93            throw new Exception('Error: Unable to parse message content');
94        }
95    }
96
97    /**
98     * Set Subject
99     *
100     * @param  string $subject
101     * @return Message\AbstractMessage
102     */
103    public function setSubject($subject)
104    {
105        return $this->addHeader('Subject', $subject);
106    }
107
108    /**
109     * Set To
110     *
111     * @param  string $to
112     * @return Message\AbstractMessage
113     */
114    public function setTo($to)
115    {
116        $this->addresses['To'] = $this->parseAddresses($to, true);
117        return $this->addHeader('To', $this->parseAddresses($to));
118    }
119
120    /**
121     * Set CC
122     *
123     * @param  string $cc
124     * @return Message\AbstractMessage
125     */
126    public function setCc($cc)
127    {
128        $this->addresses['CC'] = $this->parseAddresses($cc, true);
129        return $this->addHeader('CC', $this->parseAddresses($cc));
130    }
131
132    /**
133     * Set BCC
134     *
135     * @param  string $bcc
136     * @return Message\AbstractMessage
137     */
138    public function setBcc($bcc)
139    {
140        $this->addresses['BCC'] = $this->parseAddresses($bcc, true);
141        return $this->addHeader('BCC', $this->parseAddresses($bcc));
142    }
143
144    /**
145     * Set From
146     *
147     * @param  string $from
148     * @return Message\AbstractMessage
149     */
150    public function setFrom($from)
151    {
152        $this->addresses['From'] = $this->parseAddresses($from, true);
153        return $this->addHeader('From', $this->parseAddresses($from));
154    }
155
156    /**
157     * Set Reply-To
158     *
159     * @param  string $replyTo
160     * @return Message\AbstractMessage
161     */
162    public function setReplyTo($replyTo)
163    {
164        $this->addresses['Reply-To'] = $this->parseAddresses($replyTo, true);
165        return $this->addHeader('Reply-To', $this->parseAddresses($replyTo));
166    }
167
168    /**
169     * Set Sender
170     *
171     * @param  mixed $sender
172     * @return Message\AbstractMessage
173     */
174    public function setSender($sender)
175    {
176        $this->addresses['Sender'] = $this->parseAddresses($sender, true);
177        return $this->addHeader('Sender', $this->parseAddresses($sender));
178    }
179
180    /**
181     * Set Return-Path
182     *
183     * @param  mixed $returnPath
184     * @return Message\AbstractMessage
185     */
186    public function setReturnPath($returnPath)
187    {
188        $this->addresses['Return-Path'] = $this->parseAddresses($returnPath, true);
189        return $this->addHeader('Return-Path', $this->parseAddresses($returnPath));
190    }
191
192    /**
193     * Set body
194     *
195     * @param  mixed $body
196     * @return Message
197     */
198    public function setBody($body)
199    {
200        if (!($body instanceof Message\PartInterface)) {
201            $body = new Message\Text($body);
202        }
203        return $this->addPart($body);
204    }
205
206    /**
207     * Add text message part
208     *
209     * @param  mixed $text
210     * @return Message
211     */
212    public function addText($text)
213    {
214        if (!($text instanceof Message\Text) && is_string($text)) {
215            $text = new Message\Text($text);
216        }
217        return $this->addPart($text);
218    }
219
220    /**
221     * Add HTML message part
222     *
223     * @param  mixed $html
224     * @return Message
225     */
226    public function addHtml($html)
227    {
228        if (!($html instanceof Message\Html) && is_string($html)) {
229            $html = new Message\Html($html);
230        }
231        return $this->addPart($html);
232    }
233
234    /**
235     * Attach file message part
236     *
237     * @param  string $file
238     * @param  string $encoding
239     * @return Message
240     */
241    public function attachFile($file, $encoding = AbstractPart::BASE64)
242    {
243        if (!($file instanceof Message\Attachment)) {
244            $options = [
245                'encoding' => $encoding,
246                'chunk'    => true
247            ];
248            $file = Message\Attachment::createFromFile($file, $options);
249        }
250        return $this->addPart($file);
251    }
252
253    /**
254     * Attach file message part from stream
255     *
256     * @param  string $stream
257     * @param  string $basename
258     * @param  string $encoding
259     * @return Message
260     */
261    public function attachFileFromStream($stream, $basename = 'file.tmp', $encoding = AbstractPart::BASE64)
262    {
263        $options = [
264            'basename' => $basename,
265            'encoding' => $encoding,
266            'chunk'    => true
267        ];
268        $file = Message\Attachment::createFromStream($stream, $options);
269        return $this->addPart($file);
270    }
271
272    /**
273     * Add message part
274     *
275     * @param  Message\PartInterface $part
276     * @return Message
277     */
278    public function addPart(Message\PartInterface $part)
279    {
280        $this->parts[] = $part;
281        $this->validateContentType();
282        return $this;
283    }
284
285    /**
286     * Remove header
287     *
288     * @param  string $header
289     * @return Message
290     */
291    public function removeHeader($header)
292    {
293        if (isset($this->headers[$header])) {
294            unset($this->headers[$header]);
295        }
296        return $this;
297    }
298
299    /**
300     * Get subject
301     *
302     * @return string
303     */
304    public function getSubject()
305    {
306        return $this->getHeader('Subject');
307    }
308
309    /**
310     * Get To
311     *
312     * @return array
313     */
314    public function getTo()
315    {
316        return $this->addresses['To'];
317    }
318
319    /**
320     * Get CC
321     *
322     * @return array
323     */
324    public function getCc()
325    {
326        return $this->addresses['CC'];
327    }
328
329    /**
330     * Get BCC
331     *
332     * @return array
333     */
334    public function getBcc()
335    {
336        return $this->addresses['BCC'];
337    }
338
339    /**
340     * Get From
341     *
342     * @return array
343     */
344    public function getFrom()
345    {
346        return $this->addresses['From'];
347    }
348
349    /**
350     * Get Reply-To
351     *
352     * @return array
353     */
354    public function getReplyTo()
355    {
356        return $this->addresses['Reply-To'];
357    }
358
359    /**
360     * Get Sender
361     *
362     * @return array
363     */
364    public function getSender()
365    {
366        return $this->addresses['Sender'];
367    }
368
369    /**
370     * Get Return-Path
371     *
372     * @return array
373     */
374    public function getReturnPath()
375    {
376        return $this->addresses['Return-Path'];
377    }
378
379    /**
380     * Get message body
381     *
382     * @return string
383     */
384    public function getBody()
385    {
386        $body  = null;
387
388        if (count($this->parts) > 1) {
389            foreach ($this->parts as $i => $part) {
390                $body .= '--' . $this->getBoundary() . self::CRLF . $part;
391            }
392            $body .= '--' . $this->getBoundary() . '--' . self::CRLF . self::CRLF;
393        } else if (count($this->parts) == 1) {
394            $part  = $this->parts[0];
395            if (($part instanceof Message\Text) || ($part instanceof Message\Html)) {
396                $body .= $part->getBody() . self::CRLF . self::CRLF;
397            } else {
398                $body .= '--' . $this->getBoundary() . self::CRLF . $part;
399                $body .= '--' . $this->getBoundary() . '--' . self::CRLF . self::CRLF;
400            }
401        }
402
403        return $body;
404    }
405
406    /**
407     * Get message MIME boundary
408     *
409     * @return string
410     */
411    public function getBoundary()
412    {
413        if (null === $this->boundary) {
414            $this->generateBoundary();
415        }
416        return $this->boundary;
417    }
418
419    /**
420     * Get message part
421     *
422     * @param  int $i
423     * @return Message\PartInterface
424     */
425    public function getPart($i)
426    {
427        return (isset($this->parts[(int)$i])) ? $this->parts[(int)$i] : null;
428    }
429
430    /**
431     * Get message parts
432     *
433     * @return array
434     */
435    public function getParts()
436    {
437        return $this->parts;
438    }
439
440    /**
441     * Set message MIME boundary
442     *
443     * @param  string $boundary
444     * @return Message
445     */
446    public function setBoundary($boundary)
447    {
448        $this->boundary = $boundary;
449        $this->addHeader('MIME-Version', '1.0');
450        return $this;
451    }
452
453    /**
454     * Generate message MIME boundary
455     *
456     * @return Message
457     */
458    public function generateBoundary()
459    {
460        return $this->setBoundary(sha1(time()));
461    }
462
463    /**
464     * Render message
465     *
466     * @param  array $omitHeaders
467     * @return string
468     */
469    public function render(array $omitHeaders = [])
470    {
471        return $this->getHeadersAsString($omitHeaders) . self::CRLF . $this->getBody();
472    }
473
474    /**
475     * Save message to file on disk
476     *
477     * @param  string $to
478     * @param  array  $omitHeaders
479     * @return void
480     */
481    public function save($to, array $omitHeaders = [])
482    {
483        file_put_contents($to, $this->render($omitHeaders));
484    }
485
486    /**
487     * Render as an array of lines
488     *
489     * @param  array $omitHeaders
490     * @return array
491     */
492    public function renderAsLines(array $omitHeaders = [])
493    {
494        $lines   = [];
495        $headers = explode(Message::CRLF, $this->getHeadersAsString($omitHeaders) . Message::CRLF);
496        $body    = explode("\n", $this->getBody());
497
498        foreach ($headers as $header) {
499            $lines[] = trim($header);
500        }
501
502        foreach ($body as $line) {
503            $lines[] = trim($line);
504        }
505
506        return $lines;
507    }
508
509    /**
510     * Write this entire entity to a buffer
511     *
512     * @param Transport\Smtp\Stream\BufferInterface $is
513     * @param  array $omitHeaders
514     */
515    public function toByteStream(Transport\Smtp\Stream\BufferInterface $is, array $omitHeaders = [])
516    {
517        $lines = $this->renderAsLines($omitHeaders);
518        foreach ($lines as $line) {
519            $is->write($line . self::CRLF);
520        }
521        $is->commit();
522    }
523
524    /**
525     * Parse message from file
526     *
527     * @param  string $file
528     * @throws Exception
529     * @return Message
530     */
531    public static function parseFromFile($file)
532    {
533        if (!file_exists($file)) {
534            throw new Exception("Error: The file '" . $file . "' does not exist.");
535        }
536
537        return self::parse(file_get_contents($file));
538    }
539
540    /**
541     * Parse message from string
542     *
543     * @param  string $stream
544     * @throws Exception
545     * @return Message
546     */
547    public static function parse($stream)
548    {
549        $parsedMessage = \Pop\Mime\Message::parseMessage($stream);
550        $message       = new self();
551
552        if ($parsedMessage->hasHeaders()) {
553            $headers = $parsedMessage->getHeaders();
554            foreach ($headers as $header => $value) {
555                switch (strtolower($header)) {
556                    case 'subject':
557                        $message->setSubject($value->getValue());
558                        break;
559                    case 'to':
560                        $message->setTo($value->getValue());
561                        break;
562                    case 'cc':
563                        $message->setCc($value->getValue());
564                        break;
565                    case 'bcc':
566                        $message->setBcc($value->getValue());
567                        break;
568                    case 'from':
569                        $message->setFrom($value->getValue());
570                        break;
571                    case 'reply-to':
572                        $message->setReplyTo($value->getValue());
573                        break;
574                    case 'sender':
575                        $message->setSender($value->getValue());
576                        break;
577                    case 'return-path':
578                        $message->setReturnPath($value->getValue());
579                        break;
580                    default:
581                        $message->addHeader($header, $value->getValue());
582                }
583            }
584        }
585
586        if (empty($message->getSubject())) {
587            throw new Exception('Error: There is no subject in the message contents');
588        }
589
590        if (empty($message->getTo())) {
591            throw new Exception('Error: There is no to address in the message contents');
592        }
593
594        if ($parsedMessage->hasParts()) {
595            $parts = Message\Part::parseParts($parsedMessage->getParts());
596
597            foreach ($parts as $part) {
598                if ($part->attachment) {
599                    $options = [
600                        'contentType' => $part->type,
601                        'basename'    => $part->basename,
602                        'encoding'    => AbstractPart::BASE64,
603                        'chunk'       => true
604                    ];
605                    $message->addPart(Message\Attachment::createFromStream($part->content, $options));
606                } else if (!empty($part->type) && (stripos($part->type, 'html') !== false)) {
607                    $message->addPart(new Message\Html($part->content));
608                } else if (!empty($part->type) && (stripos($part->type, 'text') !== false)) {
609                    $message->addPart(new Message\Text($part->content));
610                } else {
611                    $message->addPart(new Message\Simple($part->content));
612                }
613            }
614        }
615
616        return $message;
617    }
618
619    /**
620     * Decode text
621     *
622     * @param  string $text
623     * @return string
624     */
625    public static function decodeText($text)
626    {
627        $decodedValues = imap_mime_header_decode($text);
628        $decoded       = '';
629
630        foreach ($decodedValues as $string) {
631            $decoded .= $string->text;
632        }
633
634        return $decoded;
635    }
636
637    /**
638     * Parse addresses
639     *
640     * @param  mixed   $addresses
641     * @param  boolean $asArray
642     * @return string
643     */
644    public function parseAddresses($addresses, $asArray = false)
645    {
646        $formatted = [];
647        $emails    = [];
648
649        if (is_array($addresses)) {
650            foreach ($addresses as $key => $value) {
651                if ($value instanceof \stdClass) {
652                    $formatted[]    = $value->mailbox . '@' . $value->host;
653                    $emails[$value->mailbox . '@' . $value->host] = null;
654                } else {
655                    // $key is email
656                    if (strpos($key, '@') !== false) {
657                        if (!empty($value)) {
658                            $formatted[]  = '"' . $value . '" <' . $key . '>';
659                            $emails[$key] = $value;
660
661                        } else {
662                            $formatted[]  = $key;
663                            $emails[$key] = null;
664                        }
665                    // $value is email
666                    } else if (strpos($value, '@') !== false) {
667                        if (!empty($key)) {
668                            $formatted[]    = '"' . $key . '" <' . $value . '>';
669                            $emails[$value] = $key;
670                        } else {
671                            $formatted[]    = $value;
672                            $emails[$value] = null;
673                        }
674                    }
675                }
676            }
677        } else if (is_string($addresses) && (strpos($addresses, '@') !== false)) {
678            $formatted          = [$addresses];
679            if (strpos($addresses, ',') !== false) {
680                $addresses = explode(',', $addresses);
681                foreach ($addresses as $address) {
682                    $address = $this->parseNameAndEmail(trim($address));
683                    $emails[$address['email']] = $address['name'];
684                }
685            } else {
686                $address = $this->parseNameAndEmail(trim($addresses));
687                $emails[$address['email']] = $address['name'];
688            }
689        }
690
691        return ($asArray) ? $emails : implode(', ', $formatted);
692    }
693
694    /**
695     * Parse a name and email from an address string
696     *
697     * @param  string $address
698     * @return array
699     */
700    public function parseNameAndEmail($address)
701    {
702        $name  = null;
703        $email = null;
704
705        if ((strpos($address, '<') !== false) && (strpos($address, '>') !== false)) {
706            $name  = trim(substr($address, 0, strpos($address, '<')));
707            $email = substr($address, (strpos($address, '<') + 1));
708            $email = trim(substr($email, 0, -1));
709        } else if (strpos($address, '@') !== false) {
710            $email = trim($address);
711        }
712
713        return ['name' => $name, 'email' => $email];
714    }
715
716    /**
717     * Validate content type based on message parts added to the message
718     *
719     * @return void
720     */
721    protected function validateContentType()
722    {
723        $hasText = false;
724        $hasHtml = false;
725        $hasFile = false;
726
727        foreach ($this->parts as $part) {
728            if ($part instanceof Message\Text) {
729                $hasText = true;
730            }
731            if ($part instanceof Message\Html) {
732                $hasHtml = true;
733            }
734            if ($part instanceof Message\Attachment) {
735                $hasFile = true;
736            }
737        }
738
739        if (($hasText) && ($hasHtml)) {
740            $this->setContentType(
741                'multipart/alternative; boundary="' . $this->getBoundary() . '"' . self::CRLF .
742                'This is a multi-part message in MIME format.'
743            );
744            $this->setCharSet('');
745        } else if ($hasFile) {
746            $this->setContentType(
747                'multipart/mixed; boundary="' . $this->getBoundary() . '"' . self::CRLF .
748                'This is a multi-part message in MIME format.'
749            );
750            $this->setCharSet('');
751        }
752    }
753
754    /**
755     * Perform a "deep" clone of a message object
756     *
757     * @return void
758     */
759    public function __clone() {
760        foreach($this as $key => $val) {
761            if (is_object($val) || (is_array($val))) {
762                $this->{$key} = unserialize(serialize($val));
763            }
764        }
765    }
766
767}