• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 12518821104

27 Dec 2024 05:21PM UTC coverage: 84.426% (+0.02%) from 84.404%
12518821104

Pull #9339

github

web-flow
Merge 5caee6ae0 into 6cbbf601b
Pull Request #9339: refactor: enable instanceof and strictBooleans rector set

55 of 60 new or added lines in 34 files covered. (91.67%)

19 existing lines in 3 files now uncovered.

20437 of 24207 relevant lines covered (84.43%)

189.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.87
/system/Email/Email.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Email;
15

16
use CodeIgniter\Events\Events;
17
use CodeIgniter\I18n\Time;
18
use Config\Mimes;
19
use ErrorException;
20

21
/**
22
 * CodeIgniter Email Class
23
 *
24
 * Permits email to be sent using Mail, Sendmail, or SMTP.
25
 *
26
 * @see \CodeIgniter\Email\EmailTest
27
 */
28
class Email
29
{
30
    /**
31
     * Properties from the last successful send.
32
     *
33
     * @var array|null
34
     */
35
    public $archive;
36

37
    /**
38
     * Properties to be added to the next archive.
39
     *
40
     * @var array
41
     */
42
    protected $tmpArchive = [];
43

44
    /**
45
     * @var string
46
     */
47
    public $fromEmail;
48

49
    /**
50
     * @var string
51
     */
52
    public $fromName;
53

54
    /**
55
     * Used as the User-Agent and X-Mailer headers' value.
56
     *
57
     * @var string
58
     */
59
    public $userAgent = 'CodeIgniter';
60

61
    /**
62
     * Path to the Sendmail binary.
63
     *
64
     * @var string
65
     */
66
    public $mailPath = '/usr/sbin/sendmail';
67

68
    /**
69
     * Which method to use for sending e-mails.
70
     *
71
     * @var string 'mail', 'sendmail' or 'smtp'
72
     */
73
    public $protocol = 'mail';
74

75
    /**
76
     * STMP Server Hostname
77
     *
78
     * @var string
79
     */
80
    public $SMTPHost = '';
81

82
    /**
83
     * SMTP Username
84
     *
85
     * @var string
86
     */
87
    public $SMTPUser = '';
88

89
    /**
90
     * SMTP Password
91
     *
92
     * @var string
93
     */
94
    public $SMTPPass = '';
95

96
    /**
97
     * SMTP Server port
98
     *
99
     * @var int
100
     */
101
    public $SMTPPort = 25;
102

103
    /**
104
     * SMTP connection timeout in seconds
105
     *
106
     * @var int
107
     */
108
    public $SMTPTimeout = 5;
109

110
    /**
111
     * SMTP persistent connection
112
     *
113
     * @var bool
114
     */
115
    public $SMTPKeepAlive = false;
116

117
    /**
118
     * SMTP Encryption
119
     *
120
     * @var string '', 'tls' or 'ssl'. 'tls' will issue a STARTTLS command
121
     *             to the server. 'ssl' means implicit SSL. Connection on port
122
     *             465 should set this to ''.
123
     */
124
    public $SMTPCrypto = '';
125

126
    /**
127
     * Whether to apply word-wrapping to the message body.
128
     *
129
     * @var bool
130
     */
131
    public $wordWrap = true;
132

133
    /**
134
     * Number of characters to wrap at.
135
     *
136
     * @see Email::$wordWrap
137
     *
138
     * @var int
139
     */
140
    public $wrapChars = 76;
141

142
    /**
143
     * Message format.
144
     *
145
     * @var string 'text' or 'html'
146
     */
147
    public $mailType = 'text';
148

149
    /**
150
     * Character set (default: utf-8)
151
     *
152
     * @var string
153
     */
154
    public $charset = 'UTF-8';
155

156
    /**
157
     * Alternative message (for HTML messages only)
158
     *
159
     * @var string
160
     */
161
    public $altMessage = '';
162

163
    /**
164
     * Whether to validate e-mail addresses.
165
     *
166
     * @var bool
167
     */
168
    public $validate = true;
169

170
    /**
171
     * X-Priority header value.
172
     *
173
     * @var int 1-5
174
     */
175
    public $priority = 3;
176

177
    /**
178
     * Newline character sequence.
179
     * Use "\r\n" to comply with RFC 822.
180
     *
181
     * @see http://www.ietf.org/rfc/rfc822.txt
182
     *
183
     * @var string "\r\n" or "\n"
184
     */
185
    public $newline = "\r\n";
186

187
    /**
188
     * CRLF character sequence
189
     *
190
     * RFC 2045 specifies that for 'quoted-printable' encoding,
191
     * "\r\n" must be used. However, it appears that some servers
192
     * (even on the receiving end) don't handle it properly and
193
     * switching to "\n", while improper, is the only solution
194
     * that seems to work for all environments.
195
     *
196
     * @see http://www.ietf.org/rfc/rfc822.txt
197
     *
198
     * @var string
199
     */
200
    public $CRLF = "\r\n";
201

202
    /**
203
     * Whether to use Delivery Status Notification.
204
     *
205
     * @var bool
206
     */
207
    public $DSN = false;
208

209
    /**
210
     * Whether to send multipart alternatives.
211
     * Yahoo! doesn't seem to like these.
212
     *
213
     * @var bool
214
     */
215
    public $sendMultipart = true;
216

217
    /**
218
     * Whether to send messages to BCC recipients in batches.
219
     *
220
     * @var bool
221
     */
222
    public $BCCBatchMode = false;
223

224
    /**
225
     * BCC Batch max number size.
226
     *
227
     * @see Email::$BCCBatchMode
228
     *
229
     * @var int|string
230
     */
231
    public $BCCBatchSize = 200;
232

233
    /**
234
     * Subject header
235
     *
236
     * @var string
237
     */
238
    protected $subject = '';
239

240
    /**
241
     * Message body
242
     *
243
     * @var string
244
     */
245
    protected $body = '';
246

247
    /**
248
     * Final message body to be sent.
249
     *
250
     * @var string
251
     */
252
    protected $finalBody = '';
253

254
    /**
255
     * Final headers to send
256
     *
257
     * @var string
258
     */
259
    protected $headerStr = '';
260

261
    /**
262
     * SMTP Connection socket placeholder
263
     *
264
     * @var resource|null
265
     */
266
    protected $SMTPConnect;
267

268
    /**
269
     * Mail encoding
270
     *
271
     * @var string '8bit' or '7bit'
272
     */
273
    protected $encoding = '8bit';
274

275
    /**
276
     * Whether to perform SMTP authentication
277
     *
278
     * @var bool
279
     */
280
    protected $SMTPAuth = false;
281

282
    /**
283
     * Whether to send a Reply-To header
284
     *
285
     * @var bool
286
     */
287
    protected $replyToFlag = false;
288

289
    /**
290
     * Debug messages
291
     *
292
     * @see Email::printDebugger()
293
     *
294
     * @var array
295
     */
296
    protected $debugMessage = [];
297

298
    /**
299
     * Raw debug messages
300
     *
301
     * @var list<string>
302
     */
303
    private array $debugMessageRaw = [];
304

305
    /**
306
     * Recipients
307
     *
308
     * @var array|string
309
     */
310
    protected $recipients = [];
311

312
    /**
313
     * CC Recipients
314
     *
315
     * @var array
316
     */
317
    protected $CCArray = [];
318

319
    /**
320
     * BCC Recipients
321
     *
322
     * @var array
323
     */
324
    protected $BCCArray = [];
325

326
    /**
327
     * Message headers
328
     *
329
     * @var array
330
     */
331
    protected $headers = [];
332

333
    /**
334
     * Attachment data
335
     *
336
     * @var array
337
     */
338
    protected $attachments = [];
339

340
    /**
341
     * Valid $protocol values
342
     *
343
     * @see Email::$protocol
344
     *
345
     * @var array
346
     */
347
    protected $protocols = [
348
        'mail',
349
        'sendmail',
350
        'smtp',
351
    ];
352

353
    /**
354
     * Character sets valid for 7-bit encoding,
355
     * excluding language suffix.
356
     *
357
     * @var list<string>
358
     */
359
    protected $baseCharsets = [
360
        'us-ascii',
361
        'iso-2022-',
362
    ];
363

364
    /**
365
     * Bit depths
366
     *
367
     * Valid mail encodings
368
     *
369
     * @see Email::$encoding
370
     *
371
     * @var array
372
     */
373
    protected $bitDepths = [
374
        '7bit',
375
        '8bit',
376
    ];
377

378
    /**
379
     * $priority translations
380
     *
381
     * Actual values to send with the X-Priority header
382
     *
383
     * @var array
384
     */
385
    protected $priorities = [
386
        1 => '1 (Highest)',
387
        2 => '2 (High)',
388
        3 => '3 (Normal)',
389
        4 => '4 (Low)',
390
        5 => '5 (Lowest)',
391
    ];
392

393
    /**
394
     * mbstring.func_overload flag
395
     *
396
     * @var bool
397
     */
398
    protected static $func_overload;
399

400
    /**
401
     * @param array|\Config\Email|null $config
402
     */
403
    public function __construct($config = null)
404
    {
405
        $this->initialize($config);
6,440✔
406
        if (! isset(static::$func_overload)) {
6,440✔
407
            static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload'));
242✔
408
        }
409
    }
410

411
    /**
412
     * Initialize preferences
413
     *
414
     * @param array|\Config\Email|null $config
415
     *
416
     * @return Email
417
     */
418
    public function initialize($config)
419
    {
420
        $this->clear();
6,440✔
421

422
        if ($config instanceof \Config\Email) {
6,440✔
423
            $config = get_object_vars($config);
6,440✔
424
        }
425

426
        foreach (array_keys(get_class_vars(static::class)) as $key) {
6,440✔
427
            if (property_exists($this, $key) && isset($config[$key])) {
6,440✔
428
                $method = 'set' . ucfirst($key);
6,440✔
429

430
                if (method_exists($this, $method)) {
6,440✔
431
                    $this->{$method}($config[$key]);
6,440✔
432
                } else {
433
                    $this->{$key} = $config[$key];
6,440✔
434
                }
435
            }
436
        }
437

438
        $this->charset  = strtoupper($this->charset);
6,440✔
439
        $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]);
6,440✔
440

441
        return $this;
6,440✔
442
    }
443

444
    /**
445
     * @param bool $clearAttachments
446
     *
447
     * @return Email
448
     */
449
    public function clear($clearAttachments = false)
450
    {
451
        $this->subject         = '';
6,440✔
452
        $this->body            = '';
6,440✔
453
        $this->finalBody       = '';
6,440✔
454
        $this->headerStr       = '';
6,440✔
455
        $this->replyToFlag     = false;
6,440✔
456
        $this->recipients      = [];
6,440✔
457
        $this->CCArray         = [];
6,440✔
458
        $this->BCCArray        = [];
6,440✔
459
        $this->headers         = [];
6,440✔
460
        $this->debugMessage    = [];
6,440✔
461
        $this->debugMessageRaw = [];
6,440✔
462

463
        $this->setHeader('Date', $this->setDate());
6,440✔
464

465
        if ($clearAttachments !== false) {
6,440✔
466
            $this->attachments = [];
×
467
        }
468

469
        return $this;
6,440✔
470
    }
471

472
    /**
473
     * @param string      $from
474
     * @param string      $name
475
     * @param string|null $returnPath Return-Path
476
     *
477
     * @return Email
478
     */
479
    public function setFrom($from, $name = '', $returnPath = null)
480
    {
481
        if (preg_match('/\<(.*)\>/', $from, $match)) {
4✔
482
            $from = $match[1];
×
483
        }
484

485
        if ($this->validate) {
4✔
486
            $this->validateEmail($this->stringToArray($from));
3✔
487

488
            if ($returnPath) {
3✔
489
                $this->validateEmail($this->stringToArray($returnPath));
×
490
            }
491
        }
492

493
        $this->tmpArchive['fromEmail'] = $from;
4✔
494
        $this->tmpArchive['fromName']  = $name;
4✔
495

496
        if ($name !== '') {
4✔
497
            // only use Q encoding if there are characters that would require it
498
            if (preg_match('/[\200-\377]/', $name) !== 1) {
2✔
499
                $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"';
2✔
500
            } else {
501
                $name = $this->prepQEncoding($name);
×
502
            }
503
        }
504

505
        $this->setHeader('From', $name . ' <' . $from . '>');
4✔
506
        if (! isset($returnPath)) {
4✔
507
            $returnPath = $from;
4✔
508
        }
509
        $this->setHeader('Return-Path', '<' . $returnPath . '>');
4✔
510
        $this->tmpArchive['returnPath'] = $returnPath;
4✔
511

512
        return $this;
4✔
513
    }
514

515
    /**
516
     * @param string $replyto
517
     * @param string $name
518
     *
519
     * @return Email
520
     */
521
    public function setReplyTo($replyto, $name = '')
522
    {
523
        if (preg_match('/\<(.*)\>/', $replyto, $match)) {
×
524
            $replyto = $match[1];
×
525
        }
526

527
        if ($this->validate) {
×
528
            $this->validateEmail($this->stringToArray($replyto));
×
529
        }
530

531
        if ($name !== '') {
×
532
            $this->tmpArchive['replyName'] = $name;
×
533

534
            // only use Q encoding if there are characters that would require it
NEW
535
            if (preg_match('/[\200-\377]/', $name) !== 1) {
×
536
                $name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"';
×
537
            } else {
538
                $name = $this->prepQEncoding($name);
×
539
            }
540
        }
541

542
        $this->setHeader('Reply-To', $name . ' <' . $replyto . '>');
×
543
        $this->replyToFlag           = true;
×
544
        $this->tmpArchive['replyTo'] = $replyto;
×
545

546
        return $this;
×
547
    }
548

549
    /**
550
     * @param array|string $to
551
     *
552
     * @return Email
553
     */
554
    public function setTo($to)
555
    {
556
        $to = $this->stringToArray($to);
10✔
557
        $to = $this->cleanEmail($to);
10✔
558

559
        if ($this->validate) {
10✔
560
            $this->validateEmail($to);
9✔
561
        }
562

563
        if ($this->getProtocol() !== 'mail') {
10✔
564
            $this->setHeader('To', implode(', ', $to));
×
565
        }
566

567
        $this->recipients = $to;
10✔
568

569
        return $this;
10✔
570
    }
571

572
    /**
573
     * @param string $cc
574
     *
575
     * @return Email
576
     */
577
    public function setCC($cc)
578
    {
579
        $cc = $this->cleanEmail($this->stringToArray($cc));
×
580

581
        if ($this->validate) {
×
582
            $this->validateEmail($cc);
×
583
        }
584

585
        $this->setHeader('Cc', implode(', ', $cc));
×
586

587
        if ($this->getProtocol() === 'smtp') {
×
588
            $this->CCArray = $cc;
×
589
        }
590

591
        $this->tmpArchive['CCArray'] = $cc;
×
592

593
        return $this;
×
594
    }
595

596
    /**
597
     * @param string $bcc
598
     * @param string $limit
599
     *
600
     * @return Email
601
     */
602
    public function setBCC($bcc, $limit = '')
603
    {
604
        if ($limit !== '' && is_numeric($limit)) {
×
605
            $this->BCCBatchMode = true;
×
606
            $this->BCCBatchSize = $limit;
×
607
        }
608

609
        $bcc = $this->cleanEmail($this->stringToArray($bcc));
×
610

611
        if ($this->validate) {
×
612
            $this->validateEmail($bcc);
×
613
        }
614

615
        if ($this->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) {
×
616
            $this->BCCArray = $bcc;
×
617
        } else {
618
            $this->setHeader('Bcc', implode(', ', $bcc));
×
619
            $this->tmpArchive['BCCArray'] = $bcc;
×
620
        }
621

622
        return $this;
×
623
    }
624

625
    /**
626
     * @param string $subject
627
     *
628
     * @return Email
629
     */
630
    public function setSubject($subject)
631
    {
632
        $this->tmpArchive['subject'] = $subject;
2✔
633

634
        $subject = $this->prepQEncoding($subject);
2✔
635
        $this->setHeader('Subject', $subject);
2✔
636

637
        return $this;
2✔
638
    }
639

640
    /**
641
     * @param string $body
642
     *
643
     * @return Email
644
     */
645
    public function setMessage($body)
646
    {
647
        $this->body = rtrim(str_replace("\r", '', $body));
2✔
648

649
        return $this;
2✔
650
    }
651

652
    /**
653
     * @param string      $file        Can be local path, URL or buffered content
654
     * @param string      $disposition 'attachment'
655
     * @param string|null $newname
656
     * @param string      $mime
657
     *
658
     * @return bool|Email
659
     */
660
    public function attach($file, $disposition = '', $newname = null, $mime = '')
661
    {
662
        if ($mime === '') {
2✔
663
            if (! str_contains($file, '://') && ! is_file($file)) {
1✔
664
                $this->setErrorMessage(lang('Email.attachmentMissing', [$file]));
×
665

666
                return false;
×
667
            }
668

669
            if (! $fp = @fopen($file, 'rb')) {
1✔
670
                $this->setErrorMessage(lang('Email.attachmentUnreadable', [$file]));
×
671

672
                return false;
×
673
            }
674

675
            $fileContent = stream_get_contents($fp);
1✔
676

677
            $mime = $this->mimeTypes(pathinfo($file, PATHINFO_EXTENSION));
1✔
678

679
            fclose($fp);
1✔
680
        } else {
681
            $fileContent = &$file; // buffered file
1✔
682
        }
683

684
        // declare names on their own, to make phpcbf happy
685
        $namesAttached = [$file, $newname];
2✔
686

687
        $this->attachments[] = [
2✔
688
            'name'        => $namesAttached,
2✔
689
            'disposition' => empty($disposition) ? 'attachment' : $disposition,
2✔
690
            // Can also be 'inline'  Not sure if it matters
691
            'type'      => $mime,
2✔
692
            'content'   => chunk_split(base64_encode($fileContent)),
2✔
693
            'multipart' => 'mixed',
2✔
694
        ];
2✔
695

696
        return $this;
2✔
697
    }
698

699
    /**
700
     * Set and return attachment Content-ID
701
     * Useful for attached inline pictures
702
     *
703
     * @param string $filename
704
     *
705
     * @return bool|string
706
     */
707
    public function setAttachmentCID($filename)
708
    {
709
        foreach ($this->attachments as $i => $attachment) {
2✔
710
            // For file path.
711
            if ($attachment['name'][0] === $filename) {
2✔
712
                $this->attachments[$i]['multipart'] = 'related';
1✔
713

714
                $this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][0]) . '@', true);
1✔
715

716
                return $this->attachments[$i]['cid'];
1✔
717
            }
718

719
            // For buffer string.
720
            if ($attachment['name'][1] === $filename) {
1✔
721
                $this->attachments[$i]['multipart'] = 'related';
1✔
722

723
                $this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][1]) . '@', true);
1✔
724

725
                return $this->attachments[$i]['cid'];
1✔
726
            }
727
        }
728

729
        return false;
×
730
    }
731

732
    /**
733
     * @param string $header
734
     * @param string $value
735
     *
736
     * @return Email
737
     */
738
    public function setHeader($header, $value)
739
    {
740
        $this->headers[$header] = str_replace(["\n", "\r"], '', $value);
6,440✔
741

742
        return $this;
6,440✔
743
    }
744

745
    /**
746
     * @param array|string $email
747
     *
748
     * @return array
749
     */
750
    protected function stringToArray($email)
751
    {
752
        if (! is_array($email)) {
10✔
753
            return (str_contains($email, ',')) ? preg_split('/[\s,]/', $email, -1, PREG_SPLIT_NO_EMPTY) : (array) trim($email);
10✔
754
        }
755

756
        return $email;
×
757
    }
758

759
    /**
760
     * @param string $str
761
     *
762
     * @return Email
763
     */
764
    public function setAltMessage($str)
765
    {
766
        $this->altMessage = (string) $str;
×
767

768
        return $this;
×
769
    }
770

771
    /**
772
     * @param string $type
773
     *
774
     * @return Email
775
     */
776
    public function setMailType($type = 'text')
777
    {
778
        $this->mailType = ($type === 'html') ? 'html' : 'text';
6,440✔
779

780
        return $this;
6,440✔
781
    }
782

783
    /**
784
     * @param bool $wordWrap
785
     *
786
     * @return Email
787
     */
788
    public function setWordWrap($wordWrap = true)
789
    {
790
        $this->wordWrap = (bool) $wordWrap;
6,440✔
791

792
        return $this;
6,440✔
793
    }
794

795
    /**
796
     * @param string $protocol
797
     *
798
     * @return Email
799
     */
800
    public function setProtocol($protocol = 'mail')
801
    {
802
        $this->protocol = in_array($protocol, $this->protocols, true) ? strtolower($protocol) : 'mail';
6,440✔
803

804
        return $this;
6,440✔
805
    }
806

807
    /**
808
     * @param int $n
809
     *
810
     * @return Email
811
     */
812
    public function setPriority($n = 3)
813
    {
814
        $this->priority = preg_match('/^[1-5]$/', (string) $n) ? (int) $n : 3;
6,440✔
815

816
        return $this;
6,440✔
817
    }
818

819
    /**
820
     * @param string $newline
821
     *
822
     * @return Email
823
     */
824
    public function setNewline($newline = "\n")
825
    {
826
        $this->newline = in_array($newline, ["\n", "\r\n", "\r"], true) ? $newline : "\n";
6,440✔
827

828
        return $this;
6,440✔
829
    }
830

831
    /**
832
     * @param string $CRLF
833
     *
834
     * @return Email
835
     */
836
    public function setCRLF($CRLF = "\n")
837
    {
838
        $this->CRLF = ($CRLF !== "\n" && $CRLF !== "\r\n" && $CRLF !== "\r") ? "\n" : $CRLF;
6,440✔
839

840
        return $this;
6,440✔
841
    }
842

843
    /**
844
     * @return string
845
     */
846
    protected function getMessageID()
847
    {
848
        $from = str_replace(['>', '<'], '', $this->headers['Return-Path']);
×
849

850
        return '<' . uniqid('', true) . strstr($from, '@') . '>';
×
851
    }
852

853
    /**
854
     * @return string
855
     */
856
    protected function getProtocol()
857
    {
858
        $this->protocol = strtolower($this->protocol);
11✔
859

860
        if (! in_array($this->protocol, $this->protocols, true)) {
11✔
861
            $this->protocol = 'mail';
×
862
        }
863

864
        return $this->protocol;
11✔
865
    }
866

867
    /**
868
     * @return string
869
     */
870
    protected function getEncoding()
871
    {
872
        if (! in_array($this->encoding, $this->bitDepths, true)) {
×
873
            $this->encoding = '8bit';
×
874
        }
875

876
        foreach ($this->baseCharsets as $charset) {
×
877
            if (str_starts_with($this->charset, $charset)) {
×
878
                $this->encoding = '7bit';
×
879

880
                break;
×
881
            }
882
        }
883

884
        return $this->encoding;
×
885
    }
886

887
    /**
888
     * @return string
889
     */
890
    protected function getContentType()
891
    {
892
        if ($this->mailType === 'html') {
×
893
            return empty($this->attachments) ? 'html' : 'html-attach';
×
894
        }
895

896
        if ($this->mailType === 'text' && ! empty($this->attachments)) {
×
897
            return 'plain-attach';
×
898
        }
899

900
        return 'plain';
×
901
    }
902

903
    /**
904
     * Set RFC 822 Date
905
     *
906
     * @return string
907
     */
908
    protected function setDate()
909
    {
910
        $timezone = date('Z');
6,440✔
911
        $operator = ($timezone[0] === '-') ? '-' : '+';
6,440✔
912
        $timezone = abs((int) $timezone);
6,440✔
913
        $timezone = floor($timezone / 3600) * 100 + ($timezone % 3600) / 60;
6,440✔
914

915
        return sprintf('%s %s%04d', date('D, j M Y H:i:s'), $operator, $timezone);
6,440✔
916
    }
917

918
    /**
919
     * @return string
920
     */
921
    protected function getMimeMessage()
922
    {
923
        return 'This is a multi-part message in MIME format.' . $this->newline . 'Your email application may not support this format.';
×
924
    }
925

926
    /**
927
     * @param array|string $email
928
     *
929
     * @return bool
930
     */
931
    public function validateEmail($email)
932
    {
933
        if (! is_array($email)) {
9✔
934
            $this->setErrorMessage(lang('Email.mustBeArray'));
×
935

936
            return false;
×
937
        }
938

939
        foreach ($email as $val) {
9✔
940
            if (! $this->isValidEmail($val)) {
9✔
941
                $this->setErrorMessage(lang('Email.invalidAddress', [$val]));
1✔
942

943
                return false;
1✔
944
            }
945
        }
946

947
        return true;
8✔
948
    }
949

950
    /**
951
     * @param string $email
952
     *
953
     * @return bool
954
     */
955
    public function isValidEmail($email)
956
    {
957
        if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) {
9✔
958
            $email = static::substr($email, 0, ++$atpos)
8✔
959
                . idn_to_ascii(static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46);
8✔
960
        }
961

962
        return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
9✔
963
    }
964

965
    /**
966
     * @param array|string $email
967
     *
968
     * @return array|string
969
     */
970
    public function cleanEmail($email)
971
    {
972
        if (! is_array($email)) {
10✔
973
            return preg_match('/\<(.*)\>/', $email, $match) ? $match[1] : $email;
×
974
        }
975

976
        $cleanEmail = [];
10✔
977

978
        foreach ($email as $addy) {
10✔
979
            $cleanEmail[] = preg_match('/\<(.*)\>/', $addy, $match) ? $match[1] : $addy;
10✔
980
        }
981

982
        return $cleanEmail;
10✔
983
    }
984

985
    /**
986
     * Build alternative plain text message
987
     *
988
     * Provides the raw message for use in plain-text headers of
989
     * HTML-formatted emails.
990
     *
991
     * If the user hasn't specified his own alternative message
992
     * it creates one by stripping the HTML
993
     *
994
     * @return string
995
     */
996
    protected function getAltMessage()
997
    {
998
        if (! empty($this->altMessage)) {
×
999
            return ($this->wordWrap) ? $this->wordWrap($this->altMessage, 76) : $this->altMessage;
×
1000
        }
1001

1002
        $body = preg_match('/\<body.*?\>(.*)\<\/body\>/si', $this->body, $match) ? $match[1] : $this->body;
×
1003
        $body = str_replace("\t", '', preg_replace('#<!--(.*)--\>#', '', trim(strip_tags($body))));
×
1004

1005
        for ($i = 20; $i >= 3; $i--) {
×
1006
            $body = str_replace(str_repeat("\n", $i), "\n\n", $body);
×
1007
        }
1008

1009
        $body = preg_replace('| +|', ' ', $body);
×
1010

1011
        return ($this->wordWrap) ? $this->wordWrap($body, 76) : $body;
×
1012
    }
1013

1014
    /**
1015
     * @param string   $str
1016
     * @param int|null $charlim Line-length limit
1017
     *
1018
     * @return string
1019
     */
1020
    public function wordWrap($str, $charlim = null)
1021
    {
1022
        if (empty($charlim)) {
×
1023
            $charlim = empty($this->wrapChars) ? 76 : $this->wrapChars;
×
1024
        }
1025

1026
        if (str_contains($str, "\r")) {
×
1027
            $str = str_replace(["\r\n", "\r"], "\n", $str);
×
1028
        }
1029

1030
        $str = preg_replace('| +\n|', "\n", $str);
×
1031

1032
        $unwrap = [];
×
1033

1034
        if (preg_match_all('|\{unwrap\}(.+?)\{/unwrap\}|s', $str, $matches)) {
×
1035
            for ($i = 0, $c = count($matches[0]); $i < $c; $i++) {
×
1036
                $unwrap[] = $matches[1][$i];
×
1037
                $str      = str_replace($matches[0][$i], '{{unwrapped' . $i . '}}', $str);
×
1038
            }
1039
        }
1040

1041
        // Use PHP's native function to do the initial wordwrap.
1042
        // We set the cut flag to FALSE so that any individual words that are
1043
        // too long get left alone. In the next step we'll deal with them.
1044
        $str = wordwrap($str, $charlim, "\n", false);
×
1045

1046
        // Split the string into individual lines of text and cycle through them
1047
        $output = '';
×
1048

1049
        foreach (explode("\n", $str) as $line) {
×
1050
            if (static::strlen($line) <= $charlim) {
×
1051
                $output .= $line . $this->newline;
×
1052

1053
                continue;
×
1054
            }
1055

1056
            $temp = '';
×
1057

1058
            do {
1059
                if (preg_match('!\[url.+\]|://|www\.!', $line)) {
×
1060
                    break;
×
1061
                }
1062

1063
                $temp .= static::substr($line, 0, $charlim - 1);
×
1064
                $line = static::substr($line, $charlim - 1);
×
1065
            } while (static::strlen($line) > $charlim);
×
1066

1067
            if ($temp !== '') {
×
1068
                $output .= $temp . $this->newline;
×
1069
            }
1070

1071
            $output .= $line . $this->newline;
×
1072
        }
1073

1074
        foreach ($unwrap as $key => $val) {
×
1075
            $output = str_replace('{{unwrapped' . $key . '}}', $val, $output);
×
1076
        }
1077

1078
        return $output;
×
1079
    }
1080

1081
    /**
1082
     * Build final headers
1083
     *
1084
     * @return void
1085
     */
1086
    protected function buildHeaders()
1087
    {
1088
        $this->setHeader('User-Agent', $this->userAgent);
×
1089
        $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From']));
×
1090
        $this->setHeader('X-Mailer', $this->userAgent);
×
1091
        $this->setHeader('X-Priority', $this->priorities[$this->priority]);
×
1092
        $this->setHeader('Message-ID', $this->getMessageID());
×
1093
        $this->setHeader('Mime-Version', '1.0');
×
1094
    }
1095

1096
    /**
1097
     * Write Headers as a string
1098
     *
1099
     * @return void
1100
     */
1101
    protected function writeHeaders()
1102
    {
1103
        if ($this->protocol === 'mail' && isset($this->headers['Subject'])) {
×
1104
            $this->subject = $this->headers['Subject'];
×
1105
            unset($this->headers['Subject']);
×
1106
        }
1107

1108
        reset($this->headers);
×
1109
        $this->headerStr = '';
×
1110

1111
        foreach ($this->headers as $key => $val) {
×
1112
            $val = trim($val);
×
1113

1114
            if ($val !== '') {
×
1115
                $this->headerStr .= $key . ': ' . $val . $this->newline;
×
1116
            }
1117
        }
1118

1119
        if ($this->getProtocol() === 'mail') {
×
1120
            $this->headerStr = rtrim($this->headerStr);
×
1121
        }
1122
    }
1123

1124
    /**
1125
     * Build Final Body and attachments
1126
     *
1127
     * @return void
1128
     */
1129
    protected function buildMessage()
1130
    {
1131
        if ($this->wordWrap === true && $this->mailType !== 'html') {
×
1132
            $this->body = $this->wordWrap($this->body);
×
1133
        }
1134

1135
        $this->writeHeaders();
×
1136
        $hdr  = ($this->getProtocol() === 'mail') ? $this->newline : '';
×
1137
        $body = '';
×
1138

1139
        switch ($this->getContentType()) {
×
1140
            case 'plain':
×
1141
                $hdr .= 'Content-Type: text/plain; charset='
×
1142
                    . $this->charset
×
1143
                    . $this->newline
×
1144
                    . 'Content-Transfer-Encoding: '
×
1145
                    . $this->getEncoding();
×
1146

1147
                if ($this->getProtocol() === 'mail') {
×
1148
                    $this->headerStr .= $hdr;
×
1149
                    $this->finalBody = $this->body;
×
1150
                } else {
1151
                    $this->finalBody = $hdr . $this->newline . $this->newline . $this->body;
×
1152
                }
1153

1154
                return;
×
1155

1156
            case 'html':
×
1157
                $boundary = uniqid('B_ALT_', true);
×
1158

1159
                if ($this->sendMultipart === false) {
×
1160
                    $hdr .= 'Content-Type: text/html; charset='
×
1161
                        . $this->charset . $this->newline
×
1162
                        . 'Content-Transfer-Encoding: quoted-printable';
×
1163
                } else {
1164
                    $hdr  .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
×
1165
                    $body .= $this->getMimeMessage() . $this->newline . $this->newline
×
1166
                        . '--' . $boundary . $this->newline
×
1167
                        . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline
×
1168
                        . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline
×
1169
                        . $this->getAltMessage() . $this->newline . $this->newline
×
1170
                        . '--' . $boundary . $this->newline
×
1171
                        . 'Content-Type: text/html; charset=' . $this->charset . $this->newline
×
1172
                        . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline;
×
1173
                }
1174

1175
                $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline;
×
1176

1177
                if ($this->getProtocol() === 'mail') {
×
1178
                    $this->headerStr .= $hdr;
×
1179
                } else {
1180
                    $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody;
×
1181
                }
1182

1183
                if ($this->sendMultipart !== false) {
×
1184
                    $this->finalBody .= '--' . $boundary . '--';
×
1185
                }
1186

1187
                return;
×
1188

1189
            case 'plain-attach':
×
1190
                $boundary = uniqid('B_ATC_', true);
×
1191
                $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
×
1192

1193
                if ($this->getProtocol() === 'mail') {
×
1194
                    $this->headerStr .= $hdr;
×
1195
                }
1196

1197
                $body .= $this->getMimeMessage() . $this->newline
×
1198
                    . $this->newline
×
1199
                    . '--' . $boundary . $this->newline
×
1200
                    . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline
×
1201
                    . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline
×
1202
                    . $this->newline
×
1203
                    . $this->body . $this->newline . $this->newline;
×
1204

1205
                $this->appendAttachments($body, $boundary);
×
1206
                break;
×
1207

1208
            case 'html-attach':
×
1209
                $altBoundary  = uniqid('B_ALT_', true);
×
1210
                $lastBoundary = null;
×
1211

1212
                if ($this->attachmentsHaveMultipart('mixed')) {
×
1213
                    $atcBoundary = uniqid('B_ATC_', true);
×
1214
                    $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atcBoundary . '"';
×
1215
                    $lastBoundary = $atcBoundary;
×
1216
                }
1217

1218
                if ($this->attachmentsHaveMultipart('related')) {
×
1219
                    $relBoundary = uniqid('B_REL_', true);
×
1220

1221
                    $relBoundaryHeader = 'Content-Type: multipart/related; boundary="' . $relBoundary . '"';
×
1222

1223
                    if (isset($lastBoundary)) {
×
1224
                        $body .= '--' . $lastBoundary . $this->newline . $relBoundaryHeader;
×
1225
                    } else {
1226
                        $hdr .= $relBoundaryHeader;
×
1227
                    }
1228

1229
                    $lastBoundary = $relBoundary;
×
1230
                }
1231

1232
                if ($this->getProtocol() === 'mail') {
×
1233
                    $this->headerStr .= $hdr;
×
1234
                }
1235

1236
                static::strlen($body) && $body .= $this->newline . $this->newline;
×
1237

1238
                $body .= $this->getMimeMessage() . $this->newline . $this->newline
×
1239
                    . '--' . $lastBoundary . $this->newline
×
1240
                    . 'Content-Type: multipart/alternative; boundary="' . $altBoundary . '"' . $this->newline . $this->newline
×
1241
                    . '--' . $altBoundary . $this->newline
×
1242
                    . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline
×
1243
                    . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline
×
1244
                    . $this->getAltMessage() . $this->newline . $this->newline
×
1245
                    . '--' . $altBoundary . $this->newline
×
1246
                    . 'Content-Type: text/html; charset=' . $this->charset . $this->newline
×
1247
                    . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline
×
1248
                    . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline
×
1249
                    . '--' . $altBoundary . '--' . $this->newline . $this->newline;
×
1250

1251
                if (isset($relBoundary)) {
×
1252
                    $body .= $this->newline . $this->newline;
×
1253
                    $this->appendAttachments($body, $relBoundary, 'related');
×
1254
                }
1255

1256
                // multipart/mixed attachments
1257
                if (isset($atcBoundary)) {
×
1258
                    $body .= $this->newline . $this->newline;
×
1259
                    $this->appendAttachments($body, $atcBoundary, 'mixed');
×
1260
                }
1261

1262
                break;
×
1263
        }
1264

1265
        $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body;
×
1266
    }
1267

1268
    /**
1269
     * @param mixed $type
1270
     *
1271
     * @return bool
1272
     */
1273
    protected function attachmentsHaveMultipart($type)
1274
    {
1275
        foreach ($this->attachments as &$attachment) {
×
1276
            if ($attachment['multipart'] === $type) {
×
1277
                return true;
×
1278
            }
1279
        }
1280

1281
        return false;
×
1282
    }
1283

1284
    /**
1285
     * @param string      $body      Message body to append to
1286
     * @param string      $boundary  Multipart boundary
1287
     * @param string|null $multipart When provided, only attachments of this type will be processed
1288
     *
1289
     * @return void
1290
     */
1291
    protected function appendAttachments(&$body, $boundary, $multipart = null)
1292
    {
1293
        foreach ($this->attachments as $attachment) {
×
1294
            if (isset($multipart) && $attachment['multipart'] !== $multipart) {
×
1295
                continue;
×
1296
            }
1297

1298
            $name = $attachment['name'][1] ?? basename($attachment['name'][0]);
×
1299
            $body .= '--' . $boundary . $this->newline
×
1300
                . 'Content-Type: ' . $attachment['type'] . '; name="' . $name . '"' . $this->newline
×
1301
                . 'Content-Disposition: ' . $attachment['disposition'] . ';' . $this->newline
×
1302
                . 'Content-Transfer-Encoding: base64' . $this->newline
×
1303
                . (empty($attachment['cid']) ? '' : 'Content-ID: <' . $attachment['cid'] . '>' . $this->newline)
×
1304
                . $this->newline
×
1305
                . $attachment['content'] . $this->newline;
×
1306
        }
1307

1308
        // $name won't be set if no attachments were appended,
1309
        // and therefore a boundary wouldn't be necessary
1310
        if (! empty($name)) {
×
1311
            $body .= '--' . $boundary . '--';
×
1312
        }
1313
    }
1314

1315
    /**
1316
     * Prepares string for Quoted-Printable Content-Transfer-Encoding
1317
     * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt
1318
     *
1319
     * @param string $str
1320
     *
1321
     * @return string
1322
     */
1323
    protected function prepQuotedPrintable($str)
1324
    {
1325
        // ASCII code numbers for "safe" characters that can always be
1326
        // used literally, without encoding, as described in RFC 2049.
1327
        // http://www.ietf.org/rfc/rfc2049.txt
1328
        static $asciiSafeChars = [
×
1329
            // ' (  )   +   ,   -   .   /   :   =   ?
1330
            39,
×
1331
            40,
×
1332
            41,
×
1333
            43,
×
1334
            44,
×
1335
            45,
×
1336
            46,
×
1337
            47,
×
1338
            58,
×
1339
            61,
×
1340
            63,
×
1341
            // numbers
1342
            48,
×
1343
            49,
×
1344
            50,
×
1345
            51,
×
1346
            52,
×
1347
            53,
×
1348
            54,
×
1349
            55,
×
1350
            56,
×
1351
            57,
×
1352
            // upper-case letters
1353
            65,
×
1354
            66,
×
1355
            67,
×
1356
            68,
×
1357
            69,
×
1358
            70,
×
1359
            71,
×
1360
            72,
×
1361
            73,
×
1362
            74,
×
1363
            75,
×
1364
            76,
×
1365
            77,
×
1366
            78,
×
1367
            79,
×
1368
            80,
×
1369
            81,
×
1370
            82,
×
1371
            83,
×
1372
            84,
×
1373
            85,
×
1374
            86,
×
1375
            87,
×
1376
            88,
×
1377
            89,
×
1378
            90,
×
1379
            // lower-case letters
1380
            97,
×
1381
            98,
×
1382
            99,
×
1383
            100,
×
1384
            101,
×
1385
            102,
×
1386
            103,
×
1387
            104,
×
1388
            105,
×
1389
            106,
×
1390
            107,
×
1391
            108,
×
1392
            109,
×
1393
            110,
×
1394
            111,
×
1395
            112,
×
1396
            113,
×
1397
            114,
×
1398
            115,
×
1399
            116,
×
1400
            117,
×
1401
            118,
×
1402
            119,
×
1403
            120,
×
1404
            121,
×
1405
            122,
×
1406
        ];
×
1407

1408
        // We are intentionally wrapping so mail servers will encode characters
1409
        // properly and MUAs will behave, so {unwrap} must go!
1410
        $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str);
×
1411

1412
        // RFC 2045 specifies CRLF as "\r\n".
1413
        // However, many developers choose to override that and violate
1414
        // the RFC rules due to (apparently) a bug in MS Exchange,
1415
        // which only works with "\n".
1416
        if ($this->CRLF === "\r\n") {
×
1417
            return quoted_printable_encode($str);
×
1418
        }
1419

1420
        // Reduce multiple spaces & remove nulls
1421
        $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str);
×
1422

1423
        // Standardize newlines
1424
        if (str_contains($str, "\r")) {
×
1425
            $str = str_replace(["\r\n", "\r"], "\n", $str);
×
1426
        }
1427

1428
        $escape = '=';
×
1429
        $output = '';
×
1430

1431
        foreach (explode("\n", $str) as $line) {
×
1432
            $length = static::strlen($line);
×
1433
            $temp   = '';
×
1434

1435
            // Loop through each character in the line to add soft-wrap
1436
            // characters at the end of a line " =\r\n" and add the newly
1437
            // processed line(s) to the output (see comment on $crlf class property)
1438
            for ($i = 0; $i < $length; $i++) {
×
1439
                // Grab the next character
1440
                $char  = $line[$i];
×
1441
                $ascii = ord($char);
×
1442

1443
                // Convert spaces and tabs but only if it's the end of the line
1444
                if ($ascii === 32 || $ascii === 9) {
×
1445
                    if ($i === ($length - 1)) {
×
1446
                        $char = $escape . sprintf('%02s', dechex($ascii));
×
1447
                    }
1448
                }
1449
                // DO NOT move this below the $ascii_safe_chars line!
1450
                //
1451
                // = (equals) signs are allowed by RFC2049, but must be encoded
1452
                // as they are the encoding delimiter!
1453
                elseif ($ascii === 61) {
×
1454
                    $char = $escape . strtoupper(sprintf('%02s', dechex($ascii)));  // =3D
×
1455
                } elseif (! in_array($ascii, $asciiSafeChars, true)) {
×
1456
                    $char = $escape . strtoupper(sprintf('%02s', dechex($ascii)));
×
1457
                }
1458

1459
                // If we're at the character limit, add the line to the output,
1460
                // reset our temp variable, and keep on chuggin'
1461
                if ((static::strlen($temp) + static::strlen($char)) >= 76) {
×
1462
                    $output .= $temp . $escape . $this->CRLF;
×
1463
                    $temp = '';
×
1464
                }
1465

1466
                // Add the character to our temporary line
1467
                $temp .= $char;
×
1468
            }
1469

1470
            // Add our completed line to the output
1471
            $output .= $temp . $this->CRLF;
×
1472
        }
1473

1474
        // get rid of extra CRLF tacked onto the end
1475
        return static::substr($output, 0, static::strlen($this->CRLF) * -1);
×
1476
    }
1477

1478
    /**
1479
     * Performs "Q Encoding" on a string for use in email headers.
1480
     * It's related but not identical to quoted-printable, so it has its
1481
     * own method.
1482
     *
1483
     * @param string $str
1484
     *
1485
     * @return string
1486
     */
1487
    protected function prepQEncoding($str)
1488
    {
1489
        $str = str_replace(["\r", "\n"], '', $str);
2✔
1490

1491
        if ($this->charset === 'UTF-8') {
2✔
1492
            // Note: We used to have mb_encode_mimeheader() as the first choice
1493
            // here, but it turned out to be buggy and unreliable. DO NOT
1494
            // re-add it! -- Narf
1495
            if (extension_loaded('iconv')) {
2✔
1496
                $output = @iconv_mime_encode('', $str, [
2✔
1497
                    'scheme'           => 'Q',
2✔
1498
                    'line-length'      => 76,
2✔
1499
                    'input-charset'    => $this->charset,
2✔
1500
                    'output-charset'   => $this->charset,
2✔
1501
                    'line-break-chars' => $this->CRLF,
2✔
1502
                ]);
2✔
1503

1504
                // There are reports that iconv_mime_encode() might fail and return FALSE
1505
                if ($output !== false) {
2✔
1506
                    // iconv_mime_encode() will always put a header field name.
1507
                    // We've passed it an empty one, but it still prepends our
1508
                    // encoded string with ': ', so we need to strip it.
1509
                    return static::substr($output, 2);
2✔
1510
                }
1511

1512
                $chars = iconv_strlen($str, 'UTF-8');
×
1513
            } elseif (extension_loaded('mbstring')) {
×
1514
                $chars = mb_strlen($str, 'UTF-8');
×
1515
            }
1516
        }
1517

1518
        // We might already have this set for UTF-8
1519
        if (! isset($chars)) {
×
1520
            $chars = static::strlen($str);
×
1521
        }
1522

1523
        $output = '=?' . $this->charset . '?Q?';
×
1524

1525
        for ($i = 0, $length = static::strlen($output); $i < $chars; $i++) {
×
1526
            $chr = ($this->charset === 'UTF-8' && extension_loaded('iconv')) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i]));
×
1527

1528
            // RFC 2045 sets a limit of 76 characters per line.
1529
            // We'll append ?= to the end of each line though.
1530
            if ($length + ($l = static::strlen($chr)) > 74) {
×
1531
                $output .= '?=' . $this->CRLF // EOL
×
1532
                    . ' =?' . $this->charset . '?Q?' . $chr; // New line
×
1533

1534
                $length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line
×
1535
            } else {
1536
                $output .= $chr;
×
1537
                $length += $l;
×
1538
            }
1539
        }
1540

1541
        // End the header
1542
        return $output . '?=';
×
1543
    }
1544

1545
    /**
1546
     * @param bool $autoClear
1547
     *
1548
     * @return bool
1549
     */
1550
    public function send($autoClear = true)
1551
    {
1552
        if (! isset($this->headers['From']) && ! empty($this->fromEmail)) {
×
1553
            $this->setFrom($this->fromEmail, $this->fromName);
×
1554
        }
1555

1556
        if (! isset($this->headers['From'])) {
×
1557
            $this->setErrorMessage(lang('Email.noFrom'));
×
1558

1559
            return false;
×
1560
        }
1561

1562
        if ($this->replyToFlag === false) {
×
1563
            $this->setReplyTo($this->headers['From']);
×
1564
        }
1565

1566
        if (
1567
            empty($this->recipients) && ! isset($this->headers['To'])
×
1568
            && empty($this->BCCArray) && ! isset($this->headers['Bcc'])
×
1569
            && ! isset($this->headers['Cc'])
×
1570
        ) {
1571
            $this->setErrorMessage(lang('Email.noRecipients'));
×
1572

1573
            return false;
×
1574
        }
1575

1576
        $this->buildHeaders();
×
1577

1578
        if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) {
×
1579
            $this->batchBCCSend();
×
1580

1581
            if ($autoClear) {
×
1582
                $this->clear();
×
1583
            }
1584

1585
            return true;
×
1586
        }
1587

1588
        $this->buildMessage();
×
1589
        $result = $this->spoolEmail();
×
1590

1591
        if ($result) {
×
1592
            $this->setArchiveValues();
×
1593

1594
            if ($autoClear) {
×
1595
                $this->clear();
×
1596
            }
1597

1598
            Events::trigger('email', $this->archive);
×
1599
        }
1600

1601
        return $result;
×
1602
    }
1603

1604
    /**
1605
     * Batch Bcc Send. Sends groups of BCCs in batches
1606
     *
1607
     * @return void
1608
     */
1609
    public function batchBCCSend()
1610
    {
1611
        $float = $this->BCCBatchSize - 1;
×
1612
        $set   = '';
×
1613
        $chunk = [];
×
1614

1615
        for ($i = 0, $c = count($this->BCCArray); $i < $c; $i++) {
×
1616
            if (isset($this->BCCArray[$i])) {
×
1617
                $set .= ', ' . $this->BCCArray[$i];
×
1618
            }
1619

1620
            if ($i === $float) {
×
1621
                $chunk[] = static::substr($set, 1);
×
1622
                $float += $this->BCCBatchSize;
×
1623
                $set = '';
×
1624
            }
1625

1626
            if ($i === $c - 1) {
×
1627
                $chunk[] = static::substr($set, 1);
×
1628
            }
1629
        }
1630

1631
        for ($i = 0, $c = count($chunk); $i < $c; $i++) {
×
1632
            unset($this->headers['Bcc']);
×
1633
            $bcc = $this->cleanEmail($this->stringToArray($chunk[$i]));
×
1634

1635
            if ($this->protocol !== 'smtp') {
×
1636
                $this->setHeader('Bcc', implode(', ', $bcc));
×
1637
            } else {
1638
                $this->BCCArray = $bcc;
×
1639
            }
1640

1641
            $this->buildMessage();
×
1642
            $this->spoolEmail();
×
1643
        }
1644

1645
        // Update the archive
1646
        $this->setArchiveValues();
×
1647
        Events::trigger('email', $this->archive);
×
1648
    }
1649

1650
    /**
1651
     * Unwrap special elements
1652
     *
1653
     * @return void
1654
     */
1655
    protected function unwrapSpecials()
1656
    {
1657
        $this->finalBody = preg_replace_callback(
×
1658
            '/\{unwrap\}(.*?)\{\/unwrap\}/si',
×
1659
            $this->removeNLCallback(...),
×
1660
            $this->finalBody
×
1661
        );
×
1662
    }
1663

1664
    /**
1665
     * Strip line-breaks via callback
1666
     *
1667
     * @used-by unwrapSpecials()
1668
     *
1669
     * @param list<string> $matches
1670
     *
1671
     * @return string
1672
     */
1673
    protected function removeNLCallback($matches)
1674
    {
1675
        if (str_contains($matches[1], "\r") || str_contains($matches[1], "\n")) {
×
1676
            $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]);
×
1677
        }
1678

1679
        return $matches[1];
×
1680
    }
1681

1682
    /**
1683
     * Spool mail to the mail server
1684
     *
1685
     * @return bool
1686
     */
1687
    protected function spoolEmail()
1688
    {
1689
        $this->unwrapSpecials();
×
1690
        $protocol = $this->getProtocol();
×
1691
        $method   = 'sendWith' . ucfirst($protocol);
×
1692

1693
        try {
1694
            $success = $this->{$method}();
×
1695
        } catch (ErrorException $e) {
×
1696
            $success = false;
×
1697
            log_message('error', 'Email: ' . $method . ' throwed ' . $e);
×
1698
        }
1699

1700
        if (! $success) {
×
1701
            $message = lang('Email.sendFailure' . ($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)));
×
1702

1703
            log_message('error', 'Email: ' . $message);
×
1704
            log_message('error', $this->printDebuggerRaw());
×
1705

1706
            $this->setErrorMessage($message);
×
1707

1708
            return false;
×
1709
        }
1710

1711
        $this->setErrorMessage(lang('Email.sent', [$protocol]));
×
1712

1713
        return true;
×
1714
    }
1715

1716
    /**
1717
     * Validate email for shell
1718
     *
1719
     * Applies stricter, shell-safe validation to email addresses.
1720
     * Introduced to prevent RCE via sendmail's -f option.
1721
     *
1722
     * @see     https://github.com/codeigniter4/CodeIgniter/issues/4963
1723
     * @see     https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36
1724
     *
1725
     * @license https://creativecommons.org/publicdomain/zero/1.0/    CC0 1.0, Public Domain
1726
     *
1727
     * Credits for the base concept go to Paul Buonopane <paul@namepros.com>
1728
     *
1729
     * @param string $email
1730
     *
1731
     * @return bool
1732
     */
1733
    protected function validateEmailForShell(&$email)
1734
    {
1735
        if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) {
×
1736
            $email = static::substr($email, 0, ++$atpos)
×
1737
                . idn_to_ascii(static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46);
×
1738
        }
1739

1740
        return filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email);
×
1741
    }
1742

1743
    /**
1744
     * Send using mail()
1745
     *
1746
     * @return bool
1747
     */
1748
    protected function sendWithMail()
1749
    {
1750
        $recipients = is_array($this->recipients) ? implode(', ', $this->recipients) : $this->recipients;
×
1751

1752
        // _validate_email_for_shell() below accepts by reference,
1753
        // so this needs to be assigned to a variable
1754
        $from = $this->cleanEmail($this->headers['Return-Path']);
×
1755

1756
        if (! $this->validateEmailForShell($from)) {
×
1757
            return mail($recipients, $this->subject, $this->finalBody, $this->headerStr);
×
1758
        }
1759

1760
        // most documentation of sendmail using the "-f" flag lacks a space after it, however
1761
        // we've encountered servers that seem to require it to be in place.
1762
        return mail($recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from);
×
1763
    }
1764

1765
    /**
1766
     * Send using Sendmail
1767
     *
1768
     * @return bool
1769
     */
1770
    protected function sendWithSendmail()
1771
    {
1772
        // _validate_email_for_shell() below accepts by reference,
1773
        // so this needs to be assigned to a variable
1774
        $from = $this->cleanEmail($this->headers['From']);
×
1775

1776
        $from = $this->validateEmailForShell($from) ? '-f ' . $from : '';
×
1777

1778
        if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) {
×
1779
            return false;
×
1780
        }
1781

1782
        fwrite($fp, $this->headerStr);
×
1783
        fwrite($fp, $this->finalBody);
×
1784
        $status = pclose($fp);
×
1785

1786
        if ($status !== 0) {
×
1787
            $this->setErrorMessage(lang('Email.exitStatus', [$status]));
×
1788
            $this->setErrorMessage(lang('Email.noSocket'));
×
1789

1790
            return false;
×
1791
        }
1792

1793
        return true;
×
1794
    }
1795

1796
    /**
1797
     * Send using SMTP
1798
     *
1799
     * @return bool
1800
     */
1801
    protected function sendWithSmtp()
1802
    {
1803
        if ($this->SMTPHost === '') {
×
1804
            $this->setErrorMessage(lang('Email.noHostname'));
×
1805

1806
            return false;
×
1807
        }
1808

1809
        if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) {
×
1810
            return false;
×
1811
        }
1812

1813
        if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) {
×
1814
            $this->SMTPEnd();
×
1815

1816
            return false;
×
1817
        }
1818

1819
        foreach ($this->recipients as $val) {
×
1820
            if (! $this->sendCommand('to', $val)) {
×
1821
                $this->SMTPEnd();
×
1822

1823
                return false;
×
1824
            }
1825
        }
1826

1827
        foreach ($this->CCArray as $val) {
×
1828
            if ($val !== '' && ! $this->sendCommand('to', $val)) {
×
1829
                $this->SMTPEnd();
×
1830

1831
                return false;
×
1832
            }
1833
        }
1834

1835
        foreach ($this->BCCArray as $val) {
×
1836
            if ($val !== '' && ! $this->sendCommand('to', $val)) {
×
1837
                $this->SMTPEnd();
×
1838

1839
                return false;
×
1840
            }
1841
        }
1842

1843
        if (! $this->sendCommand('data')) {
×
1844
            $this->SMTPEnd();
×
1845

1846
            return false;
×
1847
        }
1848

1849
        // perform dot transformation on any lines that begin with a dot
1850
        $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody));
×
1851
        $this->sendData($this->newline . '.');
×
1852
        $reply = $this->getSMTPData();
×
1853
        $this->setErrorMessage($reply);
×
1854
        $this->SMTPEnd();
×
1855

1856
        if (! str_starts_with($reply, '250')) {
×
1857
            $this->setErrorMessage(lang('Email.SMTPError', [$reply]));
×
1858

1859
            return false;
×
1860
        }
1861

1862
        return true;
×
1863
    }
1864

1865
    /**
1866
     * Shortcut to send RSET or QUIT depending on keep-alive
1867
     *
1868
     * @return void
1869
     */
1870
    protected function SMTPEnd()
1871
    {
1872
        $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit');
×
1873
    }
1874

1875
    /**
1876
     * @return bool|string
1877
     */
1878
    protected function SMTPConnect()
1879
    {
1880
        if (is_resource($this->SMTPConnect)) {
×
1881
            return true;
×
1882
        }
1883

1884
        $ssl = '';
×
1885

1886
        // Connection to port 465 should use implicit TLS (without STARTTLS)
1887
        // as per RFC 8314.
1888
        if ($this->SMTPPort === 465) {
×
1889
            $ssl = 'tls://';
×
1890
        }
1891
        // But if $SMTPCrypto is set to `ssl`, SSL can be used.
1892
        if ($this->SMTPCrypto === 'ssl') {
×
1893
            $ssl = 'ssl://';
×
1894
        }
1895

1896
        $this->SMTPConnect = fsockopen(
×
1897
            $ssl . $this->SMTPHost,
×
1898
            $this->SMTPPort,
×
1899
            $errno,
×
1900
            $errstr,
×
1901
            $this->SMTPTimeout
×
1902
        );
×
1903

1904
        if (! is_resource($this->SMTPConnect)) {
×
1905
            $this->setErrorMessage(lang('Email.SMTPError', [$errno . ' ' . $errstr]));
×
1906

1907
            return false;
×
1908
        }
1909

1910
        stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout);
×
1911
        $this->setErrorMessage($this->getSMTPData());
×
1912

1913
        if ($this->SMTPCrypto === 'tls') {
×
1914
            $this->sendCommand('hello');
×
1915
            $this->sendCommand('starttls');
×
1916
            $crypto = stream_socket_enable_crypto(
×
1917
                $this->SMTPConnect,
×
1918
                true,
×
1919
                STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT
×
1920
                | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
×
1921
                | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
×
1922
                | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT
×
1923
            );
×
1924

1925
            if ($crypto !== true) {
×
1926
                $this->setErrorMessage(lang('Email.SMTPError', [$this->getSMTPData()]));
×
1927

1928
                return false;
×
1929
            }
1930
        }
1931

1932
        return $this->sendCommand('hello');
×
1933
    }
1934

1935
    /**
1936
     * @param string $cmd
1937
     * @param string $data
1938
     *
1939
     * @return bool
1940
     */
1941
    protected function sendCommand($cmd, $data = '')
1942
    {
1943
        switch ($cmd) {
1944
            case 'hello':
×
1945
                if ($this->SMTPAuth || $this->getEncoding() === '8bit') {
×
1946
                    $this->sendData('EHLO ' . $this->getHostname());
×
1947
                } else {
1948
                    $this->sendData('HELO ' . $this->getHostname());
×
1949
                }
1950

1951
                $resp = 250;
×
1952
                break;
×
1953

1954
            case 'starttls':
×
1955
                $this->sendData('STARTTLS');
×
1956
                $resp = 220;
×
1957
                break;
×
1958

1959
            case 'from':
×
1960
                $this->sendData('MAIL FROM:<' . $data . '>');
×
1961
                $resp = 250;
×
1962
                break;
×
1963

1964
            case 'to':
×
1965
                if ($this->DSN) {
×
1966
                    $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data);
×
1967
                } else {
1968
                    $this->sendData('RCPT TO:<' . $data . '>');
×
1969
                }
1970
                $resp = 250;
×
1971
                break;
×
1972

1973
            case 'data':
×
1974
                $this->sendData('DATA');
×
1975
                $resp = 354;
×
1976
                break;
×
1977

1978
            case 'reset':
×
1979
                $this->sendData('RSET');
×
1980
                $resp = 250;
×
1981
                break;
×
1982

1983
            case 'quit':
×
1984
                $this->sendData('QUIT');
×
1985
                $resp = 221;
×
1986
                break;
×
1987

1988
            default:
1989
                $resp = null;
×
1990
        }
1991

1992
        $reply = $this->getSMTPData();
×
1993

1994
        $this->debugMessage[]    = '<pre>' . $cmd . ': ' . $reply . '</pre>';
×
1995
        $this->debugMessageRaw[] = $cmd . ': ' . $reply;
×
1996

1997
        if ($resp === null || ((int) static::substr($reply, 0, 3) !== $resp)) {
×
1998
            $this->setErrorMessage(lang('Email.SMTPError', [$reply]));
×
1999

2000
            return false;
×
2001
        }
2002

2003
        if ($cmd === 'quit') {
×
2004
            fclose($this->SMTPConnect);
×
2005
        }
2006

2007
        return true;
×
2008
    }
2009

2010
    /**
2011
     * @return bool
2012
     */
2013
    protected function SMTPAuthenticate()
2014
    {
2015
        if (! $this->SMTPAuth) {
×
2016
            return true;
×
2017
        }
2018

2019
        if ($this->SMTPUser === '' && $this->SMTPPass === '') {
×
2020
            $this->setErrorMessage(lang('Email.noSMTPAuth'));
×
2021

2022
            return false;
×
2023
        }
2024

2025
        $this->sendData('AUTH LOGIN');
×
2026
        $reply = $this->getSMTPData();
×
2027

2028
        if (str_starts_with($reply, '503')) {    // Already authenticated
×
2029
            return true;
×
2030
        }
2031

2032
        if (! str_starts_with($reply, '334')) {
×
2033
            $this->setErrorMessage(lang('Email.failedSMTPLogin', [$reply]));
×
2034

2035
            return false;
×
2036
        }
2037

2038
        $this->sendData(base64_encode($this->SMTPUser));
×
2039
        $reply = $this->getSMTPData();
×
2040

2041
        if (! str_starts_with($reply, '334')) {
×
2042
            $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply]));
×
2043

2044
            return false;
×
2045
        }
2046

2047
        $this->sendData(base64_encode($this->SMTPPass));
×
2048
        $reply = $this->getSMTPData();
×
2049

2050
        if (! str_starts_with($reply, '235')) {
×
2051
            $this->setErrorMessage(lang('Email.SMTPAuthPassword', [$reply]));
×
2052

2053
            return false;
×
2054
        }
2055

2056
        if ($this->SMTPKeepAlive) {
×
2057
            $this->SMTPAuth = false;
×
2058
        }
2059

2060
        return true;
×
2061
    }
2062

2063
    /**
2064
     * @param string $data
2065
     *
2066
     * @return bool
2067
     */
2068
    protected function sendData($data)
2069
    {
2070
        $data .= $this->newline;
×
2071

2072
        $result = null;
×
2073

2074
        for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) {
×
2075
            if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) {
×
2076
                break;
×
2077
            }
2078

2079
            // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951
2080
            if ($result === 0) {
×
2081
                if ($timestamp === 0) {
×
2082
                    $timestamp = Time::now()->getTimestamp();
×
2083
                } elseif ($timestamp < (Time::now()->getTimestamp() - $this->SMTPTimeout)) {
×
2084
                    $result = false;
×
2085

2086
                    break;
×
2087
                }
2088

2089
                usleep(250000);
×
2090

2091
                continue;
×
2092
            }
2093

2094
            $timestamp = 0;
×
2095
        }
2096

2097
        if (! is_int($result)) {
×
2098
            $this->setErrorMessage(lang('Email.SMTPDataFailure', [$data]));
×
2099

2100
            return false;
×
2101
        }
2102

2103
        return true;
×
2104
    }
2105

2106
    /**
2107
     * @return string
2108
     */
2109
    protected function getSMTPData()
2110
    {
2111
        $data = '';
×
2112

2113
        while ($str = fgets($this->SMTPConnect, 512)) {
×
2114
            $data .= $str;
×
2115

2116
            if ($str[3] === ' ') {
×
2117
                break;
×
2118
            }
2119
        }
2120

2121
        return $data;
×
2122
    }
2123

2124
    /**
2125
     * There are only two legal types of hostname - either a fully
2126
     * qualified domain name (eg: "mail.example.com") or an IP literal
2127
     * (eg: "[1.2.3.4]").
2128
     *
2129
     * @see https://tools.ietf.org/html/rfc5321#section-2.3.5
2130
     * @see http://cbl.abuseat.org/namingproblems.html
2131
     *
2132
     * @return string
2133
     */
2134
    protected function getHostname()
2135
    {
2136
        if (isset($_SERVER['SERVER_NAME'])) {
×
2137
            return $_SERVER['SERVER_NAME'];
×
2138
        }
2139

2140
        if (isset($_SERVER['SERVER_ADDR'])) {
×
2141
            return '[' . $_SERVER['SERVER_ADDR'] . ']';
×
2142
        }
2143

2144
        $hostname = gethostname();
×
2145
        if ($hostname !== false) {
×
2146
            return $hostname;
×
2147
        }
2148

2149
        return '[127.0.0.1]';
×
2150
    }
2151

2152
    /**
2153
     * @param array|string $include List of raw data chunks to include in the output
2154
     *                              Valid options are: 'headers', 'subject', 'body'
2155
     *
2156
     * @return string
2157
     */
2158
    public function printDebugger($include = ['headers', 'subject', 'body'])
2159
    {
2160
        $msg = implode('', $this->debugMessage);
1✔
2161

2162
        // Determine which parts of our raw data needs to be printed
2163
        $rawData = '';
1✔
2164

2165
        if (! is_array($include)) {
1✔
2166
            $include = [$include];
×
2167
        }
2168

2169
        if (in_array('headers', $include, true)) {
1✔
2170
            $rawData = htmlspecialchars($this->headerStr) . "\n";
1✔
2171
        }
2172
        if (in_array('subject', $include, true)) {
1✔
2173
            $rawData .= htmlspecialchars($this->subject) . "\n";
1✔
2174
        }
2175
        if (in_array('body', $include, true)) {
1✔
2176
            $rawData .= htmlspecialchars($this->finalBody);
1✔
2177
        }
2178

2179
        return $msg . ($rawData === '' ? '' : '<pre>' . $rawData . '</pre>');
1✔
2180
    }
2181

2182
    /**
2183
     * Returns raw debug messages
2184
     */
2185
    private function printDebuggerRaw(): string
2186
    {
2187
        return implode("\n", $this->debugMessageRaw);
×
2188
    }
2189

2190
    /**
2191
     * @param string $msg
2192
     *
2193
     * @return void
2194
     */
2195
    protected function setErrorMessage($msg)
2196
    {
2197
        $this->debugMessage[]    = $msg . '<br>';
1✔
2198
        $this->debugMessageRaw[] = $msg;
1✔
2199
    }
2200

2201
    /**
2202
     * Mime Types
2203
     *
2204
     * @param string $ext
2205
     *
2206
     * @return string
2207
     */
2208
    protected function mimeTypes($ext = '')
2209
    {
2210
        $mime = Mimes::guessTypeFromExtension(strtolower($ext));
1✔
2211

2212
        return ! empty($mime) ? $mime : 'application/x-unknown-content-type';
1✔
2213
    }
2214

2215
    public function __destruct()
2216
    {
2217
        if (is_resource($this->SMTPConnect)) {
6,293✔
2218
            try {
2219
                $this->sendCommand('quit');
1✔
2220
            } catch (ErrorException $e) {
1✔
2221
                $protocol = $this->getProtocol();
1✔
2222
                $method   = 'sendWith' . ucfirst($protocol);
1✔
2223
                log_message('error', 'Email: ' . $method . ' throwed ' . $e);
1✔
2224
            }
2225
        }
2226
    }
2227

2228
    /**
2229
     * Byte-safe strlen()
2230
     *
2231
     * @param string $str
2232
     *
2233
     * @return int
2234
     */
2235
    protected static function strlen($str)
2236
    {
2237
        return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str);
×
2238
    }
2239

2240
    /**
2241
     * Byte-safe substr()
2242
     *
2243
     * @param string   $str
2244
     * @param int      $start
2245
     * @param int|null $length
2246
     *
2247
     * @return string
2248
     */
2249
    protected static function substr($str, $start, $length = null)
2250
    {
2251
        if (static::$func_overload) {
9✔
2252
            return mb_substr($str, $start, $length, '8bit');
×
2253
        }
2254

2255
        return isset($length) ? substr($str, $start, $length) : substr($str, $start);
9✔
2256
    }
2257

2258
    /**
2259
     * Determines the values that should be stored in $archive.
2260
     *
2261
     * @return array The updated archive values
2262
     */
2263
    protected function setArchiveValues(): array
2264
    {
2265
        // Get property values and add anything prepped in tmpArchive
2266
        $this->archive = array_merge(get_object_vars($this), $this->tmpArchive);
8✔
2267
        unset($this->archive['archive']);
8✔
2268

2269
        // Clear tmpArchive for next run
2270
        $this->tmpArchive = [];
8✔
2271

2272
        return $this->archive;
8✔
2273
    }
2274
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc