3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\Email;
16
use CodeIgniter\Events\Events;
17
use CodeIgniter\I18n\Time;
22
* CodeIgniter Email Class
24
* Permits email to be sent using Mail, Sendmail, or SMTP.
26
* @see \CodeIgniter\Email\EmailTest
31
* Properties from the last successful send.
38
* Properties to be added to the next archive.
42
protected $tmpArchive = [];
55
* Used as the User-Agent and X-Mailer headers' value.
59
public $userAgent = 'CodeIgniter';
62
* Path to the Sendmail binary.
66
public $mailPath = '/usr/sbin/sendmail';
69
* Which method to use for sending e-mails.
71
* @var string 'mail', 'sendmail' or 'smtp'
73
public $protocol = 'mail';
76
* STMP Server Hostname
80
public $SMTPHost = '';
87
public $SMTPUser = '';
94
public $SMTPPass = '';
101
public $SMTPPort = 25;
104
* SMTP connection timeout in seconds
108
public $SMTPTimeout = 5;
111
* SMTP persistent connection
115
public $SMTPKeepAlive = false;
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 ''.
124
public $SMTPCrypto = '';
127
* Whether to apply word-wrapping to the message body.
131
public $wordWrap = true;
134
* Number of characters to wrap at.
136
* @see Email::$wordWrap
140
public $wrapChars = 76;
145
* @var string 'text' or 'html'
147
public $mailType = 'text';
150
* Character set (default: utf-8)
154
public $charset = 'UTF-8';
157
* Alternative message (for HTML messages only)
161
public $altMessage = '';
164
* Whether to validate e-mail addresses.
168
public $validate = true;
171
* X-Priority header value.
175
public $priority = 3;
178
* Newline character sequence.
179
* Use "\r\n" to comply with RFC 822.
181
* @see http://www.ietf.org/rfc/rfc822.txt
183
* @var string "\r\n" or "\n"
185
public $newline = "\r\n";
188
* CRLF character sequence
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.
196
* @see http://www.ietf.org/rfc/rfc822.txt
200
public $CRLF = "\r\n";
203
* Whether to use Delivery Status Notification.
210
* Whether to send multipart alternatives.
211
* Yahoo! doesn't seem to like these.
215
public $sendMultipart = true;
218
* Whether to send messages to BCC recipients in batches.
222
public $BCCBatchMode = false;
225
* BCC Batch max number size.
227
* @see Email::$BCCBatchMode
231
public $BCCBatchSize = 200;
238
protected $subject = '';
245
protected $body = '';
248
* Final message body to be sent.
252
protected $finalBody = '';
255
* Final headers to send
259
protected $headerStr = '';
262
* SMTP Connection socket placeholder
266
protected $SMTPConnect;
271
* @var string '8bit' or '7bit'
273
protected $encoding = '8bit';
276
* Whether to perform SMTP authentication
280
protected $SMTPAuth = false;
283
* Whether to send a Reply-To header
287
protected $replyToFlag = false;
292
* @see Email::printDebugger()
296
protected $debugMessage = [];
303
private array $debugMessageRaw = [];
310
protected $recipients = [];
317
protected $CCArray = [];
324
protected $BCCArray = [];
331
protected $headers = [];
338
protected $attachments = [];
341
* Valid $protocol values
343
* @see Email::$protocol
347
protected $protocols = [
354
* Character sets valid for 7-bit encoding,
355
* excluding language suffix.
359
protected $baseCharsets = [
367
* Valid mail encodings
369
* @see Email::$encoding
373
protected $bitDepths = [
379
* $priority translations
381
* Actual values to send with the X-Priority header
385
protected $priorities = [
394
* mbstring.func_overload flag
398
protected static $func_overload;
401
* @param array|\Config\Email|null $config
403
public function __construct($config = null)
405
$this->initialize($config);
406
if (! isset(static::$func_overload)) {
407
static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload'));
412
* Initialize preferences
414
* @param array|\Config\Email|null $config
418
public function initialize($config)
422
if ($config instanceof \Config\Email) {
423
$config = get_object_vars($config);
426
foreach (array_keys(get_class_vars(static::class)) as $key) {
427
if (property_exists($this, $key) && isset($config[$key])) {
428
$method = 'set' . ucfirst($key);
430
if (method_exists($this, $method)) {
431
$this->{$method}($config[$key]);
433
$this->{$key} = $config[$key];
438
$this->charset = strtoupper($this->charset);
439
$this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]);
445
* @param bool $clearAttachments
449
public function clear($clearAttachments = false)
453
$this->finalBody = '';
454
$this->headerStr = '';
455
$this->replyToFlag = false;
456
$this->recipients = [];
458
$this->BCCArray = [];
460
$this->debugMessage = [];
461
$this->debugMessageRaw = [];
463
$this->setHeader('Date', $this->setDate());
465
if ($clearAttachments !== false) {
466
$this->attachments = [];
473
* @param string $from
474
* @param string $name
475
* @param string|null $returnPath Return-Path
479
public function setFrom($from, $name = '', $returnPath = null)
481
if (preg_match('/\<(.*)\>/', $from, $match)) {
485
if ($this->validate) {
486
$this->validateEmail($this->stringToArray($from));
489
$this->validateEmail($this->stringToArray($returnPath));
493
$this->tmpArchive['fromEmail'] = $from;
494
$this->tmpArchive['fromName'] = $name;
497
// only use Q encoding if there are characters that would require it
498
if (! preg_match('/[\200-\377]/', $name)) {
499
$name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"';
501
$name = $this->prepQEncoding($name);
505
$this->setHeader('From', $name . ' <' . $from . '>');
506
if (! isset($returnPath)) {
509
$this->setHeader('Return-Path', '<' . $returnPath . '>');
510
$this->tmpArchive['returnPath'] = $returnPath;
516
* @param string $replyto
517
* @param string $name
521
public function setReplyTo($replyto, $name = '')
523
if (preg_match('/\<(.*)\>/', $replyto, $match)) {
524
$replyto = $match[1];
527
if ($this->validate) {
528
$this->validateEmail($this->stringToArray($replyto));
532
$this->tmpArchive['replyName'] = $name;
534
// only use Q encoding if there are characters that would require it
535
if (! preg_match('/[\200-\377]/', $name)) {
536
$name = '"' . addcslashes($name, "\0..\37\177'\"\\") . '"';
538
$name = $this->prepQEncoding($name);
542
$this->setHeader('Reply-To', $name . ' <' . $replyto . '>');
543
$this->replyToFlag = true;
544
$this->tmpArchive['replyTo'] = $replyto;
550
* @param array|string $to
554
public function setTo($to)
556
$to = $this->stringToArray($to);
557
$to = $this->cleanEmail($to);
559
if ($this->validate) {
560
$this->validateEmail($to);
563
if ($this->getProtocol() !== 'mail') {
564
$this->setHeader('To', implode(', ', $to));
567
$this->recipients = $to;
577
public function setCC($cc)
579
$cc = $this->cleanEmail($this->stringToArray($cc));
581
if ($this->validate) {
582
$this->validateEmail($cc);
585
$this->setHeader('Cc', implode(', ', $cc));
587
if ($this->getProtocol() === 'smtp') {
588
$this->CCArray = $cc;
591
$this->tmpArchive['CCArray'] = $cc;
598
* @param string $limit
602
public function setBCC($bcc, $limit = '')
604
if ($limit !== '' && is_numeric($limit)) {
605
$this->BCCBatchMode = true;
606
$this->BCCBatchSize = $limit;
609
$bcc = $this->cleanEmail($this->stringToArray($bcc));
611
if ($this->validate) {
612
$this->validateEmail($bcc);
615
if ($this->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) {
616
$this->BCCArray = $bcc;
618
$this->setHeader('Bcc', implode(', ', $bcc));
619
$this->tmpArchive['BCCArray'] = $bcc;
626
* @param string $subject
630
public function setSubject($subject)
632
$this->tmpArchive['subject'] = $subject;
634
$subject = $this->prepQEncoding($subject);
635
$this->setHeader('Subject', $subject);
641
* @param string $body
645
public function setMessage($body)
647
$this->body = rtrim(str_replace("\r", '', $body));
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
660
public function attach($file, $disposition = '', $newname = null, $mime = '')
663
if (! str_contains($file, '://') && ! is_file($file)) {
664
$this->setErrorMessage(lang('Email.attachmentMissing', [$file]));
669
if (! $fp = @fopen($file, 'rb')) {
670
$this->setErrorMessage(lang('Email.attachmentUnreadable', [$file]));
675
$fileContent = stream_get_contents($fp);
677
$mime = $this->mimeTypes(pathinfo($file, PATHINFO_EXTENSION));
681
$fileContent = &$file; // buffered file
684
// declare names on their own, to make phpcbf happy
685
$namesAttached = [$file, $newname];
687
$this->attachments[] = [
688
'name' => $namesAttached,
689
'disposition' => empty($disposition) ? 'attachment' : $disposition,
690
// Can also be 'inline' Not sure if it matters
692
'content' => chunk_split(base64_encode($fileContent)),
693
'multipart' => 'mixed',
700
* Set and return attachment Content-ID
701
* Useful for attached inline pictures
703
* @param string $filename
705
* @return bool|string
707
public function setAttachmentCID($filename)
709
foreach ($this->attachments as $i => $attachment) {
711
if ($attachment['name'][0] === $filename) {
712
$this->attachments[$i]['multipart'] = 'related';
714
$this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][0]) . '@', true);
716
return $this->attachments[$i]['cid'];
719
// For buffer string.
720
if ($attachment['name'][1] === $filename) {
721
$this->attachments[$i]['multipart'] = 'related';
723
$this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][1]) . '@', true);
725
return $this->attachments[$i]['cid'];
733
* @param string $header
734
* @param string $value
738
public function setHeader($header, $value)
740
$this->headers[$header] = str_replace(["\n", "\r"], '', $value);
746
* @param array|string $email
750
protected function stringToArray($email)
752
if (! is_array($email)) {
753
return (str_contains($email, ',')) ? preg_split('/[\s,]/', $email, -1, PREG_SPLIT_NO_EMPTY) : (array) trim($email);
764
public function setAltMessage($str)
766
$this->altMessage = (string) $str;
772
* @param string $type
776
public function setMailType($type = 'text')
778
$this->mailType = ($type === 'html') ? 'html' : 'text';
784
* @param bool $wordWrap
788
public function setWordWrap($wordWrap = true)
790
$this->wordWrap = (bool) $wordWrap;
796
* @param string $protocol
800
public function setProtocol($protocol = 'mail')
802
$this->protocol = in_array($protocol, $this->protocols, true) ? strtolower($protocol) : 'mail';
812
public function setPriority($n = 3)
814
$this->priority = preg_match('/^[1-5]$/', (string) $n) ? (int) $n : 3;
820
* @param string $newline
824
public function setNewline($newline = "\n")
826
$this->newline = in_array($newline, ["\n", "\r\n", "\r"], true) ? $newline : "\n";
832
* @param string $CRLF
836
public function setCRLF($CRLF = "\n")
838
$this->CRLF = ($CRLF !== "\n" && $CRLF !== "\r\n" && $CRLF !== "\r") ? "\n" : $CRLF;
846
protected function getMessageID()
848
$from = str_replace(['>', '<'], '', $this->headers['Return-Path']);
850
return '<' . uniqid('', true) . strstr($from, '@') . '>';
856
protected function getProtocol()
858
$this->protocol = strtolower($this->protocol);
860
if (! in_array($this->protocol, $this->protocols, true)) {
861
$this->protocol = 'mail';
864
return $this->protocol;
870
protected function getEncoding()
872
if (! in_array($this->encoding, $this->bitDepths, true)) {
873
$this->encoding = '8bit';
876
foreach ($this->baseCharsets as $charset) {
877
if (str_starts_with($this->charset, $charset)) {
878
$this->encoding = '7bit';
884
return $this->encoding;
890
protected function getContentType()
892
if ($this->mailType === 'html') {
893
return empty($this->attachments) ? 'html' : 'html-attach';
896
if ($this->mailType === 'text' && ! empty($this->attachments)) {
897
return 'plain-attach';
908
protected function setDate()
910
$timezone = date('Z');
911
$operator = ($timezone[0] === '-') ? '-' : '+';
912
$timezone = abs((int) $timezone);
913
$timezone = floor($timezone / 3600) * 100 + ($timezone % 3600) / 60;
915
return sprintf('%s %s%04d', date('D, j M Y H:i:s'), $operator, $timezone);
921
protected function getMimeMessage()
923
return 'This is a multi-part message in MIME format.' . $this->newline . 'Your email application may not support this format.';
927
* @param array|string $email
931
public function validateEmail($email)
933
if (! is_array($email)) {
934
$this->setErrorMessage(lang('Email.mustBeArray'));
939
foreach ($email as $val) {
940
if (! $this->isValidEmail($val)) {
941
$this->setErrorMessage(lang('Email.invalidAddress', [$val]));
951
* @param string $email
955
public function isValidEmail($email)
957
if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) {
958
$email = static::substr($email, 0, ++$atpos)
959
. idn_to_ascii(static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46);
962
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
966
* @param array|string $email
968
* @return array|string
970
public function cleanEmail($email)
972
if (! is_array($email)) {
973
return preg_match('/\<(.*)\>/', $email, $match) ? $match[1] : $email;
978
foreach ($email as $addy) {
979
$cleanEmail[] = preg_match('/\<(.*)\>/', $addy, $match) ? $match[1] : $addy;
986
* Build alternative plain text message
988
* Provides the raw message for use in plain-text headers of
989
* HTML-formatted emails.
991
* If the user hasn't specified his own alternative message
992
* it creates one by stripping the HTML
996
protected function getAltMessage()
998
if (! empty($this->altMessage)) {
999
return ($this->wordWrap) ? $this->wordWrap($this->altMessage, 76) : $this->altMessage;
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))));
1005
for ($i = 20; $i >= 3; $i--) {
1006
$body = str_replace(str_repeat("\n", $i), "\n\n", $body);
1009
$body = preg_replace('| +|', ' ', $body);
1011
return ($this->wordWrap) ? $this->wordWrap($body, 76) : $body;
1015
* @param string $str
1016
* @param int|null $charlim Line-length limit
1020
public function wordWrap($str, $charlim = null)
1022
if (empty($charlim)) {
1023
$charlim = empty($this->wrapChars) ? 76 : $this->wrapChars;
1026
if (str_contains($str, "\r")) {
1027
$str = str_replace(["\r\n", "\r"], "\n", $str);
1030
$str = preg_replace('| +\n|', "\n", $str);
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);
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);
1046
// Split the string into individual lines of text and cycle through them
1049
foreach (explode("\n", $str) as $line) {
1050
if (static::strlen($line) <= $charlim) {
1051
$output .= $line . $this->newline;
1059
if (preg_match('!\[url.+\]|://|www\.!', $line)) {
1063
$temp .= static::substr($line, 0, $charlim - 1);
1064
$line = static::substr($line, $charlim - 1);
1065
} while (static::strlen($line) > $charlim);
1068
$output .= $temp . $this->newline;
1071
$output .= $line . $this->newline;
1074
foreach ($unwrap as $key => $val) {
1075
$output = str_replace('{{unwrapped' . $key . '}}', $val, $output);
1082
* Build final headers
1086
protected function buildHeaders()
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');
1097
* Write Headers as a string
1101
protected function writeHeaders()
1103
if ($this->protocol === 'mail' && isset($this->headers['Subject'])) {
1104
$this->subject = $this->headers['Subject'];
1105
unset($this->headers['Subject']);
1108
reset($this->headers);
1109
$this->headerStr = '';
1111
foreach ($this->headers as $key => $val) {
1115
$this->headerStr .= $key . ': ' . $val . $this->newline;
1119
if ($this->getProtocol() === 'mail') {
1120
$this->headerStr = rtrim($this->headerStr);
1125
* Build Final Body and attachments
1129
protected function buildMessage()
1131
if ($this->wordWrap === true && $this->mailType !== 'html') {
1132
$this->body = $this->wordWrap($this->body);
1135
$this->writeHeaders();
1136
$hdr = ($this->getProtocol() === 'mail') ? $this->newline : '';
1139
switch ($this->getContentType()) {
1141
$hdr .= 'Content-Type: text/plain; charset='
1144
. 'Content-Transfer-Encoding: '
1145
. $this->getEncoding();
1147
if ($this->getProtocol() === 'mail') {
1148
$this->headerStr .= $hdr;
1149
$this->finalBody = $this->body;
1151
$this->finalBody = $hdr . $this->newline . $this->newline . $this->body;
1157
$boundary = uniqid('B_ALT_', true);
1159
if ($this->sendMultipart === false) {
1160
$hdr .= 'Content-Type: text/html; charset='
1161
. $this->charset . $this->newline
1162
. 'Content-Transfer-Encoding: quoted-printable';
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;
1175
$this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline;
1177
if ($this->getProtocol() === 'mail') {
1178
$this->headerStr .= $hdr;
1180
$this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody;
1183
if ($this->sendMultipart !== false) {
1184
$this->finalBody .= '--' . $boundary . '--';
1189
case 'plain-attach':
1190
$boundary = uniqid('B_ATC_', true);
1191
$hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
1193
if ($this->getProtocol() === 'mail') {
1194
$this->headerStr .= $hdr;
1197
$body .= $this->getMimeMessage() . $this->newline
1199
. '--' . $boundary . $this->newline
1200
. 'Content-Type: text/plain; charset=' . $this->charset . $this->newline
1201
. 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline
1203
. $this->body . $this->newline . $this->newline;
1205
$this->appendAttachments($body, $boundary);
1209
$altBoundary = uniqid('B_ALT_', true);
1210
$lastBoundary = null;
1212
if ($this->attachmentsHaveMultipart('mixed')) {
1213
$atcBoundary = uniqid('B_ATC_', true);
1214
$hdr .= 'Content-Type: multipart/mixed; boundary="' . $atcBoundary . '"';
1215
$lastBoundary = $atcBoundary;
1218
if ($this->attachmentsHaveMultipart('related')) {
1219
$relBoundary = uniqid('B_REL_', true);
1221
$relBoundaryHeader = 'Content-Type: multipart/related; boundary="' . $relBoundary . '"';
1223
if (isset($lastBoundary)) {
1224
$body .= '--' . $lastBoundary . $this->newline . $relBoundaryHeader;
1226
$hdr .= $relBoundaryHeader;
1229
$lastBoundary = $relBoundary;
1232
if ($this->getProtocol() === 'mail') {
1233
$this->headerStr .= $hdr;
1236
static::strlen($body) && $body .= $this->newline . $this->newline;
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;
1251
if (isset($relBoundary)) {
1252
$body .= $this->newline . $this->newline;
1253
$this->appendAttachments($body, $relBoundary, 'related');
1256
// multipart/mixed attachments
1257
if (isset($atcBoundary)) {
1258
$body .= $this->newline . $this->newline;
1259
$this->appendAttachments($body, $atcBoundary, 'mixed');
1265
$this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body;
1269
* @param mixed $type
1273
protected function attachmentsHaveMultipart($type)
1275
foreach ($this->attachments as &$attachment) {
1276
if ($attachment['multipart'] === $type) {
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
1291
protected function appendAttachments(&$body, $boundary, $multipart = null)
1293
foreach ($this->attachments as $attachment) {
1294
if (isset($multipart) && $attachment['multipart'] !== $multipart) {
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)
1305
. $attachment['content'] . $this->newline;
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 . '--';
1316
* Prepares string for Quoted-Printable Content-Transfer-Encoding
1317
* Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt
1319
* @param string $str
1323
protected function prepQuotedPrintable($str)
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
// ' ( ) + , - . / : = ?
1352
// upper-case letters
1379
// lower-case letters
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);
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);
1420
// Reduce multiple spaces & remove nulls
1421
$str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str);
1423
// Standardize newlines
1424
if (str_contains($str, "\r")) {
1425
$str = str_replace(["\r\n", "\r"], "\n", $str);
1431
foreach (explode("\n", $str) as $line) {
1432
$length = static::strlen($line);
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
1441
$ascii = ord($char);
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));
1449
// DO NOT move this below the $ascii_safe_chars line!
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)));
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;
1466
// Add the character to our temporary line
1470
// Add our completed line to the output
1471
$output .= $temp . $this->CRLF;
1474
// get rid of extra CRLF tacked onto the end
1475
return static::substr($output, 0, static::strlen($this->CRLF) * -1);
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
1483
* @param string $str
1487
protected function prepQEncoding($str)
1489
$str = str_replace(["\r", "\n"], '', $str);
1491
if ($this->charset === 'UTF-8') {
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')) {
1496
$output = @iconv_mime_encode('', $str, [
1498
'line-length' => 76,
1499
'input-charset' => $this->charset,
1500
'output-charset' => $this->charset,
1501
'line-break-chars' => $this->CRLF,
1504
// There are reports that iconv_mime_encode() might fail and return FALSE
1505
if ($output !== false) {
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);
1512
$chars = iconv_strlen($str, 'UTF-8');
1513
} elseif (extension_loaded('mbstring')) {
1514
$chars = mb_strlen($str, 'UTF-8');
1518
// We might already have this set for UTF-8
1519
if (! isset($chars)) {
1520
$chars = static::strlen($str);
1523
$output = '=?' . $this->charset . '?Q?';
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]));
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
1534
$length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line
1542
return $output . '?=';
1546
* @param bool $autoClear
1550
public function send($autoClear = true)
1552
if (! isset($this->headers['From']) && ! empty($this->fromEmail)) {
1553
$this->setFrom($this->fromEmail, $this->fromName);
1556
if (! isset($this->headers['From'])) {
1557
$this->setErrorMessage(lang('Email.noFrom'));
1562
if ($this->replyToFlag === false) {
1563
$this->setReplyTo($this->headers['From']);
1567
empty($this->recipients) && ! isset($this->headers['To'])
1568
&& empty($this->BCCArray) && ! isset($this->headers['Bcc'])
1569
&& ! isset($this->headers['Cc'])
1571
$this->setErrorMessage(lang('Email.noRecipients'));
1576
$this->buildHeaders();
1578
if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) {
1579
$this->batchBCCSend();
1588
$this->buildMessage();
1589
$result = $this->spoolEmail();
1592
$this->setArchiveValues();
1598
Events::trigger('email', $this->archive);
1605
* Batch Bcc Send. Sends groups of BCCs in batches
1609
public function batchBCCSend()
1611
$float = $this->BCCBatchSize - 1;
1615
for ($i = 0, $c = count($this->BCCArray); $i < $c; $i++) {
1616
if (isset($this->BCCArray[$i])) {
1617
$set .= ', ' . $this->BCCArray[$i];
1620
if ($i === $float) {
1621
$chunk[] = static::substr($set, 1);
1622
$float += $this->BCCBatchSize;
1626
if ($i === $c - 1) {
1627
$chunk[] = static::substr($set, 1);
1631
for ($i = 0, $c = count($chunk); $i < $c; $i++) {
1632
unset($this->headers['Bcc']);
1633
$bcc = $this->cleanEmail($this->stringToArray($chunk[$i]));
1635
if ($this->protocol !== 'smtp') {
1636
$this->setHeader('Bcc', implode(', ', $bcc));
1638
$this->BCCArray = $bcc;
1641
$this->buildMessage();
1642
$this->spoolEmail();
1645
// Update the archive
1646
$this->setArchiveValues();
1647
Events::trigger('email', $this->archive);
1651
* Unwrap special elements
1655
protected function unwrapSpecials()
1657
$this->finalBody = preg_replace_callback(
1658
'/\{unwrap\}(.*?)\{\/unwrap\}/si',
1659
$this->removeNLCallback(...),
1665
* Strip line-breaks via callback
1667
* @used-by unwrapSpecials()
1669
* @param list<string> $matches
1673
protected function removeNLCallback($matches)
1675
if (str_contains($matches[1], "\r") || str_contains($matches[1], "\n")) {
1676
$matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]);
1683
* Spool mail to the mail server
1687
protected function spoolEmail()
1689
$this->unwrapSpecials();
1690
$protocol = $this->getProtocol();
1691
$method = 'sendWith' . ucfirst($protocol);
1694
$success = $this->{$method}();
1695
} catch (ErrorException $e) {
1697
log_message('error', 'Email: ' . $method . ' throwed ' . $e);
1701
$message = lang('Email.sendFailure' . ($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)));
1703
log_message('error', 'Email: ' . $message);
1704
log_message('error', $this->printDebuggerRaw());
1706
$this->setErrorMessage($message);
1711
$this->setErrorMessage(lang('Email.sent', [$protocol]));
1717
* Validate email for shell
1719
* Applies stricter, shell-safe validation to email addresses.
1720
* Introduced to prevent RCE via sendmail's -f option.
1722
* @see https://github.com/codeigniter4/CodeIgniter/issues/4963
1723
* @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36
1725
* @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain
1727
* Credits for the base concept go to Paul Buonopane <paul@namepros.com>
1729
* @param string $email
1733
protected function validateEmailForShell(&$email)
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);
1740
return filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email);
1748
protected function sendWithMail()
1750
$recipients = is_array($this->recipients) ? implode(', ', $this->recipients) : $this->recipients;
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']);
1756
if (! $this->validateEmailForShell($from)) {
1757
return mail($recipients, $this->subject, $this->finalBody, $this->headerStr);
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);
1766
* Send using Sendmail
1770
protected function sendWithSendmail()
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']);
1776
$from = $this->validateEmailForShell($from) ? '-f ' . $from : '';
1778
if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) {
1782
fwrite($fp, $this->headerStr);
1783
fwrite($fp, $this->finalBody);
1784
$status = pclose($fp);
1786
if ($status !== 0) {
1787
$this->setErrorMessage(lang('Email.exitStatus', [$status]));
1788
$this->setErrorMessage(lang('Email.noSocket'));
1801
protected function sendWithSmtp()
1803
if ($this->SMTPHost === '') {
1804
$this->setErrorMessage(lang('Email.noHostname'));
1809
if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) {
1813
if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) {
1819
foreach ($this->recipients as $val) {
1820
if (! $this->sendCommand('to', $val)) {
1827
foreach ($this->CCArray as $val) {
1828
if ($val !== '' && ! $this->sendCommand('to', $val)) {
1835
foreach ($this->BCCArray as $val) {
1836
if ($val !== '' && ! $this->sendCommand('to', $val)) {
1843
if (! $this->sendCommand('data')) {
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);
1856
if (! str_starts_with($reply, '250')) {
1857
$this->setErrorMessage(lang('Email.SMTPError', [$reply]));
1866
* Shortcut to send RSET or QUIT depending on keep-alive
1870
protected function SMTPEnd()
1872
$this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit');
1876
* @return bool|string
1878
protected function SMTPConnect()
1880
if (is_resource($this->SMTPConnect)) {
1886
// Connection to port 465 should use implicit TLS (without STARTTLS)
1888
if ($this->SMTPPort === 465) {
1891
// But if $SMTPCrypto is set to `ssl`, SSL can be used.
1892
if ($this->SMTPCrypto === 'ssl') {
1896
$this->SMTPConnect = fsockopen(
1897
$ssl . $this->SMTPHost,
1904
if (! is_resource($this->SMTPConnect)) {
1905
$this->setErrorMessage(lang('Email.SMTPError', [$errno . ' ' . $errstr]));
1910
stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout);
1911
$this->setErrorMessage($this->getSMTPData());
1913
if ($this->SMTPCrypto === 'tls') {
1914
$this->sendCommand('hello');
1915
$this->sendCommand('starttls');
1916
$crypto = stream_socket_enable_crypto(
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
1925
if ($crypto !== true) {
1926
$this->setErrorMessage(lang('Email.SMTPError', [$this->getSMTPData()]));
1932
return $this->sendCommand('hello');
1936
* @param string $cmd
1937
* @param string $data
1941
protected function sendCommand($cmd, $data = '')
1945
if ($this->SMTPAuth || $this->getEncoding() === '8bit') {
1946
$this->sendData('EHLO ' . $this->getHostname());
1948
$this->sendData('HELO ' . $this->getHostname());
1955
$this->sendData('STARTTLS');
1960
$this->sendData('MAIL FROM:<' . $data . '>');
1966
$this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data);
1968
$this->sendData('RCPT TO:<' . $data . '>');
1974
$this->sendData('DATA');
1979
$this->sendData('RSET');
1984
$this->sendData('QUIT');
1992
$reply = $this->getSMTPData();
1994
$this->debugMessage[] = '<pre>' . $cmd . ': ' . $reply . '</pre>';
1995
$this->debugMessageRaw[] = $cmd . ': ' . $reply;
1997
if ($resp === null || ((int) static::substr($reply, 0, 3) !== $resp)) {
1998
$this->setErrorMessage(lang('Email.SMTPError', [$reply]));
2003
if ($cmd === 'quit') {
2004
fclose($this->SMTPConnect);
2013
protected function SMTPAuthenticate()
2015
if (! $this->SMTPAuth) {
2019
if ($this->SMTPUser === '' && $this->SMTPPass === '') {
2020
$this->setErrorMessage(lang('Email.noSMTPAuth'));
2025
$this->sendData('AUTH LOGIN');
2026
$reply = $this->getSMTPData();
2028
if (str_starts_with($reply, '503')) { // Already authenticated
2032
if (! str_starts_with($reply, '334')) {
2033
$this->setErrorMessage(lang('Email.failedSMTPLogin', [$reply]));
2038
$this->sendData(base64_encode($this->SMTPUser));
2039
$reply = $this->getSMTPData();
2041
if (! str_starts_with($reply, '334')) {
2042
$this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply]));
2047
$this->sendData(base64_encode($this->SMTPPass));
2048
$reply = $this->getSMTPData();
2050
if (! str_starts_with($reply, '235')) {
2051
$this->setErrorMessage(lang('Email.SMTPAuthPassword', [$reply]));
2056
if ($this->SMTPKeepAlive) {
2057
$this->SMTPAuth = false;
2064
* @param string $data
2068
protected function sendData($data)
2070
$data .= $this->newline;
2074
for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) {
2075
if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) {
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)) {
2097
if (! is_int($result)) {
2098
$this->setErrorMessage(lang('Email.SMTPDataFailure', [$data]));
2109
protected function getSMTPData()
2113
while ($str = fgets($this->SMTPConnect, 512)) {
2116
if ($str[3] === ' ') {
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]").
2129
* @see https://tools.ietf.org/html/rfc5321#section-2.3.5
2130
* @see http://cbl.abuseat.org/namingproblems.html
2134
protected function getHostname()
2136
if (isset($_SERVER['SERVER_NAME'])) {
2137
return $_SERVER['SERVER_NAME'];
2140
if (isset($_SERVER['SERVER_ADDR'])) {
2141
return '[' . $_SERVER['SERVER_ADDR'] . ']';
2144
$hostname = gethostname();
2145
if ($hostname !== false) {
2149
return '[127.0.0.1]';
2153
* @param array|string $include List of raw data chunks to include in the output
2154
* Valid options are: 'headers', 'subject', 'body'
2158
public function printDebugger($include = ['headers', 'subject', 'body'])
2160
$msg = implode('', $this->debugMessage);
2162
// Determine which parts of our raw data needs to be printed
2165
if (! is_array($include)) {
2166
$include = [$include];
2169
if (in_array('headers', $include, true)) {
2170
$rawData = htmlspecialchars($this->headerStr) . "\n";
2172
if (in_array('subject', $include, true)) {
2173
$rawData .= htmlspecialchars($this->subject) . "\n";
2175
if (in_array('body', $include, true)) {
2176
$rawData .= htmlspecialchars($this->finalBody);
2179
return $msg . ($rawData === '' ? '' : '<pre>' . $rawData . '</pre>');
2183
* Returns raw debug messages
2185
private function printDebuggerRaw(): string
2187
return implode("\n", $this->debugMessageRaw);
2191
* @param string $msg
2195
protected function setErrorMessage($msg)
2197
$this->debugMessage[] = $msg . '<br>';
2198
$this->debugMessageRaw[] = $msg;
2204
* @param string $ext
2208
protected function mimeTypes($ext = '')
2210
$mime = Mimes::guessTypeFromExtension(strtolower($ext));
2212
return ! empty($mime) ? $mime : 'application/x-unknown-content-type';
2215
public function __destruct()
2217
if (is_resource($this->SMTPConnect)) {
2219
$this->sendCommand('quit');
2220
} catch (ErrorException $e) {
2221
$protocol = $this->getProtocol();
2222
$method = 'sendWith' . ucfirst($protocol);
2223
log_message('error', 'Email: ' . $method . ' throwed ' . $e);
2229
* Byte-safe strlen()
2231
* @param string $str
2235
protected static function strlen($str)
2237
return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str);
2241
* Byte-safe substr()
2243
* @param string $str
2245
* @param int|null $length
2249
protected static function substr($str, $start, $length = null)
2251
if (static::$func_overload) {
2252
return mb_substr($str, $start, $length, '8bit');
2255
return isset($length) ? substr($str, $start, $length) : substr($str, $start);
2259
* Determines the values that should be stored in $archive.
2261
* @return array The updated archive values
2263
protected function setArchiveValues(): array
2265
// Get property values and add anything prepped in tmpArchive
2266
$this->archive = array_merge(get_object_vars($this), $this->tmpArchive);
2267
unset($this->archive['archive']);
2269
// Clear tmpArchive for next run
2270
$this->tmpArchive = [];
2272
return $this->archive;