Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.43% covered (danger)
26.43%
231 / 874
45.83% covered (danger)
45.83%
22 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
Email
26.43% covered (danger)
26.43%
231 / 874
45.83% covered (danger)
45.83%
22 / 48
35898.26
0.00% covered (danger)
0.00%
0 / 1
 setFrom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getContentType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addCC
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addBCC
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addReplyTo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseAddresses
85.71% covered (warning)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
13.49
 preSend
12.50% covered (danger)
12.50%
4 / 32
0.00% covered (danger)
0.00%
0 / 1
126.22
 createHeader
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
342
 addrAppend
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addrFormat
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getMailMime
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
 punyencodeAddress
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 generateId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 createBody
0.00% covered (danger)
0.00%
0 / 151
0.00% covered (danger)
0.00%
0 / 1
870
 getBoundary
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 attachAll
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
240
 quotedString
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 encodeFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 encodeString
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 setMessageType
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 hasInlineImage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hasAttachment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 createAddressList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setWordWrap
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 wrapText
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
462
 encodeHeader
18.60% covered (danger)
18.60%
8 / 43
0.00% covered (danger)
0.00%
0 / 1
172.85
 encodeQ
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 base64EncodeWrapMB
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 addAttachment
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 getAttachments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addStringAttachment
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 addEmbeddedImage
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 addStringEmbeddedImage
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 cidExists
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 addCustomHeader
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getCustomHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 msgHTML
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
21.26
 normalizeBreaks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 html2text
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 sign
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 dkimQP
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 dkimSign
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 dkimHeaderC
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 dkimBodyC
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dkimAdd
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
420
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Message\Mail
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 *
13 * Extended based on:
14 * GLGPL 2.1 License
15 * (c) 2012 - 2015 Marcus Bointon, 2010 - 2012 Jim Jagielski, 2004 - 2009 Andy Prevost
16 * (c) PHPMailer
17 */
18declare(strict_types=1);
19
20namespace phpOMS\Message\Mail;
21
22use phpOMS\System\CharsetType;
23use phpOMS\System\File\FileUtils;
24use phpOMS\System\MimeType;
25use phpOMS\System\SystemUtils;
26use phpOMS\Utils\MbStringUtils;
27use phpOMS\Validation\Network\Email as EmailValidator;
28
29/**
30 * Mail class.
31 *
32 * @package phpOMS\Message\Mail
33 * @license OMS License 2.0
34 * @link    https://jingga.app
35 * @since   1.0.0
36 */
37class Email implements MessageInterface
38{
39    /**
40     * Mailer name.
41     *
42     * @var string
43     * @since 1.0.0
44     */
45    public const XMAILER = 'phpOMS';
46
47    /**
48     * The maximum line length supported by mail().
49     *
50     * @var int
51     * @since 1.0.0
52     */
53    public const MAIL_MAX_LINE_LENGTH = 63;
54
55    /**
56     * The maximum line length allowed by RFC 2822 section 2.1.1.
57     *
58     * @var int
59     * @since 1.0.0
60     */
61    public const MAX_LINE_LENGTH = 998;
62
63    /**
64     * The lower maximum line length allowed by RFC 2822 section 2.1.1.
65     *
66     * @var int
67     * @since 1.0.0
68     */
69    public const STD_LINE_LENGTH = 76;
70
71    /**
72     * Folding White Space.
73     *
74     * @var string
75     * @since 1.0.0
76     */
77    public const FWS = ' ';
78
79    /**
80     * SMTP RFC standard line ending
81     *
82     * @var string
83     * @since 1.0.0
84     */
85    protected static string $LE = "\r\n";
86
87    /**
88     * Message id.
89     *
90     * Format <id@domain>. If empty this is automatically generated.
91     *
92     * @var string
93     * @since 1.0.0
94     */
95    public string $messageId = '';
96
97    /**
98     * Unique ID used for message ID and boundaries.
99     *
100     * @var string
101     * @since 1.0.0
102     */
103    public string $uniqueid = '';
104
105    /**
106     * Hostname coming from the mail handler.
107     *
108     * @var string
109     * @since 1.0.0
110     */
111    public string $hostname = '';
112
113    /**
114     * Mailer for sending message
115     *
116     * @var string
117     * @since 1.0.0
118     */
119    public string $mailer = SubmitType::MAIL;
120
121    /**
122     * Mail from.
123     *
124     * @var array
125     * @since 1.0.0
126     */
127    public array $from = [];
128
129    /**
130     * Return path/bounce address
131     *
132     * @var string
133     * @since 1.0.0
134     */
135    public string $sender = '';
136
137    /**
138     * Confirm address.
139     *
140     * @var string
141     */
142    public string $confirmationAddress = '';
143
144    /**
145     * Mail to.
146     *
147     * @var array
148     * @since 1.0.0
149     */
150    public array $to = [];
151
152    /**
153     * Mail subject.
154     *
155     * @var string
156     * @since 1.0.0
157     */
158    public string $subject = '';
159
160    /**
161     * Mail cc.
162     *
163     * @var array
164     * @since 1.0.0
165     */
166    public array $cc = [];
167
168    /**
169     * Mail bcc.
170     *
171     * @var array
172     * @since 1.0.0
173     */
174    public array $bcc = [];
175
176    /**
177     * The array of reply-to names and addresses.
178     *
179     * @var array
180     * @since 1.0.0
181     */
182    public array $replyTo = [];
183
184    /**
185     * Mail attachments.
186     *
187     * @var array
188     * @since 1.0.0
189     */
190    protected array $attachment = [];
191
192    /**
193     * Mail body.
194     *
195     * @var string
196     * @since 1.0.0
197     */
198    public string $body = '';
199
200    /**
201     * Mail alt.
202     *
203     * @var string
204     * @since 1.0.0
205     */
206    public string $bodyAlt = '';
207
208    /**
209     * Ical body.
210     *
211     * @var string
212     * @since 1.0.0
213     */
214    public string $ical = '';
215
216    /**
217     * Mail mime.
218     *
219     * @var string
220     * @since 1.0.0
221     */
222    public string $bodyMime = '';
223
224    /**
225     * The array of MIME boundary strings.
226     *
227     * @var array
228     * @since 1.0.0
229     */
230    protected array $boundary = [];
231
232    /**
233     * Mail header.
234     *
235     * @var string
236     * @since 1.0.0
237     */
238    protected string $header = '';
239
240    /**
241     * Mail header.
242     *
243     * @var string
244     * @since 1.0.0
245     */
246    public string $headerMime = '';
247
248    /**
249     * The array of custom headers.
250     *
251     * @var array
252     * @since 1.0.0
253     */
254    protected array $customHeader = [];
255
256    /**
257     * Word wrap.
258     *
259     * @var int
260     * @since 1.0.0
261     */
262    protected int $wordWrap = 72;
263
264    /**
265     * Encoding.
266     *
267     * @var string
268     * @since 1.0.0
269     */
270    protected string $encoding = EncodingType::E_8BIT;
271
272    /**
273     * Mail content type.
274     *
275     * @var string
276     * @since 1.0.0
277     */
278    protected string $contentType = MimeType::M_TXT;
279
280    /**
281     * Character set
282     *
283     * @var string
284     * @since 1.0.0
285     */
286    public string $charset = CharsetType::ISO_8859_1;
287
288    /**
289     * Mail message type.
290     *
291     * @var string
292     * @since 1.0.0
293     */
294    protected string $messageType = '';
295
296    /**
297     * Mail from.
298     *
299     * @var null|\DateTime
300     * @since 1.0.0
301     */
302    public ?\DateTimeImmutable $messageDate = null;
303
304    /**
305     * Priority
306     *
307     * @var int
308     * @since 1.0.0
309     */
310    public int $priority = 0;
311
312    /**
313     * The S/MIME certificate file path.
314     *
315     * @var string
316     * @since 1.0.0
317     */
318    protected string $signCertFile = '';
319
320    /**
321     * The S/MIME key file path.
322     *
323     * @var string
324     * @since 1.0.0
325     */
326    protected string $signKeyFile = '';
327
328    /**
329     * The optional S/MIME extra certificates ("CA Chain") file path.
330     *
331     * @var string
332     * @since 1.0.0
333     */
334    protected string $signExtracertFiles = '';
335
336    /**
337     * The S/MIME password for the key.
338     * Used only if the key is encrypted.
339     *
340     * @var string
341     * @since 1.0.0
342     */
343    protected string $signKeyPass = '';
344
345    /**
346     * DKIM selector.
347     *
348     * @var string
349     * @since 1.0.0
350     */
351    public string $dkimSelector = '';
352
353    /**
354     * DKIM Identity.
355     * Usually the email address used as the source of the email.
356     *
357     * @var string
358     * @since 1.0.0
359     */
360    public string $dkimIdentity = '';
361
362    /**
363     * DKIM passphrase.
364     * Used if your key is encrypted.
365     *
366     * @var string
367     * @since 1.0.0
368     */
369    public string $dkimPass = '';
370
371    /**
372     * DKIM signing domain name.
373     *
374     * @var string
375     * @since 1.0.0
376     */
377    public string $dkimDomain = '';
378
379    /**
380     * DKIM Copy header field values for diagnostic use.
381     *
382     * @var bool
383     * @since 1.0.0
384     */
385    public bool $dkimCopyHeader = true;
386
387    /**
388     * DKIM Extra signing headers.
389     *
390     * @example ['List-Unsubscribe', 'List-Help']
391     *
392     * @var array
393     * @since 1.0.0
394     */
395    public array $dkimHeaders = [];
396
397    /**
398     * DKIM private key file path.
399     *
400     * @var string
401     * @since 1.0.0
402     */
403    public string $dkimPrivatePath = '';
404
405    /**
406     * DKIM private key string.
407     *
408     * If set, takes precedence over `$dkimPrivatePath`.
409     *
410     * @var string
411     * @since 1.0.0
412     */
413    public string $dkimPrivateKey = '';
414
415    /**
416     * Set the From and FromName.
417     *
418     * @param string $address Email address
419     * @param string $name    Name
420     *
421     * @return bool
422     *
423     * @since 1.0.0
424     */
425    public function setFrom(string $address, string $name = '') : bool
426    {
427        $address = \trim($address);
428        $name    = \trim(\preg_replace('/[\r\n]+/', '', $name));
429
430        if (!EmailValidator::isValid($address)) {
431            return false;
432        }
433
434        $this->from = [$address, $name];
435
436        if (empty($this->sender)) {
437            $this->sender = $address;
438        }
439
440        return true;
441    }
442
443    /**
444     * Sets message type to html or plain.
445     *
446     * @param bool $isHtml Html mode
447     *
448     * @return void
449     *
450     * @since 1.0.0
451     */
452    public function setHtml(bool $isHtml = true) : void
453    {
454        $this->contentType = $isHtml ? MimeType::M_HTML : MimeType::M_TEXT;
455    }
456
457    public function getContentType() : string
458    {
459        return $this->contentType;
460    }
461
462    public function isHtml() : bool
463    {
464        return $this->contentType === MimeType::M_HTML;
465    }
466
467    /**
468     * Add a "To" address.
469     *
470     * @param string $address Email address
471     * @param string $name    Name
472     *
473     * @return bool
474     *
475     * @since 1.0.0
476     */
477    public function addTo(string $address, string $name = '') : bool
478    {
479        if (!EmailValidator::isValid($address)) {
480            return false;
481        }
482
483        $this->to[$address] = [$address, $name];
484
485        return true;
486    }
487
488    /**
489     * Add a "CC" address.
490     *
491     * @param string $address Email address
492     * @param string $name    Name
493     *
494     * @return bool
495     *
496     * @since 1.0.0
497     */
498    public function addCC(string $address, string $name = '') : bool
499    {
500        if (!EmailValidator::isValid($address)) {
501            return false;
502        }
503
504        $this->cc[$address] = [$address, $name];
505
506        return true;
507    }
508
509    /**
510     * Add a "BCC" address.
511     *
512     * @param string $address Email address
513     * @param string $name    Name
514     *
515     * @return bool
516     *
517     * @since 1.0.0
518     */
519    public function addBCC(string $address, string $name = '') : bool
520    {
521        if (!EmailValidator::isValid($address)) {
522            return false;
523        }
524
525        $this->bcc[$address] = [$address, $name];
526
527        return true;
528    }
529
530    /**
531     * Add a "Reply-To" address.
532     *
533     * @param string $address Email address
534     * @param string $name    Name
535     *
536     * @return bool
537     *
538     * @since 1.0.0
539     */
540    public function addReplyTo(string $address, string $name = '') : bool
541    {
542        if (!EmailValidator::isValid($address)) {
543            return false;
544        }
545
546        $this->replyTo[$address] = [$address, $name];
547
548        return true;
549    }
550
551    /**
552     * Parse and validate a string containing one or more RFC822-style comma-separated email addresses
553     * of the form "display name <address>" into an array of name/address pairs.
554     *
555     * @param string $addrstr Address line
556     * @param bool   $useImap Use imap for parsing
557     * @param string $charset Charset for email
558     *
559     * @return array
560     *
561     * @since 1.0.0
562     */
563    public static function parseAddresses(string $addrstr, bool $useimap = true, string $charset = CharsetType::ISO_8859_1) : array
564    {
565        $addresses = [];
566        if ($useimap && \function_exists('imap_rfc822_parse_adrlist')) {
567            $list = \imap_rfc822_parse_adrlist($addrstr, '');
568            foreach ($list as $address) {
569                if (($address->host !== '.SYNTAX-ERROR.')
570                    && EmailValidator::isValid($address->mailbox . '@' . $address->host)
571                ) {
572                    if (\property_exists($address, 'personal')
573                        && \preg_match('/^=\?.*\?=$/s', $address->personal)
574                    ) {
575                        $origCharset = \mb_internal_encoding();
576                        \mb_internal_encoding($charset);
577                        $address->personal = \str_replace('_', '=20', $address->personal);
578                        $address->personal = \mb_decode_mimeheader($address->personal);
579                        \mb_internal_encoding($origCharset);
580                    }
581
582                    $addresses[] = [
583                        'name'    => (\property_exists($address, 'personal') ? $address->personal : ''),
584                        'address' => $address->mailbox . '@' . $address->host,
585                    ];
586                }
587            }
588
589            return $addresses;
590        }
591
592        $list = \explode(',', $addrstr);
593        foreach ($list as $address) {
594            $address = \trim($address);
595            if (\strpos($address, '<') === false) {
596                if (EmailValidator::isValid($address)) {
597                    $addresses[] = [
598                        'name'    => '',
599                        'address' => $address,
600                    ];
601                }
602            } else {
603                $addr  = \explode('<', $address);
604                $email = \trim(\str_replace('>', '', $addr[1]));
605
606                if (EmailValidator::isValid($email)) {
607                    $addresses[] = [
608                        'name'    => \trim($addr[0], '\'" '),
609                        'address' => $email,
610                    ];
611                }
612            }
613        }
614
615        return $addresses;
616    }
617
618    /**
619     * Pre-send preparations
620     *
621     * @param string $mailer Mailer tool
622     *
623     * @return bool
624     *
625     * @since 1.0.0
626     */
627    public function preSend(string $mailer) : bool
628    {
629        $this->header = '';
630        $this->mailer = $mailer;
631
632        if (empty($this->to) && empty($this->cc) && empty($this->bcc)) {
633            return false;
634        }
635
636        if (!empty($this->bodyAlt)) {
637            $this->contentType = MimeType::M_ALT;
638        }
639
640        $this->setMessageType();
641
642        $this->headerMime = '';
643        $this->bodyMime   = $this->createBody();
644
645        $tempheaders       = $this->headerMime;
646        $this->headerMime  = $this->createHeader();
647        $this->headerMime .= $tempheaders;
648
649        if ($this->mailer === SubmitType::MAIL) {
650            $this->header .= empty($this->to)
651                ? 'Subject: undisclosed-recipients:;' . self::$LE
652                : $this->createAddressList('To', $this->to);
653
654            $this->header .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE;
655        }
656
657        // Sign with DKIM if enabled
658        if (!empty($this->dkimDomain)
659            && !empty($this->dkimSelector)
660            && (!empty($this->dkimPrivateKey)
661                || (!empty($this->dkimPrivatePath)
662                    && FileUtils::isPermittedPath($this->dkimPrivatePath)
663                    && \is_file($this->dkimPrivatePath)
664                )
665            )
666        ) {
667            $headerDkim = $this->dkimAdd(
668                $this->headerMime . $this->header,
669                $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))),
670                $this->bodyMime
671            );
672
673            $this->headerMime = \rtrim($this->headerMime, " \r\n\t") . self::$LE .
674                self::normalizeBreaks($headerDkim, self::$LE) . self::$LE;
675        }
676
677        return true;
678    }
679
680    /**
681     * Assemble message headers.
682     *
683     * @return string The assembled headers
684     *
685     * @since 1.0.0
686     */
687    private function createHeader() : string
688    {
689        $result = 'Date : ' . ($this->messageDate === null
690                ? (new \DateTime('now'))->format('D, j M Y H:i:s O')
691                : $this->messageDate->format('D, j M Y H:i:s O'))
692            . self::$LE;
693
694        if ($this->mailer !== SubmitType::MAIL) {
695            $result .= empty($this->to)
696                ? 'To: undisclosed-recipients:;' . self::$LE
697                : $this->addrAppend('To', $this->to);
698        }
699
700        $result .= $this->addrAppend('From', [$this->from]);
701
702        // sendmail and mail() extract Cc from the header before sending
703        if (!empty($this->cc)) {
704            $result .= $this->addrAppend('Cc', $this->cc);
705        }
706
707        // sendmail and mail() extract Bcc from the header before sending
708        if (($this->mailer === SubmitType::MAIL
709                || $this->mailer === SubmitType::SENDMAIL
710                || $this->mailer === SubmitType::QMAIL)
711            && !empty($this->bcc)
712        ) {
713            $result .= $this->addrAppend('Bcc', $this->bcc);
714        }
715
716        if (!empty($this->replyTo)) {
717            $result .= $this->addrAppend('Reply-To', $this->replyTo);
718        }
719
720        // mail() sets the subject itself
721        if ($this->mailer !== SubmitType::MAIL) {
722            $result .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE;
723        }
724
725        $this->hostname = empty($this->hostname) ? SystemUtils::getHostname() : $this->hostname;
726
727        // Only allow a custom message Id if it conforms to RFC 5322 section 3.6.4
728        // https://tools.ietf.org/html/rfc5322#section-3.6.4
729        $this->messageId = $this->messageId !== ''
730            && \preg_match('/^<((([a-z\d!#$%&\'*+\/=?^_`{|}~-]+(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)' .
731        '|("(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]|[\x21\x23-\x5B\x5D-\x7E])' .
732        '|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*"))@(([a-z\d!#$%&\'*+\/=?^_`{|}~-]+' .
733        '(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)|(\[(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]' .
734        '|[\x21-\x5A\x5E-\x7E])|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*\])))>$/Di', $this->messageId)
735            ? $this->messageId
736            : \sprintf('<%s@%s>', $this->uniqueid, $this->hostname);
737
738        $result .= 'Message-ID: ' . $this->messageId . self::$LE;
739
740        if ($this->priority > 0) {
741            $result .= 'X-Priority: ' . $this->priority . self::$LE;
742        }
743
744        $result .= 'X-Mailer: ' . self::XMAILER . self::$LE;
745
746        if ($this->confirmationAddress !== '') {
747            $result .= 'Disposition-Notification-To: ' . '<' . $this->confirmationAddress . '>' . self::$LE;
748        }
749
750        // Add custom headers
751        foreach ($this->customHeader as $header) {
752            $result .= \trim($header[0]) . ': ' . $this->encodeHeader(\trim($header[1])) . self::$LE;
753        }
754
755        if (empty($this->signKeyFile)) {
756            $result .= 'MIME-Version: 1.0' . self::$LE;
757            $result .= $this->getMailMime();
758        }
759
760        return $result;
761    }
762
763    /**
764     * Create recipient headers.
765     *
766     * @param string $type Address type
767     * @param array  $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User']
768     *
769     * @return string
770     *
771     * @since 1.0.0
772     */
773    private function addrAppend(string $type, array $addr) : string
774    {
775        $addresses = [];
776        foreach ($addr as $address) {
777            $addresses[] = $this->addrFormat($address);
778        }
779
780        return $type . ': ' . \implode(', ', $addresses) . self::$LE;
781    }
782
783    /**
784     * Format an address for use in a message header.
785     *
786     * @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User']
787     *
788     * @return string
789     *
790     * @since 1.0.0
791     */
792    public function addrFormat(array $addr) : string
793    {
794        if (empty($addr[1])) {
795            return \trim(\str_replace(["\r", "\n"], '', $addr[0]));
796        }
797
798        return $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $addr[1])), 'phrase') .
799            ' <' . \trim(\str_replace(["\r", "\n"], '', $addr[0])) . '>';
800    }
801
802    /**
803     * Get the message MIME type headers.
804     *
805     * @return string
806     *
807     * @since 1.0.0
808     */
809    private function getMailMime() : string
810    {
811        $result      = '';
812        $isMultipart = true;
813
814        switch ($this->messageType) {
815            case 'inline':
816                $result .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
817                $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
818                break;
819            case 'attach':
820            case 'inline_attach':
821            case 'alt_attach':
822            case 'alt_inline_attach':
823                $result .= 'Content-Type: ' . MimeType::M_MIXED . ';' . self::$LE;
824                $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
825                break;
826            case 'alt':
827            case 'alt_inline':
828                $result .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
829                $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
830                break;
831            default:
832                // Catches case 'plain': and case '':
833                $result     .= 'Content-Type: ' . $this->contentType . '; charset=' . $this->charset . self::$LE;
834                $isMultipart = false;
835                break;
836        }
837
838        // RFC1341 part 5 says 7bit is assumed if not specified
839        if ($this->encoding === EncodingType::E_7BIT) {
840            return $result;
841        }
842
843        // RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE
844        if ($isMultipart) {
845            if ($this->encoding === EncodingType::E_8BIT) {
846                $result .= 'Content-Transfer-Encoding: ' . EncodingType::E_8BIT . self::$LE;
847            }
848
849            // quoted-printable and base64 are 7bit compatible
850        } else {
851            $result .= 'Content-Transfer-Encoding: ' . $this->encoding . self::$LE;
852        }
853
854        return $result;
855    }
856
857    /**
858     * Converts IDN in given email address to its ASCII form
859     *
860     * @param string $charset Charset
861     * @param string $address Email address
862     *
863     * @return string The encoded address in ASCII form
864     *
865     * @since 1.0.0
866     */
867    private function punyencodeAddress(string $charset, string $address) : string
868    {
869        if (empty($charset) || !EmailValidator::isValid($address)) {
870            return $address;
871        }
872
873        $pos    = \strrpos($address, '@');
874        $domain = \substr($address, ++$pos);
875
876        if (!((bool) \preg_match('/[\x80-\xFF]/', $domain)) || !\mb_check_encoding($domain, $charset)) {
877            return $address;
878        }
879
880        $domain = \mb_convert_encoding($domain, 'UTF-8', $charset);
881
882        $errorcode = 0;
883        if (\defined('INTL_IDNA_VARIANT_UTS46')) {
884            $punycode = \idn_to_ascii(
885                $domain,
886                \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII,
887                \INTL_IDNA_VARIANT_UTS46);
888        } else {
889            $punycode = \idn_to_ascii($domain, $errorcode);
890        }
891
892        if ($punycode !== false) {
893            return \substr($address, 0, $pos) . $punycode;
894        }
895
896        return $address;
897    }
898
899    /**
900     * Create a unique ID to use for boundaries.
901     *
902     * @return string
903     *
904     * @since 1.0.0
905     */
906    protected function generateId() : string
907    {
908        $len   = 32; //32 bytes = 256 bits
909        $bytes = '';
910        $bytes = \random_bytes($len);
911
912        if ($bytes === '') {
913            $bytes = \hash('sha256', \uniqid((string) \mt_rand(), true), true); // @codeCoverageIgnore
914        }
915
916        return \str_replace(['=', '+', '/'], '', \base64_encode(\hash('sha256', $bytes, true)));
917    }
918
919    /**
920     * Assemble the message body.
921     *
922     * @return string Empty on failure
923     *
924     * @since 1.0.0
925     */
926    public function createBody() : string
927    {
928        $body           = '';
929        $this->uniqueid = $this->generateId();
930
931        $this->boundary[1] = 'b1=_' . $this->uniqueid;
932        $this->boundary[2] = 'b2=_' . $this->uniqueid;
933        $this->boundary[3] = 'b3=_' . $this->uniqueid;
934
935        if (!empty($this->signKeyFile)) {
936            $body .= $this->getMailMime() . self::$LE;
937        }
938
939        $this->setWordWrap();
940
941        $bodyEncoding = $this->encoding;
942        $bodyCharSet  = $this->charset;
943
944        // Can we do a 7-bit downgrade?
945        if ($bodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->body))) {
946            $bodyEncoding = EncodingType::E_7BIT;
947
948            //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit
949            $bodyCharSet = CharsetType::ASCII;
950        }
951
952        // If lines are too long, and we're not already using an encoding that will shorten them,
953        // change to quoted-printable transfer encoding for the body part only
954        if ($this->encoding !== EncodingType::E_BASE64
955            && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->body))
956        ) {
957            $bodyEncoding = EncodingType::E_QUOTED;
958        }
959
960        $altBodyEncoding = $this->encoding;
961        $altBodyCharSet  = $this->charset;
962
963        //Can we do a 7-bit downgrade?
964        if ($altBodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->bodyAlt))) {
965            $altBodyEncoding = EncodingType::E_7BIT;
966
967            //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit
968            $altBodyCharSet = CharsetType::ASCII;
969        }
970
971        //If lines are too long, and we're not already using an encoding that will shorten them,
972        //change to quoted-printable transfer encoding for the alt body part only
973        if ($altBodyEncoding !== EncodingType::E_BASE64
974            && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->bodyAlt))
975        ) {
976            $altBodyEncoding = EncodingType::E_QUOTED;
977        }
978
979        //Use this as a preamble in all multipart message types
980        $mimePre = '';
981        switch ($this->messageType) {
982            case 'inline':
983                $body .= $mimePre;
984                $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding);
985                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
986                $body .= $this->attachAll('inline', $this->boundary[1]);
987                break;
988            case 'attach':
989                $body .= $mimePre;
990                $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding);
991                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
992                $body .= $this->attachAll('attachment', $this->boundary[1]);
993                break;
994            case 'inline_attach':
995                $body .= $mimePre;
996                $body .= '--' . $this->boundary[1] . self::$LE;
997                $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
998                $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE;
999                $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
1000                $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding);
1001                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
1002                $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE;
1003                $body .= $this->attachAll('attachment', $this->boundary[1]);
1004                break;
1005            case 'alt':
1006                $body .= $mimePre;
1007                $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
1008                $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
1009                $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
1010                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
1011
1012                if (!empty($this->ical)) {
1013                    $method  = ICALMethodType::REQUEST;
1014                    $methods = ICALMethodType::getConstants();
1015
1016                    foreach ($methods as $imethod) {
1017                        if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) {
1018                            $method = $imethod;
1019                            break;
1020                        }
1021                    }
1022
1023                    $body .= $this->getBoundary($this->boundary[1], '', MimeType::M_ICS . '; method=' . $method, '');
1024                    $body .= $this->encodeString($this->ical, $this->encoding) . self::$LE;
1025                }
1026
1027                $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE;
1028                break;
1029            case 'alt_inline':
1030                $body .= $mimePre;
1031                $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
1032                $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
1033                $body .= '--' . $this->boundary[1] . self::$LE;
1034                $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
1035                $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE;
1036                $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
1037                $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
1038                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
1039                $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE;
1040                $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE;
1041                break;
1042            case 'alt_attach':
1043                $body .= $mimePre;
1044                $body .= '--' . $this->boundary[1] . self::$LE;
1045                $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
1046                $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE . self::$LE;
1047                $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
1048                $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) .  self::$LE;
1049                $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
1050                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
1051
1052                if (!empty($this->ical)) {
1053                    $method  = ICALMethodType::REQUEST;
1054                    $methods = ICALMethodType::getConstants();
1055
1056                    foreach ($methods as $imethod) {
1057                        if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) {
1058                            $method = $imethod;
1059                            break;
1060                        }
1061                    }
1062
1063                    $body .= $this->getBoundary($this->boundary[2], '', MimeType::M_ICS . '; method=' . $method, '');
1064                    $body .= $this->encodeString($this->ical, $this->encoding);
1065                }
1066
1067                $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE;
1068                $body .= $this->attachAll('attachment', $this->boundary[1]);
1069                break;
1070            case 'alt_inline_attach':
1071                $body .= $mimePre;
1072                $body .= '--' . $this->boundary[1] . self::$LE;
1073                $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
1074                $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE;
1075                $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
1076                $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
1077                $body .= '--' . $this->boundary[2] . self::$LE;
1078                $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
1079                $body .= ' boundary="' . $this->boundary[3] . '";' . self::$LE;
1080                $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
1081                $body .= $this->getBoundary($this->boundary[3], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
1082                $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
1083                $body .= $this->attachAll('inline', $this->boundary[3]) . self::$LE;
1084                $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE;
1085                $body .= $this->attachAll('attachment', $this->boundary[1]);
1086                break;
1087            default:
1088                // Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types
1089                $this->encoding = $bodyEncoding;
1090                $body          .= $this->encodeString($this->body, $this->encoding);
1091                break;
1092        }
1093
1094        if (!empty($this->signKeyFile)) {
1095            if (!\defined('PKCS7_TEXT')) {
1096                return '';
1097            }
1098
1099            $file   = \tempnam($tmpDir = \sys_get_temp_dir(), 'srcsign');
1100            $signed = \tempnam($tmpDir, 'mailsign');
1101            \file_put_contents($file, $body);
1102
1103            try {
1104                // Workaround for PHP bug https://bugs.php.net/bug.php?id=69197
1105                $sign = empty($this->signExtracertFiles)
1106                    ? \openssl_pkcs7_sign(\realpath($file), $signed,
1107                            'file://' . \realpath($this->signCertFile),
1108                            ['file://' . \realpath($this->signKeyFile), $this->signKeyPass],
1109                            [],
1110                        )
1111                    : \openssl_pkcs7_sign(\realpath($file), $signed,
1112                            'file://' . \realpath($this->signCertFile),
1113                            ['file://' . \realpath($this->signKeyFile), $this->signKeyPass],
1114                            [],
1115                            \PKCS7_DETACHED,
1116                            $this->signExtracertFiles
1117                        );
1118            } catch (\Throwable $_) {
1119                $sign = false;
1120            }
1121
1122            \unlink($file);
1123            if ($sign === false) {
1124                \unlink($signed);
1125                return '';
1126            }
1127
1128            $body = \file_get_contents($signed);
1129            \unlink($signed);
1130
1131            //The message returned by openssl contains both headers and body, so need to split them up
1132            $parts             = \explode("\n\n", $body, 2);
1133            $this->headerMime .= $parts[0] . self::$LE . self::$LE;
1134            $body              = $parts[1];
1135        }
1136
1137        return $body;
1138    }
1139
1140    /**
1141     * Return the start of a message boundary.
1142     *
1143     * @param string $boundary    Boundary
1144     * @param string $charset     Charset
1145     * @param string $contentType Content type
1146     * @param string $encoding    Concoding
1147     *
1148     * @return string
1149     *
1150     * @since 1.0.0
1151     */
1152    protected function getBoundary(string $boundary, string $charset, string $contentType, string $encoding) : string
1153    {
1154        $result = '';
1155        if ($charset === '') {
1156            $charset = $this->charset;
1157        }
1158
1159        if ($contentType === '') {
1160            $contentType = $this->contentType;
1161        }
1162
1163        if ($encoding === '') {
1164            $encoding = $this->encoding;
1165        }
1166
1167        $result .= '--' . $boundary . self::$LE;
1168        $result .= \sprintf('Content-Type: %s; charset=%s', $contentType, $charset);
1169        $result .= self::$LE;
1170
1171        // RFC1341 part 5 says 7bit is assumed if not specified
1172        if ($encoding !== EncodingType::E_7BIT) {
1173            $result .= 'Content-Transfer-Encoding: ' . $encoding . self::$LE;
1174        }
1175
1176        return $result . self::$LE;
1177    }
1178
1179    /**
1180     * Attach all file, string, and binary attachments to the message.
1181     *
1182     * @param string $dispositionType Disposition type
1183     * @param string $boundary        Boundary string
1184     *
1185     * @return string
1186     *
1187     * @since 1.0.0
1188     */
1189    protected function attachAll(string $dispositionType, string $boundary) : string
1190    {
1191        $mime    = [];
1192        $cidUniq = [];
1193        $incl    = [];
1194
1195        $attachments = $this->getAttachments();
1196        foreach ($attachments as $attachment) {
1197            if ($attachment[6] !== $dispositionType) {
1198                continue;
1199            }
1200
1201            $bString = $attachment[5];
1202            $string  = $bString ? $attachment[0] : '';
1203            $path    = $bString ? '' : $attachment[0];
1204
1205            $inclHash = \hash('sha256', \serialize($attachment));
1206            if (\in_array($inclHash, $incl, true)) {
1207                continue;
1208            }
1209
1210            $incl[]      = $inclHash;
1211            $name        = $attachment[2];
1212            $encoding    = $attachment[3];
1213            $type        = $attachment[4];
1214            $disposition = $attachment[6];
1215            $cid         = $attachment[7];
1216
1217            if ($disposition === 'inline' && isset($cidUniq[$cid])) {
1218                continue;
1219            }
1220
1221            $cidUniq[$cid] = true;
1222            $mime[]        = \sprintf('--%s%s', $boundary, self::$LE);
1223
1224            //Only include a filename property if we have one
1225            $mime[] = empty($name)
1226                ? \sprintf('Content-Type: %s%s',
1227                        $type,
1228                        self::$LE
1229                    )
1230                : \sprintf('Content-Type: %s; name=%s%s',
1231                        $type,
1232                        self::quotedString($this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)))),
1233                        self::$LE
1234                    );
1235
1236            // RFC1341 part 5 says 7bit is assumed if not specified
1237            if ($encoding !== EncodingType::E_7BIT) {
1238                $mime[] = \sprintf('Content-Transfer-Encoding: %s%s', $encoding, self::$LE);
1239            }
1240
1241            //Only set Content-IDs on inline attachments
1242            if ((string) $cid !== '' && $disposition === 'inline') {
1243                $mime[] = 'Content-ID: <' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $cid))) . '>' . self::$LE;
1244            }
1245
1246            // Allow for bypassing the Content-Disposition header
1247            if (!empty($disposition)) {
1248                $encodedName = $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)));
1249                    $mime[]  = empty($encodedName)
1250                        ? \sprintf('Content-Disposition: %s%s', $disposition, self::$LE . self::$LE)
1251                        : \sprintf('Content-Disposition: %s; filename=%s%s',
1252                                $disposition,
1253                                self::quotedString($encodedName),
1254                                self::$LE . self::$LE
1255            );
1256            } else {
1257                $mime[] = self::$LE;
1258            }
1259
1260            // Encode as string attachment
1261            $mime[] = $bString
1262                ? $this->encodeString($string, $encoding)
1263                : $this->encodeFile($path, $encoding);
1264
1265            $mime[] = self::$LE;
1266        }
1267
1268        $mime[] = \sprintf('--%s--%s', $boundary, self::$LE);
1269
1270        return \implode('', $mime);
1271    }
1272
1273    /**
1274     * If a string contains any "special" characters, double-quote the name,
1275     * and escape any double quotes with a backslash.
1276     *
1277     * @param string $str String to quote
1278     *
1279     * @return string
1280     *
1281     * @since 1.0.0
1282     */
1283    private static function quotedString(string $str) : string
1284    {
1285        if (\preg_match('/[ ()<>@,;:"\/\[\]?=]/', $str)) {
1286            return '"' . \str_replace('"', '\\"', $str) . '"';
1287        }
1288
1289        return $str;
1290    }
1291
1292    /**
1293     * Encode a file attachment in requested format.
1294     *
1295     * @param string $path     Path
1296     * @param string $encoding Encoding
1297     *
1298     * @return string
1299     *
1300     * @since 1.0.0
1301     */
1302    private function encodeFile(string $path, string $encoding = EncodingType::E_BASE64) : string
1303    {
1304        if (!FileUtils::isAccessible($path)) {
1305            return '';
1306        }
1307
1308        $fileBuffer = \file_get_contents($path);
1309
1310        return $fileBuffer === false ? '' : $this->encodeString($fileBuffer, $encoding);
1311    }
1312
1313    /**
1314     * Encode a string in requested format.
1315     *
1316     * @param string $str      The text to encode
1317     * @param string $encoding Encoding
1318     *
1319     * @return string
1320     *
1321     * @since 1.0.0
1322     */
1323    private function encodeString(string $str, string $encoding = EncodingType::E_BASE64) : string
1324    {
1325        $encoded = '';
1326        switch (\strtolower($encoding)) {
1327            case EncodingType::E_BASE64:
1328                $encoded = \chunk_split(\base64_encode($str), self::STD_LINE_LENGTH, self::$LE);
1329                break;
1330            case EncodingType::E_7BIT:
1331            case EncodingType::E_8BIT:
1332                $encoded = self::normalizeBreaks($str, self::$LE);
1333                if (\substr($encoded, -(\strlen(self::$LE))) !== self::$LE) {
1334                    $encoded .= self::$LE;
1335                }
1336
1337                break;
1338            case EncodingType::E_BINARY:
1339                $encoded = $str;
1340                break;
1341            case EncodingType::E_QUOTED:
1342                $encoded = self::normalizeBreaks(\quoted_printable_encode($str), self::$LE);
1343                break;
1344            default:
1345                return '';
1346        }
1347
1348        return $encoded;
1349    }
1350
1351    /**
1352     * Set message type based on content
1353     *
1354     * @return void
1355     *
1356     * @since 1.0.0
1357     */
1358    protected function setMessageType() : void
1359    {
1360        $type = [];
1361        if (!empty($this->bodyAlt)) {
1362            $type[] = 'alt';
1363        }
1364
1365        if ($this->hasInlineImage()) {
1366            $type[] = 'inline';
1367        }
1368
1369        if ($this->hasAttachment()) {
1370            $type[] = 'attach';
1371        }
1372
1373        $this->messageType = \implode('_', $type);
1374        if ($this->messageType === '') {
1375            $this->messageType = 'plain';
1376        }
1377    }
1378
1379    /**
1380     * Mail has inline image
1381     *
1382     * @return bool
1383     *
1384     * @since 1.0.0
1385     */
1386    public function hasInlineImage() : bool
1387    {
1388        foreach ($this->attachment as $attachment) {
1389            if ($attachment[6] === 'inline') {
1390                return true;
1391            }
1392        }
1393
1394        return false;
1395    }
1396
1397    /**
1398     * Mail has attachment
1399     *
1400     * @return bool
1401     *
1402     * @since 1.0.0
1403     */
1404    public function hasAttachment() : bool
1405    {
1406        foreach ($this->attachment as $attachment) {
1407            if ($attachment[6] === 'attachment') {
1408                return true;
1409            }
1410        }
1411
1412        return false;
1413    }
1414
1415    /**
1416     * Create address list
1417     *
1418     * @param string $type Address type
1419     * @param array  $addr Addresses
1420     *
1421     * @return string
1422     *
1423     * @since 1.0.0
1424     */
1425    public function createAddressList(string $type, array $addr) : string
1426    {
1427        $addresses = [];
1428        foreach ($addr as $address) {
1429            $addresses[] = $this->addrFormat($address);
1430        }
1431
1432        return $type . ': ' . \implode(', ', $addresses) . static::$LE;
1433    }
1434
1435    /**
1436     * Apply word wrapping
1437     *
1438     * @return void
1439     *
1440     * @return 1.0.0
1441     */
1442    public function setWordWrap() : void
1443    {
1444        if ($this->wordWrap < 1) {
1445            return;
1446        }
1447
1448        switch ($this->messageType) {
1449            case 'alt':
1450            case 'alt_inline':
1451            case 'alt_attach':
1452            case 'alt_inline_attach':
1453                $this->bodyAlt = $this->wrapText($this->bodyAlt, $this->wordWrap);
1454                break;
1455            default:
1456                $this->body = $this->wrapText($this->body, $this->wordWrap);
1457                break;
1458        }
1459    }
1460
1461    /**
1462     * Word-wrap message.
1463     * Original written by philippe.
1464     *
1465     * @param string $message The message to wrap
1466     * @param int    $length  The line length to wrap to
1467     * @param bool   $qpMode  Use Quoted-Printable mode
1468     *
1469     * @return string
1470     *
1471     * @since 1.0.0
1472     */
1473    private function wrapText(string $message, int $length, bool $qpMode = false) : string
1474    {
1475        $softBreak = $qpMode ? \sprintf(' =%s', self::$LE) : self::$LE;
1476
1477        // Don't split multibyte characters
1478        $isUtf8  = \strtolower($this->charset) === CharsetType::UTF_8;
1479        $leLen   = \strlen(self::$LE);
1480        $crlfLen = \strlen(self::$LE);
1481
1482        $message = self::normalizeBreaks($message, self::$LE);
1483
1484        //Remove a trailing line break
1485        if (\substr($message, -$leLen) === self::$LE) {
1486            $message = \substr($message, 0, -$leLen);
1487        }
1488
1489        //Split message into lines
1490        $lines = \explode(self::$LE, $message);
1491
1492        $message = '';
1493        foreach ($lines as $line) {
1494            $words     = \explode(' ', $line);
1495            $buf       = '';
1496            $firstword = true;
1497
1498            foreach ($words as $word) {
1499                if ($qpMode && \strlen($word) > $length) {
1500                    $spaceLeft = $length - \strlen($buf) - $crlfLen;
1501
1502                    if (!$firstword) {
1503                        if ($spaceLeft > 20) {
1504                            $len = $spaceLeft;
1505                            if ($isUtf8) {
1506                                $len = MbStringUtils::utf8CharBoundary($word, $len);
1507                            } elseif (\substr($word, $len - 1, 1) === '=') {
1508                                --$len;
1509                            } elseif (\substr($word, $len - 2, 1) === '=') {
1510                                $len -= 2;
1511                            }
1512
1513                            $part     = \substr($word, 0, $len);
1514                            $word     = \substr($word, $len);
1515                            $buf     .= ' ' . $part;
1516                            $message .= $buf . \sprintf('=%s', self::$LE);
1517                        } else {
1518                            $message .= $buf . $softBreak;
1519                        }
1520
1521                        $buf = '';
1522                    }
1523
1524                    while ($word !== '') {
1525                        if ($length <= 0) {
1526                            break;
1527                        }
1528
1529                        $len = $length;
1530                        if ($isUtf8) {
1531                            $len = MbStringUtils::utf8CharBoundary($word, $len);
1532                        } elseif (\substr($word, $len - 1, 1) === '=') {
1533                            --$len;
1534                        } elseif (\substr($word, $len - 2, 1) === '=') {
1535                            $len -= 2;
1536                        }
1537
1538                        $part = \substr($word, 0, $len);
1539                        $word = (string) \substr($word, $len);
1540
1541                        if ($word !== '') {
1542                            $message .= $part . \sprintf('=%s', self::$LE);
1543                        } else {
1544                            $buf = $part;
1545                        }
1546                    }
1547                } else {
1548                    $bufO = $buf;
1549                    if (!$firstword) {
1550                        $buf .= ' ';
1551                    }
1552
1553                    $buf .= $word;
1554                    if ($bufO !== '' && \strlen($buf) > $length) {
1555                        $message .= $bufO . $softBreak;
1556                        $buf      = $word;
1557                    }
1558                }
1559
1560                $firstword = false;
1561            }
1562
1563            $message .= $buf . self::$LE;
1564        }
1565
1566        return $message;
1567    }
1568
1569    /**
1570     * Encode a header value (not including its label) optimally.
1571     * Picks shortest of Q, B, or none. Result includes folding if needed.
1572     *
1573     * @param string $str      Header value
1574     * @param string $position Context
1575     *
1576     * @return string
1577     *
1578     * @since 1.0.0
1579     */
1580    public function encodeHeader(string $str, string $position = 'text') : string
1581    {
1582        $matchcount = 0;
1583        switch (\strtolower($position)) {
1584            case 'phrase':
1585                if (!\preg_match('/[\200-\377]/', $str)) {
1586                    $encoded = \addcslashes($str, "\0..\37\177\\\"");
1587
1588                    return $str === $encoded && !\preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str)
1589                        ? $encoded
1590                        : '"' . $encoded . '"';
1591                }
1592
1593                $matchcount = \preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches);
1594                break;
1595            /* @noinspection PhpMissingBreakStatementInspection */
1596            case 'comment':
1597                $matchcount = \preg_match_all('/[()"]/', $str, $matches);
1598            case 'text':
1599            default:
1600                $matchcount += \preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches);
1601                break;
1602        }
1603
1604        $charset = ((bool) \preg_match('/[\x80-\xFF]/', $str)) ? $this->charset : CharsetType::ASCII;
1605
1606        // Q/B encoding adds 8 chars and the charset ("` =?<charset>?[QB]?<content>?=`").
1607        $overhead = 8 + \strlen($charset);
1608        $maxLen   = $this->mailer === SubmitType::MAIL
1609            ? self::MAIL_MAX_LINE_LENGTH - $overhead
1610            : self::MAX_LINE_LENGTH - $overhead;
1611
1612        // Select the encoding that produces the shortest output and/or prevents corruption.
1613        if ($matchcount > \strlen($str) / 3) {
1614            // More than 1/3 of the content needs encoding, use B-encode.
1615            $encoding = 'B';
1616        } elseif ($matchcount > 0) {
1617            // Less than 1/3 of the content needs encoding, use Q-encode.
1618            $encoding = 'Q';
1619        } elseif (\strlen($str) > $maxLen) {
1620            // No encoding needed, but value exceeds max line length, use Q-encode to prevent corruption.
1621            $encoding = 'Q';
1622        } else {
1623            // No reformatting needed
1624            $encoding = '';
1625        }
1626
1627        switch ($encoding) {
1628            case 'B':
1629                if (\strlen($str) > \mb_strlen($str, $this->charset)) {
1630                    $encoded = $this->base64EncodeWrapMB($str, "\n");
1631                } else {
1632                    $encoded = \base64_encode($str);
1633                    $maxLen -= $maxLen % 4;
1634                    $encoded = \trim(\chunk_split($encoded, $maxLen, "\n"));
1635                }
1636                $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded);
1637                break;
1638            case 'Q':
1639                $encoded = $this->encodeQ($str, $position);
1640                $encoded = $this->wrapText($encoded, $maxLen, true);
1641                $encoded = \str_replace('=' . self::$LE, "\n", \trim($encoded));
1642                $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded);
1643                break;
1644            default:
1645                return $str;
1646        }
1647
1648        return \trim(self::normalizeBreaks($encoded, self::$LE));
1649    }
1650
1651    /**
1652     * Encode a string using Q encoding.
1653     *
1654     * @param string $str      Text to encode
1655     * @param string $position Where the text is going to be used, see the RFC for what that means
1656     *
1657     * @return string
1658     *
1659     * @since 1.0.0
1660     */
1661    private function encodeQ(string $str, string $position = 'text') : string
1662    {
1663        $pattern = '';
1664        $encoded = \str_replace(["\r", "\n"], '', $str);
1665
1666        switch (\strtolower($position)) {
1667            case 'phrase':
1668                $pattern = '^A-Za-z0-9!*+\/ -';
1669                break;
1670            case 'comment':
1671                $pattern = '\(\)"';
1672            case 'text':
1673            default:
1674                // Replace every high ascii, control, =, ? and _ characters
1675                $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern;
1676                break;
1677        }
1678
1679        if (\preg_match_all("/[{$pattern}]/", $encoded, $matches) !== false) {
1680            return \strtr($encoded, ' ', '_');
1681        }
1682
1683        $matches = [];
1684        // If the string contains an '=', make sure it's the first thing we replace
1685        // so as to avoid double-encoding
1686        $eqkey = \array_search('=', $matches[0], true);
1687        if ($eqkey !== false) {
1688            unset($matches[0][$eqkey]);
1689            \array_unshift($matches[0], '=');
1690        }
1691
1692        $unique = \array_unique($matches[0]);
1693        foreach ($unique as $char) {
1694            $encoded = \str_replace($char, '=' . \sprintf('%02X', \ord($char)), $encoded);
1695        }
1696
1697        // Replace spaces with _ (more readable than =20)
1698        // RFC 2047 section 4.2(2)
1699        return \strtr($encoded, ' ', '_');
1700    }
1701
1702    /**
1703     * Encode and wrap long multibyte strings for mail headers
1704     *
1705     * @param string $str       Multi-byte text to wrap encode
1706     * @param string $linebreak string to use as linefeed/end-of-line
1707     *
1708     * @return string
1709     *
1710     * @since 1.0.0
1711     */
1712    private function base64EncodeWrapMB(string $str, string $linebreak) : string
1713    {
1714        $start   = '=?' . $this->charset . '?B?';
1715        $end     = '?=';
1716        $encoded = '';
1717
1718        $mbLength  = \mb_strlen($str, $this->charset);
1719        $length    = 75 - \strlen($start) - \strlen($end);
1720        $ratio     = $mbLength / \strlen($str);
1721        $avgLength = \floor($length * $ratio * .75);
1722
1723        $offset = 0;
1724        for ($i = 0; $i < $mbLength; $i += $offset) {
1725            $lookBack = 0;
1726
1727            do {
1728                $offset = $avgLength - $lookBack;
1729                $chunk  = \mb_substr($str, $i, $offset, $this->charset);
1730                $chunk  = \base64_encode($chunk);
1731                ++$lookBack;
1732            } while (\strlen($chunk) > $length);
1733
1734            $encoded .= $chunk . $linebreak;
1735        }
1736
1737        return \substr($encoded, 0, -\strlen($linebreak));
1738    }
1739
1740    /**
1741     * Add an attachment from a path on the filesystem.
1742     *
1743     * @param string $path        Path
1744     * @param string $name        Overrides the attachment name
1745     * @param string $encoding    File encoding
1746     * @param string $type        Mime type; determined automatically from $path if not specified
1747     * @param string $disposition Disposition to use
1748     *
1749     * @return bool
1750     *
1751     * @since 1.0.0
1752     */
1753    public function addAttachment(
1754        string $path,
1755        string $name = '',
1756        string $encoding = EncodingType::E_BASE64,
1757        string $type = '',
1758        string $disposition = 'attachment'
1759    ) : bool {
1760        if (!FileUtils::isAccessible($path)) {
1761            return false;
1762        }
1763
1764        // Mime from file
1765        if ($type === '') {
1766            $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION));
1767        }
1768
1769        $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME);
1770        if ($name === '') {
1771            $name = $filename;
1772        }
1773
1774        $this->attachment[] = [
1775            0 => $path,
1776            1 => $filename,
1777            2 => $name,
1778            3 => $encoding,
1779            4 => $type,
1780            5 => false, // isStringAttachment
1781            6 => $disposition,
1782            7 => $name,
1783        ];
1784
1785        return true;
1786    }
1787
1788    /**
1789     * Return the array of attachments.
1790     *
1791     * @return array
1792     */
1793    public function getAttachments()
1794    {
1795        return $this->attachment;
1796    }
1797
1798    /**
1799     * Add a string or binary attachment (non-filesystem).
1800     *
1801     * @param string $string      String attachment data
1802     * @param string $filename    Name of the attachment
1803     * @param string $encoding    File encoding (see $encoding)
1804     * @param string $type        File extension (MIME) type
1805     * @param string $disposition Disposition to use
1806     *
1807     * @return bool
1808     *
1809     * @since 1.0.0
1810     */
1811    public function addStringAttachment(
1812        string $string,
1813        string $filename,
1814        string $encoding = EncodingType::E_BASE64,
1815        string $type = '',
1816        string $disposition = 'attachment'
1817    ) : bool {
1818        // Mime from file
1819        if ($type === '') {
1820            $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION));
1821        }
1822
1823        $this->attachment[] = [
1824            0 => $string,
1825            1 => $filename,
1826            2 => FileUtils::mb_pathinfo($filename, \PATHINFO_BASENAME),
1827            3 => $encoding,
1828            4 => $type,
1829            5 => true, // isStringAttachment
1830            6 => $disposition,
1831            7 => 0,
1832        ];
1833
1834        return true;
1835    }
1836
1837    /**
1838     * Add an embedded (inline) attachment from a file.
1839     * This can include images, sounds, and just about any other document type.
1840     *
1841     * @param string $path        Path to the attachment
1842     * @param string $cid         Content ID of the attachment
1843     * @param string $name        Overrides the attachment name
1844     * @param string $encoding    File encoding (see $encoding)
1845     * @param string $type        File MIME type
1846     * @param string $disposition Disposition to use
1847     *
1848     * @return bool
1849     *
1850     * @since 1.0.0
1851     */
1852    public function addEmbeddedImage(
1853        string $path,
1854        string $cid,
1855        string $name = '',
1856        string $encoding = EncodingType::E_BASE64,
1857        string $type = '',
1858        string $disposition = 'inline'
1859    ) : bool {
1860        if (!FileUtils::isAccessible($path)) {
1861            return false;
1862        }
1863
1864        // Mime from file
1865        if ($type === '') {
1866            $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION));
1867        }
1868
1869        $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME);
1870        if ($name === '') {
1871            $name = $filename;
1872        }
1873
1874        // Append to $attachment array
1875        $this->attachment[] = [
1876            0 => $path,
1877            1 => $filename,
1878            2 => $name,
1879            3 => $encoding,
1880            4 => $type,
1881            5 => false, // isStringAttachment
1882            6 => $disposition,
1883            7 => $cid,
1884        ];
1885
1886        return true;
1887    }
1888
1889    /**
1890     * Add an embedded stringified attachment.
1891     * This can include images, sounds, and just about any other document type.
1892     *
1893     * @param string $string      The attachment binary data
1894     * @param string $cid         Content ID of the attachment
1895     * @param string $name        A filename for the attachment. Should use extension.
1896     * @param string $encoding    File encoding (see $encoding), defaults to 'base64'
1897     * @param string $type        MIME type - will be used in preference to any automatically derived type
1898     * @param string $disposition Disposition to use
1899     *
1900     * @return bool
1901     *
1902     * @since 1.0.0
1903     */
1904    public function addStringEmbeddedImage(
1905        string $string,
1906        string $cid,
1907        string $name = '',
1908        string $encoding = EncodingType::E_BASE64,
1909        string $type = '',
1910        string $disposition = 'inline'
1911    ) : bool {
1912        // Mime from file
1913        if ($type === '' && !empty($name)) {
1914            $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($name, \PATHINFO_EXTENSION));
1915        }
1916
1917        // Append to $attachment array
1918        $this->attachment[] = [
1919            0 => $string,
1920            1 => $name,
1921            2 => $name,
1922            3 => $encoding,
1923            4 => $type,
1924            5 => true, // isStringAttachment
1925            6 => $disposition,
1926            7 => $cid,
1927        ];
1928
1929        return true;
1930    }
1931
1932    /**
1933     * Check if an embedded attachment is present with this cid.
1934     *
1935     * @param string $cid Cid
1936     *
1937     * @return bool
1938     *
1939     * @since 1.0.0
1940     */
1941    protected function cidExists(string $cid) : bool
1942    {
1943        foreach ($this->attachment as $attachment) {
1944            if ($attachment[6] === 'inline' && $cid === $attachment[7]) {
1945                return true;
1946            }
1947        }
1948
1949        return false;
1950    }
1951
1952    /**
1953     * Add a custom header.
1954     *
1955     * @param string      $name  Name
1956     * @param null|string $value Value
1957     *
1958     * @return bool
1959     *
1960     * @since 1.0.0
1961     */
1962    public function addCustomHeader(string $name, string $value = null) : bool
1963    {
1964        $name  = \trim($name);
1965        $value = \trim($value);
1966
1967        if (empty($name) || \strpbrk($name . $value, "\r\n") !== false) {
1968            return false;
1969        }
1970
1971        $this->customHeader[] = [$name, $value];
1972
1973        return true;
1974    }
1975
1976    /**
1977     * Returns all custom headers.
1978     *
1979     * @return array
1980     *
1981     * @since 1.0.0
1982     */
1983    public function getCustomHeaders() : array
1984    {
1985        return $this->customHeader;
1986    }
1987
1988    /**
1989     * Create a message body from an HTML string.
1990     *
1991     * $basedir is prepended when handling relative URLs, e.g. <img src="/images/a.png"> and must not be empty
1992     * will look for an image file in $basedir/images/a.png and convert it to inline.
1993     * If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email)
1994     * Converts data-uri images into embedded attachments.
1995     *
1996     * If you don't want to apply these transformations to your HTML, just set Body and AltBody directly.
1997     *
1998     * @param string        $message  HTML message string
1999     * @param string        $basedir  Absolute path to a base directory to prepend to relative paths to images
2000     * @param null|\Closure $advanced Internal or external text to html converter
2001     *
2002     * @return string
2003     *
2004     * @since 1.0.0
2005     */
2006    public function msgHTML(string $message, string $basedir = '', \Closure $advanced = null)
2007    {
2008        \preg_match_all('/(?<!-)(src|background)=["\'](.*)["\']/Ui', $message, $images);
2009
2010        if (isset($images[2])) {
2011            if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') {
2012                $basedir .= '/';
2013            }
2014
2015            foreach ($images[2] as $imgindex => $url) {
2016                // Convert data URIs into embedded images
2017                $match = [];
2018                if (\preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) {
2019                    if (\count($match) === 4 && $match[2] === EncodingType::E_BASE64) {
2020                        $data = \base64_decode($match[3]);
2021                    } elseif ($match[2] === '') {
2022                        $data = \rawurldecode($match[3]);
2023                    } else {
2024                        continue;
2025                    }
2026
2027                    $cid = \substr(\hash('sha256', $data), 0, 32) . '@phpoms.0'; // RFC2392 S 2
2028                    if (!$this->cidExists($cid)) {
2029                        $this->addStringEmbeddedImage($data, $cid, 'embed' . $imgindex, EncodingType::E_BASE64, $match[1]);
2030                    }
2031
2032                    $message = \str_replace($images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message);
2033
2034                    continue;
2035                }
2036
2037                if (!empty($basedir)
2038                    && (\strpos($url, '..') === false)
2039                    && !\str_starts_with($url, 'cid:')
2040                    && !\preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url)
2041                ) {
2042                    $filename  = FileUtils::mb_pathinfo($url, \PATHINFO_BASENAME);
2043                    $directory = \dirname($url);
2044
2045                    if ($directory === '.') {
2046                        $directory = '';
2047                    }
2048
2049                    // RFC2392 S 2
2050                    $cid = \substr(\hash('sha256', $url), 0, 32) . '@phpoms.0';
2051                    if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') {
2052                        $basedir .= '/';
2053                    }
2054
2055                    if (\strlen($directory) > 1 && \substr($directory, -1) !== '/') {
2056                        $directory .= '/';
2057                    }
2058
2059                    if ($this->addEmbeddedImage(
2060                            $basedir . $directory . $filename,
2061                            $cid,
2062                            $filename,
2063                            EncodingType::E_BASE64,
2064                            MimeType::extensionToMime((string) FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION))
2065                        )
2066                    ) {
2067                        $message = \preg_replace(
2068                            '/' . $images[1][$imgindex] . '=["\']' . \preg_quote($url, '/') . '["\']/Ui',
2069                            $images[1][$imgindex] . '="cid:' . $cid . '"',
2070                            $message
2071                        );
2072                    }
2073                }
2074            }
2075        }
2076
2077        $this->contentType = MimeType::M_HTML;
2078        $this->body        = self::normalizeBreaks($message, self::$LE);
2079        $this->bodyAlt     = self::normalizeBreaks($this->html2text($message, $advanced), self::$LE);
2080
2081        if (empty($this->bodyAlt)) {
2082            $this->bodyAlt = 'This is an HTML-only message. To view it, activate HTML in your email application.' . self::$LE;
2083        }
2084
2085        return $this->body;
2086    }
2087
2088    /**
2089     * Normalize line breaks in a string.
2090     *
2091     * @param string $text
2092     * @param string $breaktype What kind of line break to use; defaults to self::$LE
2093     *
2094     * @return string
2095     *
2096     * @since 1.0.0
2097     */
2098    private static function normalizeBreaks(string $text, string $breaktype) : string
2099    {
2100        $text = \str_replace(["\r\n", "\r"], "\n", $text);
2101
2102        if ($breaktype !== "\n") {
2103            $text = \str_replace("\n", $breaktype, $text);
2104        }
2105
2106        return $text;
2107    }
2108
2109    /**
2110     * Convert an HTML string into plain text.
2111     *
2112     * @param string        $html     The HTML text to convert
2113     * @param null|\Closure $advanced Internal or external text to html converter
2114     *
2115     * @return string
2116     *
2117     * @since 1.0.0
2118     */
2119    private function html2text(string $html, \Closure $advanced = null) : string
2120    {
2121        if ($advanced !== null) {
2122            return $advanced($html);
2123        }
2124
2125        return \html_entity_decode(
2126            \trim(\strip_tags(\preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))),
2127            \ENT_QUOTES,
2128            $this->charset
2129        );
2130    }
2131
2132    /**
2133     * Set the public and private key files and password for S/MIME signing.
2134     *
2135     * @param string $certFile       Certification file
2136     * @param string $keyFile        Key file
2137     * @param string $keyPass        Password for private key
2138     * @param string $extracertsFile Optional path to chain certificate
2139     *
2140     * @return void
2141     *
2142     * @since 1.0.0
2143     */
2144    public function sign($certFile, $keyFile, $keyPass, $extracertsFile = '') : void
2145    {
2146        $this->signCertFile       = $certFile;
2147        $this->signKeyFile        = $keyFile;
2148        $this->signKeyPass        = $keyPass;
2149        $this->signExtracertFiles = $extracertsFile;
2150    }
2151
2152    /**
2153     * Quoted-Printable-encode a DKIM header.
2154     *
2155     * @param string $txt Text
2156     *
2157     * @return string
2158     *
2159     * @since 1.0.0
2160     */
2161    public function dkimQP(string $txt) : string
2162    {
2163        $line = '';
2164        $len  = \strlen($txt);
2165
2166        for ($i = 0; $i < $len; ++$i) {
2167            $ord   = \ord($txt[$i]);
2168            $line .= (($ord >= 0x21) && ($ord <= 0x3A)) || $ord === 0x3C || (($ord >= 0x3E) && ($ord <= 0x7E))
2169                ? $txt[$i]
2170                : '=' . \sprintf('%02X', $ord);
2171        }
2172
2173        return $line;
2174    }
2175
2176    /**
2177     * Generate a DKIM signature.
2178     *
2179     * @param string $signHeader
2180     *
2181     * @return string The DKIM signature value
2182     *
2183     * @since 1.0.0
2184     */
2185    public function dkimSign(string $signHeader) : string
2186    {
2187        if (!\defined('PKCS7_TEXT')) {
2188            return '';
2189        }
2190
2191        $privKeyStr = empty($this->dkimPrivateKey)
2192            ? \file_get_contents($this->dkimPrivatePath)
2193            : $this->dkimPrivateKey;
2194
2195        $privKey = $this->dkimPass === ''
2196            ? \openssl_pkey_get_private($privKeyStr)
2197            : \openssl_pkey_get_private($privKeyStr, $this->dkimPass);
2198
2199        return \openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption')
2200            ? \base64_encode($signature)
2201            : '';
2202    }
2203
2204    /**
2205     * Generate a DKIM canonicalization header.
2206     *
2207     * @param string $signHeader Header
2208     *
2209     * @return string
2210     *
2211     * @since 1.0.0
2212     */
2213    public function dkimHeaderC(string $signHeader) : string
2214    {
2215        $signHeader = self::normalizeBreaks($signHeader, "\r\n");
2216        $signHeader = \preg_replace('/\r\n[ \t]+/', ' ', $signHeader);
2217        $lines      = \explode("\r\n", $signHeader);
2218
2219        foreach ($lines as $key => $line) {
2220            if (\strpos($line, ':') === false) {
2221                continue;
2222            }
2223
2224            list($heading, $value) = \explode(':', $line, 2);
2225            $heading               = \strtolower($heading);
2226            $value                 = \preg_replace('/[ \t]+/', ' ', $value);
2227
2228            $lines[$key] = \trim($heading, " \t") . ':' . \trim($value, " \t");
2229        }
2230
2231        return \implode("\r\n", $lines);
2232    }
2233
2234    /**
2235     * Generate a DKIM canonicalization body.
2236     *
2237     * @param string $body Message Body
2238     *
2239     * @return string
2240     *
2241     * @since 1.0.0
2242     */
2243    public function dkimBodyC(string $body) : string
2244    {
2245        if (empty($body)) {
2246            return "\r\n";
2247        }
2248
2249        $body = self::normalizeBreaks($body, "\r\n");
2250
2251        return \rtrim($body, " \r\n\t") . "\r\n";
2252    }
2253
2254    /**
2255     * Create the DKIM header and body in a new message header.
2256     *
2257     * @param string $headersLine Header lines
2258     * @param string $subject     Subject
2259     * @param string $body        Body
2260     *
2261     * @return string
2262     *
2263     * @since 1.0.0
2264     */
2265    public function dkimAdd(string $headersLine, string $subject, string $body) : string
2266    {
2267        $DKIMsignatureType    = 'rsa-sha256';
2268        $DKIMcanonicalization = 'relaxed/simple';
2269        $DKIMquery            = 'dns/txt';
2270        $DKIMtime             = \time();
2271
2272        $autoSignHeaders = [
2273            'from',
2274            'to',
2275            'cc',
2276            'date',
2277            'subject',
2278            'reply-to',
2279            'message-id',
2280            'content-type',
2281            'mime-version',
2282            'x-mailer',
2283        ];
2284
2285        if (\stripos($headersLine, 'Subject') === false) {
2286            $headersLine .= 'Subject: ' . $subject . self::$LE;
2287        }
2288
2289        $headerLines        = \explode(self::$LE, $headersLine);
2290        $currentHeaderLabel = '';
2291        $currentHeaderValue = '';
2292        $parsedHeaders      = [];
2293        $headerLineIndex    = 0;
2294        $headerLineCount    = \count($headerLines);
2295
2296        foreach ($headerLines as $headerLine) {
2297            $matches = [];
2298            if (\preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) {
2299                if ($currentHeaderLabel !== '') {
2300                    $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue];
2301                }
2302
2303                $currentHeaderLabel = $matches[1];
2304                $currentHeaderValue = $matches[2];
2305            } elseif (\preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) {
2306                $currentHeaderValue .= ' ' . $matches[1];
2307            }
2308
2309            ++$headerLineIndex;
2310
2311            if ($headerLineIndex >= $headerLineCount) {
2312                $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue];
2313            }
2314        }
2315
2316        $copiedHeaders     = [];
2317        $headersToSignKeys = [];
2318        $headersToSign     = [];
2319
2320        foreach ($parsedHeaders as $header) {
2321            if (\in_array(\strtolower($header['label']), $autoSignHeaders, true)) {
2322                $headersToSignKeys[] = $header['label'];
2323                $headersToSign[]     = $header['label'] . ': ' . $header['value'];
2324
2325                if ($this->dkimCopyHeader) {
2326                    $copiedHeaders[] = $header['label'] . ':'
2327                        . \str_replace('|', '=7C', $this->dkimQP($header['value']));
2328                }
2329
2330                continue;
2331            }
2332
2333            if (\in_array($header['label'], $this->dkimHeaders, true)) {
2334                foreach ($this->customHeader as $customHeader) {
2335                    if ($customHeader[0] === $header['label']) {
2336                        $headersToSignKeys[] = $header['label'];
2337                        $headersToSign[]     = $header['label'] . ': ' . $header['value'];
2338
2339                        if ($this->dkimCopyHeader) {
2340                            $copiedHeaders[] = $header['label'] . ':'
2341                                . \str_replace('|', '=7C', $this->dkimQP($header['value']));
2342                        }
2343
2344                        continue 2;
2345                    }
2346                }
2347            }
2348        }
2349
2350        $copiedHeaderFields = '';
2351        if ($this->dkimCopyHeader && !empty($copiedHeaders)) {
2352            $copiedHeaderFields = ' z=';
2353            $first              = true;
2354
2355            foreach ($copiedHeaders as $copiedHeader) {
2356                if (!$first) {
2357                    $copiedHeaderFields .= self::$LE . ' |';
2358                }
2359
2360                $copiedHeaderFields .= \strlen($copiedHeader) > self::STD_LINE_LENGTH - 3
2361                    ? \substr(
2362                            \chunk_split($copiedHeader, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS),
2363                            0,
2364                            -\strlen(self::$LE . self::FWS)
2365                        )
2366                    : $copiedHeader;
2367
2368                $first = false;
2369            }
2370
2371            $copiedHeaderFields .= ';' . self::$LE;
2372        }
2373
2374        $headerKeys   = ' h=' . \implode(':', $headersToSignKeys) . ';' . self::$LE;
2375        $headerValues = \implode(self::$LE, $headersToSign);
2376        $body         = $this->dkimBodyC($body);
2377        $DKIMb64      = \base64_encode(\pack('H*', \hash('sha256', $body)));
2378        $ident        = '';
2379
2380        if ($this->dkimIdentity !== '') {
2381            $ident = ' i=' . $this->dkimIdentity . ';' . self::$LE;
2382        }
2383
2384        $dkimSignatureHeader = 'DKIM-Signature: v=1;'
2385            . ' d=' . $this->dkimDomain . ';'
2386            . ' s=' . $this->dkimSelector . ';' . self::$LE
2387            . ' a=' . $DKIMsignatureType . ';'
2388            . ' q=' . $DKIMquery . ';'
2389            . ' t=' . $DKIMtime . ';'
2390            . ' c=' . $DKIMcanonicalization . ';' . self::$LE
2391            . $headerKeys . $ident . $copiedHeaderFields
2392            . ' bh=' . $DKIMb64 . ';' . self::$LE
2393            . ' b=';
2394
2395        $canonicalizedHeaders = $this->dkimHeaderC(
2396            $headerValues . self::$LE . $dkimSignatureHeader
2397        );
2398
2399        $signature = $this->dkimSign($canonicalizedHeaders);
2400        $signature = \trim(\chunk_split($signature, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS));
2401
2402        return self::normalizeBreaks($dkimSignatureHeader . $signature, self::$LE);
2403    }
2404}