Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
139 / 139
100.00% covered (success)
100.00%
48 / 48
CRAP
100.00% covered (success)
100.00%
1 / 1
Message
100.00% covered (success)
100.00%
139 / 139
100.00% covered (success)
100.00%
48 / 48
64
100.00% covered (success)
100.00%
1 / 1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMailer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCrlf
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCharset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaderLines
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareHeaders
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 renderData
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 setPlainMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPlainMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderPlainMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setHtmlMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHtmlMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderHtmlMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 renderMessage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getAttachments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addAttachment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setInlineAttachment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getInlineAttachments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderAttachments
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getContentType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 renderInlineAttachments
100.00% covered (success)
100.00%
14 / 14
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
 getSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTo
100.00% covered (success)
100.00%
3 / 3
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
 addCc
100.00% covered (success)
100.00%
3 / 3
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
 getRecipients
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addBcc
100.00% covered (success)
100.00%
3 / 3
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
 addReplyTo
100.00% covered (success)
100.00%
3 / 3
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
 setFrom
100.00% covered (success)
100.00%
3 / 3
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
 getFromAddress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFromName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setXPriority
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getXPriority
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatAddress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 formatAddressList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Email Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Email;
11
12use DateTime;
13use JetBrains\PhpStorm\Language;
14use LogicException;
15
16/**
17 * Class Message.
18 *
19 * @package email
20 */
21class Message implements \Stringable
22{
23    /**
24     * The Mailer instance.
25     *
26     * @var Mailer
27     */
28    protected Mailer $mailer;
29    /**
30     * The message boundary.
31     *
32     * @var string
33     */
34    protected string $boundary;
35    /**
36     * @var array<string,string>
37     */
38    protected array $headers = [
39        'mime-version' => '1.0',
40    ];
41    /**
42     * A list of attachments with Content-Disposition equals `attachment`.
43     *
44     * @var array<int,string> The filenames
45     */
46    protected array $attachments = [];
47    /**
48     * An associative array of attachments with Content-Disposition equals `inline`.
49     *
50     * @var array<string,string> The Content-ID's as keys and the filenames as values
51     */
52    protected array $inlineAttachments = [];
53    /**
54     * The plain text message.
55     *
56     * @var string
57     */
58    protected string $plainMessage;
59    /**
60     * The HTML message.
61     *
62     * @var string
63     */
64    protected string $htmlMessage;
65    /**
66     * An associative array used in the `To` header.
67     *
68     * @var array<string,string|null> The email addresses as keys and the optional
69     * name as values
70     */
71    protected array $to = [];
72    /**
73     * An associative array used in the `Cc` header.
74     *
75     * @var array<string,string|null> The email addresses as keys and the optional
76     * name as values
77     */
78    protected array $cc = [];
79    /**
80     * An associative array used in the `Bcc` header.
81     *
82     * @var array<string,string|null> The email addresses as keys and the optional
83     * name as values
84     */
85    protected array $bcc = [];
86    /**
87     * An associative array used in the `Reply-To` header.
88     *
89     * @var array<string,string|null> The email addresses as keys and the optional
90     * name as values
91     */
92    protected array $replyTo = [];
93    /**
94     * The values used in the `From` header.
95     *
96     * @var array<int,string|null> The email address as in the index 0 and the
97     * optional name in the index 1
98     */
99    protected array $from = [];
100    /**
101     * The message Date.
102     *
103     * @var string|null
104     */
105    protected ?string $date = null;
106    /**
107     * The message X-Priority.
108     *
109     * @var XPriority
110     */
111    protected XPriority $xPriority;
112
113    public function __toString() : string
114    {
115        return $this->renderData();
116    }
117
118    /**
119     * @param Mailer $mailer The Mailer instance
120     *
121     * @return static
122     */
123    public function setMailer(Mailer $mailer) : static
124    {
125        $this->mailer = $mailer;
126        return $this;
127    }
128
129    protected function getCrlf() : string
130    {
131        if (isset($this->mailer)) {
132            return $this->mailer->getCrlf();
133        }
134        return "\r\n";
135    }
136
137    protected function getCharset() : string
138    {
139        if (isset($this->mailer)) {
140            return $this->mailer->getCharset();
141        }
142        return 'utf-8';
143    }
144
145    public function setBoundary(string $boundary = null) : static
146    {
147        $this->boundary = $boundary ?? \bin2hex(\random_bytes(16));
148        return $this;
149    }
150
151    public function getBoundary() : string
152    {
153        if ( ! isset($this->boundary)) {
154            $this->setBoundary();
155        }
156        return $this->boundary;
157    }
158
159    /**
160     * @param string $name
161     * @param string $value
162     *
163     * @return static
164     */
165    public function setHeader(string $name, string $value) : static
166    {
167        $this->headers[\strtolower($name)] = $value;
168        return $this;
169    }
170
171    public function getHeader(string $name) : ?string
172    {
173        return $this->headers[\strtolower($name)] ?? null;
174    }
175
176    /**
177     * @return array<string,string>
178     */
179    public function getHeaders() : array
180    {
181        return $this->headers;
182    }
183
184    /**
185     * @return array<int,string>
186     */
187    public function getHeaderLines() : array
188    {
189        $lines = [];
190        foreach ($this->getHeaders() as $name => $value) {
191            $lines[] = Header::getName($name) . ': ' . $value;
192        }
193        return $lines;
194    }
195
196    protected function renderHeaders() : string
197    {
198        return \implode($this->getCrlf(), $this->getHeaderLines());
199    }
200
201    protected function prepareHeaders() : void
202    {
203        if ( ! $this->getDate()) {
204            $this->setDate();
205        }
206        $multipart = $this->getInlineAttachments() ? 'related' : 'mixed';
207        $this->setHeader(
208            Header::CONTENT_TYPE,
209            'multipart/' . $multipart . '; boundary="mixed-' . $this->getBoundary() . '"'
210        );
211    }
212
213    protected function renderData() : string
214    {
215        $boundary = $this->getBoundary();
216        $crlf = $this->getCrlf();
217        $this->prepareHeaders();
218        $data = $this->renderHeaders() . $crlf . $crlf;
219        $data .= '--mixed-' . $boundary . $crlf;
220        $data .= 'Content-Type: multipart/alternative; boundary="alt-' . $boundary . '"'
221            . $crlf . $crlf;
222        $data .= $this->renderPlainMessage();
223        $data .= $this->renderHtmlMessage();
224        $data .= '--alt-' . $boundary . '--' . $crlf . $crlf;
225        $data .= $this->renderAttachments();
226        $data .= $this->renderInlineAttachments();
227        $data .= '--mixed-' . $boundary . '--';
228        return $data;
229    }
230
231    /**
232     * @param string $message
233     *
234     * @return static
235     */
236    public function setPlainMessage(string $message) : static
237    {
238        $this->plainMessage = $message;
239        return $this;
240    }
241
242    public function getPlainMessage() : ?string
243    {
244        return $this->plainMessage ?? null;
245    }
246
247    protected function renderPlainMessage() : ?string
248    {
249        $message = $this->getPlainMessage();
250        return $message !== null ? $this->renderMessage($message, 'text/plain') : null;
251    }
252
253    /**
254     * @param string $message
255     *
256     * @return static
257     */
258    public function setHtmlMessage(#[Language('HTML')] string $message) : static
259    {
260        $this->htmlMessage = $message;
261        return $this;
262    }
263
264    public function getHtmlMessage() : ?string
265    {
266        return $this->htmlMessage ?? null;
267    }
268
269    protected function renderHtmlMessage() : ?string
270    {
271        $message = $this->getHtmlMessage();
272        return $message !== null ? $this->renderMessage($message) : null;
273    }
274
275    protected function renderMessage(
276        string $message,
277        string $contentType = 'text/html'
278    ) : string {
279        $message = \base64_encode($message);
280        $crlf = $this->getCrlf();
281        $part = '--alt-' . $this->getBoundary() . $crlf;
282        $part .= 'Content-Type: ' . $contentType . '; charset='
283            . $this->getCharset() . $crlf;
284        $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
285        $part .= \chunk_split($message) . $crlf;
286        return $part;
287    }
288
289    /**
290     * @return array<int,string>
291     */
292    public function getAttachments() : array
293    {
294        return $this->attachments;
295    }
296
297    /**
298     * @param string $filename The filename
299     *
300     * @return static
301     */
302    public function addAttachment(string $filename) : static
303    {
304        $this->attachments[] = $filename;
305        return $this;
306    }
307
308    /**
309     * @param string $filename The filename
310     * @param string $cid The Content-ID
311     *
312     * @return static
313     */
314    public function setInlineAttachment(string $filename, string $cid) : static
315    {
316        $this->inlineAttachments[$cid] = $filename;
317        return $this;
318    }
319
320    /**
321     * @return array<string,string>
322     */
323    public function getInlineAttachments() : array
324    {
325        return $this->inlineAttachments;
326    }
327
328    protected function renderAttachments() : string
329    {
330        $part = '';
331        $crlf = $this->getCrlf();
332        foreach ($this->getAttachments() as $attachment) {
333            if ( ! \is_file($attachment)) {
334                throw new LogicException('Attachment file not found: ' . $attachment);
335            }
336            $filename = \pathinfo($attachment, \PATHINFO_BASENAME);
337            $filename = \htmlspecialchars($filename, \ENT_QUOTES | \ENT_HTML5);
338            $contents = \file_get_contents($attachment);
339            $contents = \base64_encode($contents); // @phpstan-ignore-line
340            $part .= '--mixed-' . $this->getBoundary() . $crlf;
341            $part .= 'Content-Type: ' . $this->getContentType($attachment)
342                . '; name="' . $filename . '"' . $crlf;
343            $part .= 'Content-Disposition: attachment; filename="' . $filename . '"' . $crlf;
344            $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
345            $part .= \chunk_split($contents) . $crlf;
346        }
347        return $part;
348    }
349
350    protected function getContentType(string $filename) : string
351    {
352        return \mime_content_type($filename) ?: 'application/octet-stream';
353    }
354
355    protected function renderInlineAttachments() : string
356    {
357        $part = '';
358        $crlf = $this->getCrlf();
359        foreach ($this->getInlineAttachments() as $cid => $filename) {
360            if ( ! \is_file($filename)) {
361                throw new LogicException('Inline attachment file not found: ' . $filename);
362            }
363            $contents = \file_get_contents($filename);
364            $contents = \base64_encode($contents); // @phpstan-ignore-line
365            $part .= '--mixed-' . $this->getBoundary() . $crlf;
366            $part .= 'Content-ID: ' . $cid . $crlf;
367            $part .= 'Content-Type: ' . $this->getContentType($filename) . $crlf;
368            $part .= 'Content-Disposition: inline' . $crlf;
369            $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
370            $part .= \chunk_split($contents) . $crlf;
371        }
372        return $part;
373    }
374
375    /**
376     * @param string $subject
377     *
378     * @return static
379     */
380    public function setSubject(string $subject) : static
381    {
382        $this->setHeader(Header::SUBJECT, $subject);
383        return $this;
384    }
385
386    public function getSubject() : ?string
387    {
388        return $this->getHeader(Header::SUBJECT);
389    }
390
391    /**
392     * @param string $address
393     * @param string|null $name
394     *
395     * @return static
396     */
397    public function addTo(string $address, string $name = null) : static
398    {
399        $this->to[$address] = $name;
400        $this->setHeader(Header::TO, static::formatAddressList($this->to));
401        return $this;
402    }
403
404    /**
405     * @return array<string,string|null>
406     */
407    public function getTo() : array
408    {
409        return $this->to;
410    }
411
412    /**
413     * Add Carbon Copy email address.
414     *
415     * @param string $address
416     * @param string|null $name
417     *
418     * @return static
419     */
420    public function addCc(string $address, string $name = null) : static
421    {
422        $this->cc[$address] = $name;
423        $this->setHeader(Header::CC, static::formatAddressList($this->cc));
424        return $this;
425    }
426
427    /**
428     * @return array<string,string|null>
429     */
430    public function getCc() : array
431    {
432        return $this->cc;
433    }
434
435    /**
436     * @return array<int,string>
437     */
438    public function getRecipients() : array
439    {
440        $recipients = \array_replace($this->getTo(), $this->getCc());
441        return \array_keys($recipients);
442    }
443
444    /**
445     * Add Blind Carbon Copy email address.
446     *
447     * @param string $address
448     * @param string|null $name
449     *
450     * @return static
451     */
452    public function addBcc(string $address, string $name = null) : static
453    {
454        $this->bcc[$address] = $name;
455        $this->setHeader(Header::BCC, static::formatAddressList($this->bcc));
456        return $this;
457    }
458
459    /**
460     * @return array<string,string|null>
461     */
462    public function getBcc() : array
463    {
464        return $this->bcc;
465    }
466
467    /**
468     * @param string $address
469     * @param string|null $name
470     *
471     * @return static
472     */
473    public function addReplyTo(string $address, string $name = null) : static
474    {
475        $this->replyTo[$address] = $name;
476        $this->setHeader(Header::REPLY_TO, static::formatAddressList($this->replyTo));
477        return $this;
478    }
479
480    /**
481     * @return array<string,string|null>
482     */
483    public function getReplyTo() : array
484    {
485        return $this->replyTo;
486    }
487
488    /**
489     * @param string $address
490     * @param string|null $name
491     *
492     * @return static
493     */
494    public function setFrom(string $address, string $name = null) : static
495    {
496        $this->from = [$address, $name];
497        $this->setHeader(Header::FROM, static::formatAddress($address, $name));
498        return $this;
499    }
500
501    /**
502     * @return array<int,string|null>
503     */
504    public function getFrom() : array
505    {
506        return $this->from;
507    }
508
509    public function getFromAddress() : ?string
510    {
511        return $this->from[0] ?? null;
512    }
513
514    public function getFromName() : ?string
515    {
516        return $this->from[1] ?? null;
517    }
518
519    /**
520     * @param DateTime|null $datetime
521     *
522     * @return static
523     */
524    public function setDate(DateTime $datetime = null) : static
525    {
526        $date = $datetime ? $datetime->format('r') : \date('r');
527        $this->setHeader(Header::DATE, $date);
528        return $this;
529    }
530
531    public function getDate() : ?string
532    {
533        return $this->getHeader(Header::DATE);
534    }
535
536    /**
537     * @param XPriority $priority
538     *
539     * @return static
540     */
541    public function setXPriority(XPriority $priority) : static
542    {
543        $this->xPriority = $priority;
544        $this->setHeader(Header::X_PRIORITY, (string) $priority->value);
545        return $this;
546    }
547
548    public function getXPriority() : ?XPriority
549    {
550        return $this->xPriority ?? null;
551    }
552
553    protected static function formatAddress(string $address, string $name = null) : string
554    {
555        return $name !== null ? '"' . $name . '" <' . $address . '>' : $address;
556    }
557
558    /**
559     * @param array<string,string|null> $addresses
560     *
561     * @return string
562     */
563    protected static function formatAddressList(array $addresses) : string
564    {
565        $data = [];
566        foreach ($addresses as $address => $name) {
567            $data[] = static::formatAddress($address, $name);
568        }
569        return \implode(', ', $data);
570    }
571}