From 72cf7bebc2dd31c56d262eb84270f5bef4170ada Mon Sep 17 00:00:00 2001 From: Jim Parry Date: Sun, 16 Dec 2018 09:03:42 -0800 Subject: [PATCH 1/5] Extract Email exceptions --- system/Email/Email.php | 127 ++++++++------------- system/Email/Exceptions/EmailException.php | 84 ++++++++++++++ system/Email/TransporterInterface.php | 53 +++++++++ 3 files changed, 182 insertions(+), 82 deletions(-) create mode 100644 system/Email/Exceptions/EmailException.php create mode 100644 system/Email/TransporterInterface.php diff --git a/system/Email/Email.php b/system/Email/Email.php index 87f5c82e3a..4cbdd86477 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -38,6 +38,7 @@ namespace CodeIgniter\Email; * @filesource */ +use CodeIgniter\Email\Exceptions\EmailException; use Config\Mimes; use Psr\Log\LoggerAwareTrait; @@ -421,8 +422,6 @@ class Email { $this->initialize($config); - isset(static::$func_overload) || static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload')); - log_message('info', 'Email Class Initialized'); } @@ -725,27 +724,23 @@ class Email * * @param string $file Can be local path, URL or buffered content * @param string $disposition 'attachment' - * @param string|null $newname - * @param string $mime + * @param string|null $newName + * @param string $mime Empty if $file is buffered content * * @return Email */ - public function attach($file, $disposition = '', $newname = null, $mime = '') + public function attach(string $file, string $disposition = '', ?string $newName = null, string $mime = '') { if ($mime === '') { if (strpos($file, '://') === false && ! is_file($file)) { - $this->setErrorMessage(lang('Email.attachmentMissing', [$file])); - - return false; + throw EmailException::forAttachmentMissing($file); } if (! $fp = @fopen($file, 'rb')) { - $this->setErrorMessage(lang('Email.attachmentUnreadable', [$file])); - - return false; + throw EmailException::forAttachmentUnreadable($file); } $fileContent = stream_get_contents($fp); @@ -765,7 +760,7 @@ class Email $this->attachments[] = [ 'name' => $namesAttached, 'disposition' => empty($disposition) ? 'attachment' : $disposition, - // Can also be 'inline' Not sure if it matters + // Can also be 'inline' Not sure if it matters 'type' => $mime, 'content' => chunk_split(base64_encode($fileContent)), 'multipart' => 'mixed', @@ -1066,18 +1061,14 @@ class Email { if (! is_array($email)) { - $this->setErrorMessage(lang('Email.mustBeArray')); - - return false; + throw EmailException::forMustBeArray(); } foreach ($email as $val) { if (! $this->isValidEmail($val)) { - $this->setErrorMessage(lang('Email.invalidAddress', $val)); - - return false; + throw EmailException::forInvalidAddress($val); } } @@ -1341,7 +1332,7 @@ class Email $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; } - return; + return; case 'html': @@ -1369,7 +1360,7 @@ class Email if ($this->getProtocol() === 'mail') { - $this->headerStr .= $hdr; + $this->headerStr .= $hdr; } else { @@ -1381,7 +1372,7 @@ class Email $this->finalBody .= '--' . $boundary . '--'; } - return; + return; case 'plain-attach': @@ -1390,7 +1381,7 @@ class Email if ($this->getProtocol() === 'mail') { - $this->headerStr .= $hdr; + $this->headerStr .= $hdr; } $body .= $this->getMimeMessage() . $this->newline @@ -1403,7 +1394,7 @@ class Email $this->appendAttachments($body, $boundary); - break; + break; case 'html-attach': $alt_boundary = uniqid('B_ALT_', true); @@ -1435,7 +1426,7 @@ class Email if ($this->getProtocol() === 'mail') { - $this->headerStr .= $hdr; + $this->headerStr .= $hdr; } static::strlen($body) && $body .= $this->newline . $this->newline; @@ -1465,7 +1456,7 @@ class Email $this->appendAttachments($body, $atc_boundary, 'mixed'); } - break; + break; } $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; @@ -1540,7 +1531,7 @@ class Email // used literally, without encoding, as described in RFC 2049. // http://www.ietf.org/rfc/rfc2049.txt static $ascii_safe_chars = [ - // ' ( ) + , - . / : = ? + // ' ( ) + , - . / : = ? 39, 40, 41, @@ -1552,7 +1543,7 @@ class Email 58, 61, 63, - // numbers + // numbers 48, 49, 50, @@ -1563,7 +1554,7 @@ class Email 55, 56, 57, - // upper-case letters + // upper-case letters 65, 66, 67, @@ -1590,7 +1581,7 @@ class Email 88, 89, 90, - // lower-case letters + // lower-case letters 97, 98, 99, @@ -1735,10 +1726,10 @@ class Email // There are reports that iconv_mime_encode() might fail and return FALSE if ($output !== false) { - // iconv_mime_encode() will always put a header field name. - // We've passed it an empty one, but it still prepends our - // encoded string with ': ', so we need to strip it. - return static::substr($output, 2); + // iconv_mime_encode() will always put a header field name. + // We've passed it an empty one, but it still prepends our + // encoded string with ': ', so we need to strip it. + return static::substr($output, 2); } $chars = iconv_strlen($str, 'UTF-8'); @@ -1794,9 +1785,7 @@ class Email if (! isset($this->headers['From'])) { - $this->setErrorMessage(lang('Email.noFrom')); - - return false; + throw EmailException::forNoFrom(); } if ($this->replyToFlag === false) @@ -1807,9 +1796,7 @@ class Email if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) ) { - $this->setErrorMessage(lang('Email.noRecipients')); - - return false; + throw EmailException::forNoRecipients(); } $this->buildHeaders(); @@ -1947,8 +1934,7 @@ class Email if (! $success) { - $this->setErrorMessage(lang('Email.sendFailure' . ($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)))); - return false; + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); } $this->setErrorMessage(lang('Email.sent', [$protocol])); @@ -2049,10 +2035,7 @@ class Email if ($status !== 0) { - $this->setErrorMessage(lang('Email.exitStatus', [$status])); - $this->setErrorMessage(lang('Email.nosocket')); - - return false; + throw EmailException::forNosocket($status); } return true; @@ -2069,9 +2052,7 @@ class Email { if ($this->SMTPHost === '') { - $this->setErrorMessage(lang('Email.noHostname')); - - return false; + throw EmailException::forNoHostname(); } if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) @@ -2134,9 +2115,7 @@ class Email if (strpos($reply, '250') !== 0) { - $this->setErrorMessage(lang('Email.SMTPError', [$reply])); - - return false; + throw EmailException::forSMTPError($reply); } return true; @@ -2176,9 +2155,7 @@ class Email if (! is_resource($this->SMTPConnect)) { - $this->setErrorMessage(lang('Email.SMTPError', [$errno . ' ' . $errstr])); - - return false; + throw EmailException::forSMTPError($errno . ' ' . $errstr); } stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); @@ -2193,9 +2170,7 @@ class Email if ($crypto !== true) { - $this->setErrorMessage(lang('Email.SMTPError', $this->getSMTPData())); - - return false; + throw EmailException::forSMTPError($this->getSMTPData()); } } @@ -2227,15 +2202,15 @@ class Email } $resp = 250; - break; + break; case 'starttls': $this->sendData('STARTTLS'); $resp = 220; - break; + break; case 'from': $this->sendData('MAIL FROM:<' . $data . '>'); $resp = 250; - break; + break; case 'to': if ($this->DSN) { @@ -2246,19 +2221,19 @@ class Email $this->sendData('RCPT TO:<' . $data . '>'); } $resp = 250; - break; + break; case 'data': $this->sendData('DATA'); $resp = 354; - break; + break; case 'reset': $this->sendData('RSET'); $resp = 250; - break; + break; case 'quit': $this->sendData('QUIT'); $resp = 221; - break; + break; } $reply = $this->getSMTPData(); @@ -2267,9 +2242,7 @@ class Email if ((int) static::substr($reply, 0, 3) !== $resp) { - $this->setErrorMessage(lang('Email.SMTPError', [$reply])); - - return false; + throw EmailException::forSMTPError($reply); } if ($cmd === 'quit') @@ -2296,9 +2269,7 @@ class Email if ($this->SMTPUser === '' && $this->SMTPPass === '') { - $this->setErrorMessage(lang('lang:email.noSMTPAuth')); - - return false; + throw EmailException::forNoSMTPAuth(); } $this->sendData('AUTH LOGIN'); @@ -2310,9 +2281,7 @@ class Email } elseif (strpos($reply, '334') !== 0) { - $this->setErrorMessage(lang('Email.failedSMTPLogin', [$reply])); - - return false; + throw EmailException::forFailedSMTPLogin($reply); } $this->sendData(base64_encode($this->SMTPUser)); @@ -2320,9 +2289,7 @@ class Email if (strpos($reply, '334') !== 0) { - $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply])); - - return false; + throw EmailException::forSMTPAuthUsername($reply); } $this->sendData(base64_encode($this->SMTPPass)); @@ -2330,9 +2297,7 @@ class Email if (strpos($reply, '235') !== 0) { - $this->setErrorMessage(lang('Email.SMTPAuthPassword', [$reply])); - - return false; + throw EmailException::forSMTPAuthPassword($reply); } if ($this->SMTPKeepAlive) @@ -2385,9 +2350,7 @@ class Email if ($result === false) { - $this->setErrorMessage(lang('Email.SMTPDataFailure', $data)); - - return false; + throw EmailException::forSMTPDataFailure($data); } return true; diff --git a/system/Email/Exceptions/EmailException.php b/system/Email/Exceptions/EmailException.php new file mode 100644 index 0000000000..562f9fc421 --- /dev/null +++ b/system/Email/Exceptions/EmailException.php @@ -0,0 +1,84 @@ + Date: Mon, 17 Dec 2018 02:21:09 -0800 Subject: [PATCH 2/5] Email class isolated; testing started --- app/Config/Email.php | 152 +- system/Config/Services.php | 46 +- system/Email/Email.php | 1483 ++---------------- system/Email/Exceptions/EmailException.php | 3 +- system/Email/Handlers/BaseHandler.php | 1569 +++++++++++++++++++ system/Email/Handlers/MailHandler.php | 1633 +++++++++++++++++++ system/Email/Handlers/SMTPHandler.php | 1637 ++++++++++++++++++++ system/Email/Handlers/SendmailHandler.php | 1637 ++++++++++++++++++++ system/Email/TransporterInterface.php | 11 +- tests/system/Email/EmailTest.php | 55 + 10 files changed, 6809 insertions(+), 1417 deletions(-) create mode 100644 system/Email/Handlers/BaseHandler.php create mode 100644 system/Email/Handlers/MailHandler.php create mode 100644 system/Email/Handlers/SMTPHandler.php create mode 100644 system/Email/Handlers/SendmailHandler.php create mode 100644 tests/system/Email/EmailTest.php diff --git a/app/Config/Email.php b/app/Config/Email.php index 45661240a1..41415a2f79 100644 --- a/app/Config/Email.php +++ b/app/Config/Email.php @@ -1,7 +1,12 @@ -setLogger(static::logger(true)); + $protocolMap = [ + 'mail' => 'MailHandler', + 'sendmail' => 'SendmailHandler', + 'smtp' => 'SMTPHandler', + ]; - return $email; + $handler = '\\CodeIgniter\\Email\\Handlers\\' . ($protocolMap[$config->protocol ?? 'mail'] ); + $transporter = new $handler($config); + $transporter->setLogger(static::logger(true)); + + return $transporter; } //-------------------------------------------------------------------- @@ -317,13 +323,11 @@ class Services extends BaseService if ($getShared) { return static::getSharedInstance('language', $locale) - ->setLocale($locale); + ->setLocale($locale); } - $locale = ! empty($locale) - ? $locale - : static::request() - ->getLocale(); + $locale = ! empty($locale) ? $locale : static::request() + ->getLocale(); return new \CodeIgniter\Language\Language($locale); } @@ -509,10 +513,7 @@ class Services extends BaseService } return new \CodeIgniter\HTTP\IncomingRequest( - $config, - new \CodeIgniter\HTTP\URI(), - 'php://input', - new \CodeIgniter\HTTP\UserAgent() + $config, new \CodeIgniter\HTTP\URI(), 'php://input', new \CodeIgniter\HTTP\UserAgent() ); } @@ -565,7 +566,7 @@ class Services extends BaseService $response = new \CodeIgniter\HTTP\RedirectResponse($config); $response->setProtocolVersion(static::request() - ->getProtocolVersion()); + ->getProtocolVersion()); return $response; } @@ -827,5 +828,4 @@ class Services extends BaseService } //-------------------------------------------------------------------- - } diff --git a/system/Email/Email.php b/system/Email/Email.php index 4cbdd86477..d506d6e9ab 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -38,6 +38,7 @@ namespace CodeIgniter\Email; * @filesource */ +use CodeIgniter\Config\Services; use CodeIgniter\Email\Exceptions\EmailException; use Config\Mimes; use Psr\Log\LoggerAwareTrait; @@ -45,13 +46,7 @@ use Psr\Log\LoggerAwareTrait; /** * CodeIgniter Email Class * - * Permits email to be sent using Mail, Sendmail, or SMTP. - * - * @package CodeIgniter - * @subpackage Libraries - * @category Libraries - * @author EllisLab Dev Team - * @link https://codeigniter.com/user_guide/libraries/email.html + * Object model for an email to be sent using Mail, Sendmail, or SMTP. */ class Email { @@ -68,76 +63,6 @@ class Email */ public $fromName; - /** - * Used as the User-Agent and X-Mailer headers' value. - * - * @var string - */ - public $userAgent = 'CodeIgniter'; - - /** - * Path to the Sendmail binary. - * - * @var string - */ - public $mailPath = '/usr/sbin/sendmail'; // Sendmail path - - /** - * Which method to use for sending e-mails. - * - * @var string 'mail', 'sendmail' or 'smtp' - */ - public $protocol = 'mail'; // mail/sendmail/smtp - - /** - * STMP Server host - * - * @var string - */ - public $SMTPHost = ''; - - /** - * SMTP Username - * - * @var string - */ - public $SMTPUser = ''; - - /** - * SMTP Password - * - * @var string - */ - public $SMTPPass = ''; - - /** - * SMTP Server port - * - * @var integer - */ - public $SMTPPort = 25; - - /** - * SMTP connection timeout in seconds - * - * @var integer - */ - public $SMTPTimeout = 5; - - /** - * SMTP persistent connection - * - * @var boolean - */ - public $SMTPKeepAlive = false; - - /** - * SMTP Encryption - * - * @var string Empty, 'tls' or 'ssl' - */ - public $SMTPCrypto = ''; - /** * Whether to apply word-wrapping to the message body. * @@ -186,7 +111,7 @@ class Email * * @var integer 1-5 */ - public $priority = 3; // Default priority (1 - 5) + public $priority = 3; // Default priority (1 - 5) /** * Newline character sequence. @@ -195,7 +120,7 @@ class Email * @link http://www.ietf.org/rfc/rfc822.txt * @var string "\r\n" or "\n" */ - public $newline = "\n"; // Default newline. "\r\n" or "\n" (Use "\r\n" to comply with RFC 822) + public $newline = "\n"; // Default newline. "\r\n" or "\n" (Use "\r\n" to comply with RFC 822) /** * CRLF character sequence @@ -226,21 +151,6 @@ class Email */ public $sendMultipart = true; - /** - * Whether to send messages to BCC recipients in batches. - * - * @var boolean - */ - public $BCCBatchMode = false; - - /** - * BCC Batch max number size. - * - * @see Email::$BCCBatchMode - * @var integer - */ - public $BCCBatchSize = 200; - //-------------------------------------------------------------------- /** @@ -271,13 +181,6 @@ class Email */ protected $headerStr = ''; - /** - * SMTP Connection socket placeholder - * - * @var resource - */ - protected $SMTPConnect = ''; - /** * Mail encoding * @@ -285,13 +188,6 @@ class Email */ protected $encoding = '8bit'; - /** - * Whether to perform SMTP authentication - * - * @var boolean - */ - protected $SMTPAuth = false; - /** * Whether to send a Reply-To header * @@ -395,13 +291,6 @@ class Email 5 => '5 (Lowest)', ]; - /** - * mbstring.func_overload flag - * - * @var boolean - */ - protected static $func_overload; - /** * Logger instance to record error messages and awarnings. * @@ -409,6 +298,13 @@ class Email */ protected $logger; + /** + * Email transporter that this email is bound to + * + * @var \CodeIgniter\Email\TransporterInterface + */ + protected $handler; + //-------------------------------------------------------------------- /** @@ -420,9 +316,11 @@ class Email */ public function __construct($config = null) { + if ($config === null) + { + $config = new \Config\Email(); + } $this->initialize($config); - - log_message('info', 'Email Class Initialized'); } //-------------------------------------------------------------------- @@ -437,7 +335,6 @@ class Email public function initialize($config) { $this->clear(); - if ($config instanceof \Config\Email) { $config = get_object_vars($config); @@ -460,8 +357,8 @@ class Email } } - $this->charset = strtoupper($this->charset); - $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + $this->charset = strtoupper($this->charset); + $this->handler = Services::transporter($config); return $this; } @@ -477,16 +374,15 @@ class Email */ public function clear($clearAttachments = false) { - $this->subject = ''; - $this->body = ''; - $this->finalBody = ''; - $this->headerStr = ''; - $this->replyToFlag = false; - $this->recipients = []; - $this->CCArray = []; - $this->BCCArray = []; - $this->headers = []; - $this->debugMessage = []; + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; $this->setHeader('Date', $this->setDate()); @@ -541,7 +437,6 @@ class Email } $this->setHeader('From', $name . ' <' . $from . '>'); - isset($returnPath) || $returnPath = $from; $this->setHeader('Return-Path', '<' . $returnPath . '>'); @@ -550,6 +445,83 @@ class Email //-------------------------------------------------------------------- + /** + * Prep Q Encoding + * + * Performs "Q Encoding" on a string for use in email headers. + * It's related but not identical to quoted-printable, so it has its + * own method. + * + * @param string $str + * + * @return string + */ + protected function prepQEncoding($str) + { + $str = str_replace(["\r", "\n"], '', $str); + + if ($this->charset === 'UTF-8') + { + // Note: We used to have mb_encode_mimeheader() as the first choice + // here, but it turned out to be buggy and unreliable. DO NOT + // re-add it! -- Narf + if (extension_loaded('iconv')) + { + $output = @iconv_mime_encode( + '', $str, [ + 'scheme' => 'Q', + 'line-length' => 76, + 'input-charset' => $this->charset, + 'output-charset' => $this->charset, + 'line-break-chars' => $this->CRLF, + ]); + + // There are reports that iconv_mime_encode() might fail and return FALSE + if ($output !== false) + { + // iconv_mime_encode() will always put a header field name. + // We've passed it an empty one, but it still prepends our + // encoded string with ': ', so we need to strip it. + return mb_substr($output, 2); + } + + $chars = iconv_strlen($str, 'UTF-8'); + } + elseif (extension_loaded('mbstring')) + { + $chars = mb_strlen($str, 'UTF-8'); + } + } + + // We might already have this set for UTF-8 + isset($chars) || $chars = mb_strlen($str); + + $output = '=?' . $this->charset . '?Q?'; + for ($i = 0, $length = mb_strlen($output); $i < $chars; $i ++) + { + $chr = ($this->charset === 'UTF-8' && ICONV_ENABLED === true) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); + + // RFC 2045 sets a limit of 76 characters per line. + // We'll append ?= to the end of each line though. + if ($length + ($l = mb_strlen($chr)) > 74) + { + $output .= '?=' . $this->CRLF // EOL + . ' =?' . $this->charset . '?Q?' . $chr; // New line + $length = 6 + mb_strlen($this->charset) + $l; // Reset the length for the new line + } + else + { + $output .= $chr; + $length += $l; + } + } + + // End the header + return $output . '?='; + } + + //-------------------------------------------------------------------- + /** * Set Reply-to * @@ -609,7 +581,7 @@ class Email $this->validateEmail($to); } - if ($this->getProtocol() !== 'mail') + if ($this->handler->getProtocol() !== 'mail') { $this->setHeader('To', implode(', ', $to)); } @@ -639,7 +611,7 @@ class Email $this->setHeader('Cc', implode(', ', $cc)); - if ($this->getProtocol() === 'smtp') + if ($this->handler->getProtocol() === 'smtp') { $this->CCArray = $cc; } @@ -672,7 +644,7 @@ class Email $this->validateEmail($bcc); } - if ($this->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) + if ($this->handler->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) { $this->BCCArray = $bcc; } @@ -722,14 +694,14 @@ class Email /** * Assign file attachments * - * @param string $file Can be local path, URL or buffered content - * @param string $disposition 'attachment' - * @param string|null $newName - * @param string $mime Empty if $file is buffered content + * @param string $file Can be local path, URL or buffered content + * @param string $disposition 'attachment' + * @param string $newName + * @param string $mime Empty if $file is buffered content * * @return Email */ - public function attach(string $file, string $disposition = '', ?string $newName = null, string $mime = '') + public function attach(string $file, string $disposition = '', string $newName = '', string $mime = '') { if ($mime === '') { @@ -771,33 +743,6 @@ class Email //-------------------------------------------------------------------- - /** - * Set and return attachment Content-ID - * - * Useful for attached inline pictures - * - * @param string $filename - * - * @return string - */ - public function setAttachmentCID($filename) - { - for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) - { - if ($this->attachments[$i]['name'][0] === $filename) - { - $this->attachments[$i]['multipart'] = 'related'; - $this->attachments[$i]['cid'] = uniqid(basename($this->attachments[$i]['name'][0]) . '@', true); - - return $this->attachments[$i]['cid']; - } - } - - return false; - } - - //-------------------------------------------------------------------- - /** * Add a Header Item * @@ -882,22 +827,6 @@ class Email //-------------------------------------------------------------------- - /** - * Set Protocol - * - * @param string $protocol - * - * @return Email - */ - public function setProtocol($protocol = 'mail') - { - $this->protocol = in_array($protocol, $this->protocols, true) ? strtolower($protocol) : 'mail'; - - return $this; - } - - //-------------------------------------------------------------------- - /** * Set Priority * @@ -960,21 +889,6 @@ class Email //-------------------------------------------------------------------- - /** - * Get Mail Protocol - * - * @return string - */ - protected function getProtocol() - { - $this->protocol = strtolower($this->protocol); - in_array($this->protocol, $this->protocols, true) || $this->protocol = 'mail'; - - return $this->protocol; - } - - //-------------------------------------------------------------------- - /** * Get Mail Encoding * @@ -1061,7 +975,7 @@ class Email { if (! is_array($email)) { - throw EmailException::forMustBeArray(); + throw EmailException::forMustBeArray(); } foreach ($email as $val) @@ -1088,8 +1002,8 @@ class Email { if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) { - $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( - static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + $email = mb_substr($email, 0, ++ $atpos, '8bit') . idn_to_ascii( + mb_substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 ); } @@ -1205,7 +1119,7 @@ class Email { // Is the line within the allowed character count? // If so we'll join it to the output and continue - if (static::strlen($line) <= $charlim) + if (mb_strlen($line) <= $charlim) { $output .= $line . $this->newline; continue; @@ -1221,10 +1135,10 @@ class Email } // Trim the word down - $temp .= static::substr($line, 0, $charlim - 1); - $line = static::substr($line, $charlim - 1); + $temp .= mb_substr($line, 0, $charlim - 1, '8bit'); + $line = mb_substr($line, $charlim - 1); } - while (static::strlen($line) > $charlim); + while (mb_strlen($line) > $charlim); // If $temp contains data it means we had to split up an over-length // word into smaller chunks so we'll add it back to our current line @@ -1250,525 +1164,6 @@ class Email //-------------------------------------------------------------------- - /** - * Build final headers - */ - protected function buildHeaders() - { - $this->setHeader('User-Agent', $this->userAgent); - $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From'])); - $this->setHeader('X-Mailer', $this->userAgent); - $this->setHeader('X-Priority', $this->priorities[$this->priority]); - $this->setHeader('Message-ID', $this->getMessageID()); - $this->setHeader('Mime-Version', '1.0'); - } - - //-------------------------------------------------------------------- - - /** - * Write Headers as a string - */ - protected function writeHeaders() - { - if ($this->protocol === 'mail') - { - if (isset($this->headers['Subject'])) - { - $this->subject = $this->headers['Subject']; - unset($this->headers['Subject']); - } - } - - reset($this->headers); - $this->headerStr = ''; - - foreach ($this->headers as $key => $val) - { - $val = trim($val); - - if ($val !== '') - { - $this->headerStr .= $key . ': ' . $val . $this->newline; - } - } - - if ($this->getProtocol() === 'mail') - { - $this->headerStr = rtrim($this->headerStr); - } - } - - //-------------------------------------------------------------------- - - /** - * Build Final Body and attachments - */ - protected function buildMessage() - { - if ($this->wordWrap === true && $this->mailType !== 'html') - { - $this->body = $this->wordWrap($this->body); - } - - $this->writeHeaders(); - - $hdr = ($this->getProtocol() === 'mail') ? $this->newline : ''; - $body = ''; - - switch ($this->getContentType()) - { - case 'plain': - - $hdr .= 'Content-Type: text/plain; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: ' . $this->getEncoding(); - - if ($this->getProtocol() === 'mail') - { - $this->headerStr .= $hdr; - $this->finalBody = $this->body; - } - else - { - $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; - } - - return; - - case 'html': - - if ($this->sendMultipart === false) - { - $hdr .= 'Content-Type: text/html; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: quoted-printable'; - } - else - { - $boundary = uniqid('B_ALT_', true); - $hdr .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; - - $body .= $this->getMimeMessage() . $this->newline . $this->newline - . '--' . $boundary . $this->newline - . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline - . $this->getAltMessage() . $this->newline . $this->newline - . '--' . $boundary . $this->newline - . 'Content-Type: text/html; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline; - } - - $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline; - - if ($this->getProtocol() === 'mail') - { - $this->headerStr .= $hdr; - } - else - { - $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody; - } - - if ($this->sendMultipart !== false) - { - $this->finalBody .= '--' . $boundary . '--'; - } - - return; - - case 'plain-attach': - - $boundary = uniqid('B_ATC_', true); - $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; - - if ($this->getProtocol() === 'mail') - { - $this->headerStr .= $hdr; - } - - $body .= $this->getMimeMessage() . $this->newline - . $this->newline - . '--' . $boundary . $this->newline - . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline - . $this->newline - . $this->body . $this->newline . $this->newline; - - $this->appendAttachments($body, $boundary); - - break; - case 'html-attach': - - $alt_boundary = uniqid('B_ALT_', true); - $last_boundary = null; - - if ($this->attachmentsHaveMultipart('mixed')) - { - $atc_boundary = uniqid('B_ATC_', true); - $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atc_boundary . '"'; - $last_boundary = $atc_boundary; - } - - if ($this->attachmentsHaveMultipart('related')) - { - $rel_boundary = uniqid('B_REL_', true); - $rel_boundary_header = 'Content-Type: multipart/related; boundary="' . $rel_boundary . '"'; - - if (isset($last_boundary)) - { - $body .= '--' . $last_boundary . $this->newline . $rel_boundary_header; - } - else - { - $hdr .= $rel_boundary_header; - } - - $last_boundary = $rel_boundary; - } - - if ($this->getProtocol() === 'mail') - { - $this->headerStr .= $hdr; - } - - static::strlen($body) && $body .= $this->newline . $this->newline; - $body .= $this->getMimeMessage() . $this->newline . $this->newline - . '--' . $last_boundary . $this->newline - . 'Content-Type: multipart/alternative; boundary="' . $alt_boundary . '"' . $this->newline . $this->newline - . '--' . $alt_boundary . $this->newline - . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline - . $this->getAltMessage() . $this->newline . $this->newline - . '--' . $alt_boundary . $this->newline - . 'Content-Type: text/html; charset=' . $this->charset . $this->newline - . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline - . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline - . '--' . $alt_boundary . '--' . $this->newline . $this->newline; - - if (! empty($rel_boundary)) - { - $body .= $this->newline . $this->newline; - $this->appendAttachments($body, $rel_boundary, 'related'); - } - - // multipart/mixed attachments - if (! empty($atc_boundary)) - { - $body .= $this->newline . $this->newline; - $this->appendAttachments($body, $atc_boundary, 'mixed'); - } - - break; - } - - $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; - } - - //-------------------------------------------------------------------- - - protected function attachmentsHaveMultipart($type) - { - foreach ($this->attachments as &$attachment) - { - if ($attachment['multipart'] === $type) - { - return true; - } - } - - return false; - } - - //-------------------------------------------------------------------- - - /** - * Prepares attachment string - * - * @param string &$body Message body to append to - * @param string $boundary Multipart boundary - * @param string|null $multipart When provided, only attachments of this type will be processed - * - * @return string - */ - protected function appendAttachments(&$body, $boundary, $multipart = null) - { - for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) - { - if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) - { - continue; - } - - $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); - - $body .= '--' . $boundary . $this->newline - . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline - . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline - . 'Content-Transfer-Encoding: base64' . $this->newline - . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) - . $this->newline - . $this->attachments[$i]['content'] . $this->newline; - } - - // $name won't be set if no attachments were appended, - // and therefore a boundary wouldn't be necessary - empty($name) || $body .= '--' . $boundary . '--'; - } - - //-------------------------------------------------------------------- - - /** - * Prep Quoted Printable - * - * Prepares string for Quoted-Printable Content-Transfer-Encoding - * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt - * - * @param string $str - * - * @return string - */ - protected function prepQuotedPrintable($str) - { - // ASCII code numbers for "safe" characters that can always be - // used literally, without encoding, as described in RFC 2049. - // http://www.ietf.org/rfc/rfc2049.txt - static $ascii_safe_chars = [ - // ' ( ) + , - . / : = ? - 39, - 40, - 41, - 43, - 44, - 45, - 46, - 47, - 58, - 61, - 63, - // numbers - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - // upper-case letters - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - // lower-case letters - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120, - 121, - 122, - ]; - - // We are intentionally wrapping so mail servers will encode characters - // properly and MUAs will behave, so {unwrap} must go! - $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); - - // RFC 2045 specifies CRLF as "\r\n". - // However, many developers choose to override that and violate - // the RFC rules due to (apparently) a bug in MS Exchange, - // which only works with "\n". - if ($this->CRLF === "\r\n") - { - return quoted_printable_encode($str); - } - - // Reduce multiple spaces & remove nulls - $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); - - // Standardize newlines - if (strpos($str, "\r") !== false) - { - $str = str_replace(["\r\n", "\r"], "\n", $str); - } - - $escape = '='; - $output = ''; - - foreach (explode("\n", $str) as $line) - { - $length = static::strlen($line); - $temp = ''; - - // Loop through each character in the line to add soft-wrap - // characters at the end of a line " =\r\n" and add the newly - // processed line(s) to the output (see comment on $crlf class property) - for ($i = 0; $i < $length; $i ++) - { - // Grab the next character - $char = $line[$i]; - $ascii = ord($char); - - // Convert spaces and tabs but only if it's the end of the line - if ($ascii === 32 || $ascii === 9) - { - if ($i === ($length - 1)) - { - $char = $escape . sprintf('%02s', dechex($ascii)); - } - } - // DO NOT move this below the $ascii_safe_chars line! - // - // = (equals) signs are allowed by RFC2049, but must be encoded - // as they are the encoding delimiter! - elseif ($ascii === 61) - { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D - } - elseif (! in_array($ascii, $ascii_safe_chars, true)) - { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); - } - - // If we're at the character limit, add the line to the output, - // reset our temp variable, and keep on chuggin' - if ((static::strlen($temp) + static::strlen($char)) >= 76) - { - $output .= $temp . $escape . $this->CRLF; - $temp = ''; - } - - // Add the character to our temporary line - $temp .= $char; - } - - // Add our completed line to the output - $output .= $temp . $this->CRLF; - } - - // get rid of extra CRLF tacked onto the end - return static::substr($output, 0, static::strlen($this->CRLF) * -1); - } - - //-------------------------------------------------------------------- - - /** - * Prep Q Encoding - * - * Performs "Q Encoding" on a string for use in email headers. - * It's related but not identical to quoted-printable, so it has its - * own method. - * - * @param string $str - * - * @return string - */ - protected function prepQEncoding($str) - { - $str = str_replace(["\r", "\n"], '', $str); - - if ($this->charset === 'UTF-8') - { - // Note: We used to have mb_encode_mimeheader() as the first choice - // here, but it turned out to be buggy and unreliable. DO NOT - // re-add it! -- Narf - if (extension_loaded('iconv')) - { - $output = @iconv_mime_encode( - '', $str, [ - 'scheme' => 'Q', - 'line-length' => 76, - 'input-charset' => $this->charset, - 'output-charset' => $this->charset, - 'line-break-chars' => $this->CRLF, - ]); - - // There are reports that iconv_mime_encode() might fail and return FALSE - if ($output !== false) - { - // iconv_mime_encode() will always put a header field name. - // We've passed it an empty one, but it still prepends our - // encoded string with ': ', so we need to strip it. - return static::substr($output, 2); - } - - $chars = iconv_strlen($str, 'UTF-8'); - } - elseif (extension_loaded('mbstring')) - { - $chars = mb_strlen($str, 'UTF-8'); - } - } - - // We might already have this set for UTF-8 - isset($chars) || $chars = static::strlen($str); - - $output = '=?' . $this->charset . '?Q?'; - for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) - { - $chr = ($this->charset === 'UTF-8' && ICONV_ENABLED === true) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); - - // RFC 2045 sets a limit of 76 characters per line. - // We'll append ?= to the end of each line though. - if ($length + ($l = static::strlen($chr)) > 74) - { - $output .= '?=' . $this->CRLF // EOL - . ' =?' . $this->charset . '?Q?' . $chr; // New line - $length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line - } - else - { - $output .= $chr; - $length += $l; - } - } - - // End the header - return $output . '?='; - } - - //-------------------------------------------------------------------- - /** * Send Email * @@ -1778,43 +1173,7 @@ class Email */ public function send($autoClear = true) { - if (! isset($this->headers['From']) && ! empty($this->fromEmail)) - { - $this->setFrom($this->fromEmail, $this->fromName); - } - - if (! isset($this->headers['From'])) - { - throw EmailException::forNoFrom(); - } - - if ($this->replyToFlag === false) - { - $this->setReplyTo($this->headers['From']); - } - - if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) - ) - { - throw EmailException::forNoRecipients(); - } - - $this->buildHeaders(); - - if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) - { - $this->batchBCCSend(); - - if ($autoClear) - { - $this->clear(); - } - - return true; - } - - $this->buildMessage(); - $result = $this->spoolEmail(); + $result = $this->handler->send($this, $autoClear); if ($result && $autoClear) { @@ -1844,14 +1203,14 @@ class Email if ($i === $float) { - $chunk[] = static::substr($set, 1); + $chunk[] = mb_substr($set, 1); $float += $this->BCCBatchSize; $set = ''; } if ($i === $c - 1) { - $chunk[] = static::substr($set, 1); + $chunk[] = mb_substr($set, 1); } } @@ -1911,538 +1270,6 @@ class Email //-------------------------------------------------------------------- - /** - * Spool mail to the mail server - * - * @return boolean - */ - protected function spoolEmail() - { - $this->unwrapSpecials(); - - $protocol = $this->getProtocol(); - $method = 'sendWith' . ucfirst($protocol); - try - { - $success = $this->$method(); - } - catch (\ErrorException $e) - { - $success = false; - $this->logger->error('Email: ' . $method . ' throwed ' . $e->getMessage()); - } - - if (! $success) - { - throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); - } - - $this->setErrorMessage(lang('Email.sent', [$protocol])); - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Validate email for shell - * - * Applies stricter, shell-safe validation to email addresses. - * Introduced to prevent RCE via sendmail's -f option. - * - * @see https://github.com/codeigniter4/CodeIgniter/issues/4963 - * @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36 - * @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain - * - * Credits for the base concept go to Paul Buonopane - * - * @param string &$email - * - * @return boolean - */ - protected function validateEmailForShell(&$email) - { - if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) - { - $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( - static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 - ); - } - - return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); - } - - //-------------------------------------------------------------------- - - /** - * Send using mail() - * - * @return boolean - */ - protected function sendWithMail() - { - if (is_array($this->recipients)) - { - $this->recipients = implode(', ', $this->recipients); - } - - // _validate_email_for_shell() below accepts by reference, - // so this needs to be assigned to a variable - $from = $this->cleanEmail($this->headers['Return-Path']); - - if (! $this->validateEmailForShell($from)) - { - return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); - } - - // most documentation of sendmail using the "-f" flag lacks a space after it, however - // we've encountered servers that seem to require it to be in place. - return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); - } - - //-------------------------------------------------------------------- - - /** - * Send using Sendmail - * - * @return boolean - */ - protected function sendWithSendmail() - { - // _validate_email_for_shell() below accepts by reference, - // so this needs to be assigned to a variable - $from = $this->cleanEmail($this->headers['From']); - if ($this->validateEmailForShell($from)) - { - $from = '-f ' . $from; - } - else - { - $from = ''; - } - - // is popen() enabled? - if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) - { - // server probably has popen disabled, so nothing we can do to get a verbose error. - return false; - } - - fputs($fp, $this->headerStr); - fputs($fp, $this->finalBody); - - $status = pclose($fp); - - if ($status !== 0) - { - throw EmailException::forNosocket($status); - } - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Send using SMTP - * - * @return boolean - */ - protected function sendWithSmtp() - { - if ($this->SMTPHost === '') - { - throw EmailException::forNoHostname(); - } - - if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) - { - return false; - } - - if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) - { - $this->SMTPEnd(); - - return false; - } - - foreach ($this->recipients as $val) - { - if (! $this->sendCommand('to', $val)) - { - $this->SMTPEnd(); - - return false; - } - } - - foreach ($this->CCArray as $val) - { - if ($val !== '' && ! $this->sendCommand('to', $val)) - { - $this->SMTPEnd(); - - return false; - } - } - - foreach ($this->BCCArray as $val) - { - if ($val !== '' && ! $this->sendCommand('to', $val)) - { - $this->SMTPEnd(); - - return false; - } - } - - if (! $this->sendCommand('data')) - { - $this->SMTPEnd(); - - return false; - } - - // perform dot transformation on any lines that begin with a dot - $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody)); - - $this->sendData('.'); - $reply = $this->getSMTPData(); - $this->setErrorMessage($reply); - - $this->SMTPEnd(); - - if (strpos($reply, '250') !== 0) - { - throw EmailException::forSMTPError($reply); - } - - return true; - } - - //-------------------------------------------------------------------- - - /** - * SMTP End - * - * Shortcut to send RSET or QUIT depending on keep-alive - */ - protected function SMTPEnd() - { - $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit'); - } - - //-------------------------------------------------------------------- - - /** - * SMTP Connect - * - * @return string - */ - protected function SMTPConnect() - { - if (is_resource($this->SMTPConnect)) - { - return true; - } - - $ssl = ($this->SMTPCrypto === 'ssl') ? 'ssl://' : ''; - - $this->SMTPConnect = fsockopen( - $ssl . $this->SMTPHost, $this->SMTPPort, $errno, $errstr, $this->SMTPTimeout - ); - - if (! is_resource($this->SMTPConnect)) - { - throw EmailException::forSMTPError($errno . ' ' . $errstr); - } - - stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); - $this->setErrorMessage($this->getSMTPData()); - - if ($this->SMTPCrypto === 'tls') - { - $this->sendCommand('hello'); - $this->sendCommand('starttls'); - - $crypto = stream_socket_enable_crypto($this->SMTPConnect, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); - - if ($crypto !== true) - { - throw EmailException::forSMTPError($this->getSMTPData()); - } - } - - return $this->sendCommand('hello'); - } - - //-------------------------------------------------------------------- - - /** - * Send SMTP command - * - * @param string $cmd - * @param string $data - * - * @return boolean - */ - protected function sendCommand($cmd, $data = '') - { - switch ($cmd) - { - case 'hello': - if ($this->SMTPAuth || $this->getEncoding() === '8bit') - { - $this->sendData('EHLO ' . $this->getHostname()); - } - else - { - $this->sendData('HELO ' . $this->getHostname()); - } - - $resp = 250; - break; - case 'starttls': - $this->sendData('STARTTLS'); - $resp = 220; - break; - case 'from': - $this->sendData('MAIL FROM:<' . $data . '>'); - $resp = 250; - break; - case 'to': - if ($this->DSN) - { - $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data); - } - else - { - $this->sendData('RCPT TO:<' . $data . '>'); - } - $resp = 250; - break; - case 'data': - $this->sendData('DATA'); - $resp = 354; - break; - case 'reset': - $this->sendData('RSET'); - $resp = 250; - break; - case 'quit': - $this->sendData('QUIT'); - $resp = 221; - break; - } - - $reply = $this->getSMTPData(); - - $this->debugMessage[] = '
' . $cmd . ': ' . $reply . '
'; - - if ((int) static::substr($reply, 0, 3) !== $resp) - { - throw EmailException::forSMTPError($reply); - } - - if ($cmd === 'quit') - { - fclose($this->SMTPConnect); - } - - return true; - } - - //-------------------------------------------------------------------- - - /** - * SMTP Authenticate - * - * @return boolean - */ - protected function SMTPAuthenticate() - { - if (! $this->SMTPAuth) - { - return true; - } - - if ($this->SMTPUser === '' && $this->SMTPPass === '') - { - throw EmailException::forNoSMTPAuth(); - } - - $this->sendData('AUTH LOGIN'); - $reply = $this->getSMTPData(); - - if (strpos($reply, '503') === 0) // Already authenticated - { - return true; - } - elseif (strpos($reply, '334') !== 0) - { - throw EmailException::forFailedSMTPLogin($reply); - } - - $this->sendData(base64_encode($this->SMTPUser)); - $reply = $this->getSMTPData(); - - if (strpos($reply, '334') !== 0) - { - throw EmailException::forSMTPAuthUsername($reply); - } - - $this->sendData(base64_encode($this->SMTPPass)); - $reply = $this->getSMTPData(); - - if (strpos($reply, '235') !== 0) - { - throw EmailException::forSMTPAuthPassword($reply); - } - - if ($this->SMTPKeepAlive) - { - $this->SMTPAuth = false; - } - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Send SMTP data - * - * @param string $data - * - * @return boolean - */ - protected function sendData($data) - { - $data .= $this->newline; - for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) - { - if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) - { - break; - } - // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951 - elseif ($result === 0) - { - if ($timestamp === 0) - { - $timestamp = time(); - } - elseif ($timestamp < (time() - $this->SMTPTimeout)) - { - $result = false; - break; - } - - usleep(250000); - continue; - } - else - { - $timestamp = 0; - } - } - - if ($result === false) - { - throw EmailException::forSMTPDataFailure($data); - } - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Get SMTP data - * - * @return string - */ - protected function getSMTPData() - { - $data = ''; - - while ($str = fgets($this->SMTPConnect, 512)) - { - $data .= $str; - - if ($str[3] === ' ') - { - break; - } - } - - return $data; - } - - //-------------------------------------------------------------------- - - /** - * Get Hostname - * - * There are only two legal types of hostname - either a fully - * qualified domain name (eg: "mail.example.com") or an IP literal - * (eg: "[1.2.3.4]"). - * - * @link https://tools.ietf.org/html/rfc5321#section-2.3.5 - * @link http://cbl.abuseat.org/namingproblems.html - * - * @return string - */ - protected function getHostname() - { - if (isset($_SERVER['SERVER_NAME'])) - { - return $_SERVER['SERVER_NAME']; - } - - return isset($_SERVER['SERVER_ADDR']) ? '[' . $_SERVER['SERVER_ADDR'] . ']' : '[127.0.0.1]'; - } - - //-------------------------------------------------------------------- - - /** - * Get Debug Message - * - * @param array $include List of raw data chunks to include in the output - * Valid options are: 'headers', 'subject', 'body' - * - * @return string - */ - public function printDebugger($include = ['headers', 'subject', 'body']) - { - $msg = implode('', $this->debugMessage); - - // Determine which parts of our raw data needs to be printed - $raw_data = ''; - is_array($include) || $include = [$include]; - - in_array('headers', $include, true) && $raw_data = htmlspecialchars($this->headerStr) . "\n"; - in_array('subject', $include, true) && $raw_data .= htmlspecialchars($this->subject) . "\n"; - in_array('body', $include, true) && $raw_data .= htmlspecialchars($this->finalBody); - - return $msg . ($raw_data === '' ? '' : '
' . $raw_data . '
'); - } - - //-------------------------------------------------------------------- - - /** - * Set Message - * - * @param string $msg - */ - protected function setErrorMessage($msg) - { - $this->debugMessage[] = $msg . '
'; - } - - //-------------------------------------------------------------------- - /** * Mime Types * @@ -2460,46 +1287,62 @@ class Email //-------------------------------------------------------------------- /** - * Destructor + * Lookup an email header and return its value. + * + * @param string $key + * + * @return string|null */ - public function __destruct() + public function getHeader(string $key = ''): ?string { - is_resource($this->SMTPConnect) && $this->sendCommand('quit'); + return $this->headers[$key] ?? null; } //-------------------------------------------------------------------- /** - * Byte-safe strlen() + * Magic method to all protected/private class properties to be easily set, + * if a property has a setter method. * - * @param string $str + * @param string $key + * @param null $value * - * @return integer + * @return $this */ - protected static function strlen($str) + public function __set(string $key, $value = null) { - return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str); - } - - //-------------------------------------------------------------------- - - /** - * Byte-safe substr() - * - * @param string $str - * @param integer $start - * @param integer|null $length - * - * @return string - */ - protected static function substr($str, $start, $length = null) - { - if (static::$func_overload) + $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); + if (method_exists($this, $method)) { - return mb_substr($str, $start, $length, '8bit'); + $this->$method($value); } - return isset($length) ? substr($str, $start, $length) : substr($str, $start); + return $this; + } + + /** + * Magic method to allow retrieval of protected + * class properties either by their name, or through a `getCamelCasedProperty()` + * method. + * + * @param string $key + * + * @return mixed + */ + public function __get(string $key) + { + // Convert to CamelCase for the method + $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); + if (method_exists($this, $method)) + { + $result = $this->$method(); + } + else if (property_exists($this, $key)) + { + $result = $this->$key; + } + + return $result ?? null; } } diff --git a/system/Email/Exceptions/EmailException.php b/system/Email/Exceptions/EmailException.php index 562f9fc421..f64a39eae2 100644 --- a/system/Email/Exceptions/EmailException.php +++ b/system/Email/Exceptions/EmailException.php @@ -1,4 +1,5 @@ - '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + + /** + * mbstring.func_overload flag + * + * @var boolean + */ + protected static $func_overload; + + /** + * Logger instance to record error messages and awarnings. + * + * @var \PSR\Log\LoggerInterface + */ + protected $logger; + + //-------------------------------------------------------------------- + + /** + * Constructor - Sets Email Preferences + * + * The constructor can be passed an array of config values + * + * @param array|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + + log_message('info', 'Email Class Initialized'); + } + + public function getProtocol() : string + { + return $this->protocol; + } + + //-------------------------------------------------------------------- + + /** + * Initialize preferences + * + * @param array|\Config\Email $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + + if ($config instanceof \Config\Email) + { + $config = get_object_vars($config); + } + + foreach (get_class_vars(get_class($this)) as $key => $value) + { + if (property_exists($this, $key) && isset($config[$key])) + { + $method = 'set' . ucfirst($key); + + if (method_exists($this, $method)) + { + $this->$method($config[$key]); + } + } + } + + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + + return $this; + } + + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + //-------------------------------------------------------------------- + + /** + * Initialize the Email Data + * + * @param boolean $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + + $this->setHeader('Date', $this->setDate()); + + if ($clearAttachments !== false) + { + $this->attachments = []; + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Build final headers + */ + protected function buildHeaders() + { + $this->setHeader('User-Agent', $this->userAgent); + $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From'])); + $this->setHeader('X-Mailer', $this->userAgent); + $this->setHeader('X-Priority', $this->priorities[$this->priority]); + $this->setHeader('Message-ID', $this->getMessageID()); + $this->setHeader('Mime-Version', '1.0'); + } + + //-------------------------------------------------------------------- + + /** + * Write Headers as a string + */ + protected function writeHeaders() + { + if ($this->protocol === 'mail') + { + if (isset($this->headers['Subject'])) + { + $this->subject = $this->headers['Subject']; + unset($this->headers['Subject']); + } + } + + reset($this->headers); + $this->headerStr = ''; + + foreach ($this->headers as $key => $val) + { + $val = trim($val); + + if ($val !== '') + { + $this->headerStr .= $key . ': ' . $val . $this->newline; + } + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr = rtrim($this->headerStr); + } + } + + //-------------------------------------------------------------------- + + /** + * Build Final Body and attachments + */ + protected function buildMessage() + { + if ($this->wordWrap === true && $this->mailType !== 'html') + { + $this->body = $this->wordWrap($this->body); + } + + $this->writeHeaders(); + + $hdr = ($this->getProtocol() === 'mail') ? $this->newline : ''; + $body = ''; + + switch ($this->getContentType()) + { + case 'plain': + + $hdr .= 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding(); + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + $this->finalBody = $this->body; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; + } + + return; + + case 'html': + + if ($this->sendMultipart === false) + { + $hdr .= 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable'; + } + else + { + $boundary = uniqid('B_ALT_', true); + $hdr .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; + + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline; + } + + $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody; + } + + if ($this->sendMultipart !== false) + { + $this->finalBody .= '--' . $boundary . '--'; + } + + return; + + case 'plain-attach': + + $boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + $body .= $this->getMimeMessage() . $this->newline + . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline + . $this->newline + . $this->body . $this->newline . $this->newline; + + $this->appendAttachments($body, $boundary); + + break; + case 'html-attach': + + $alt_boundary = uniqid('B_ALT_', true); + $last_boundary = null; + + if ($this->attachmentsHaveMultipart('mixed')) + { + $atc_boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atc_boundary . '"'; + $last_boundary = $atc_boundary; + } + + if ($this->attachmentsHaveMultipart('related')) + { + $rel_boundary = uniqid('B_REL_', true); + $rel_boundary_header = 'Content-Type: multipart/related; boundary="' . $rel_boundary . '"'; + + if (isset($last_boundary)) + { + $body .= '--' . $last_boundary . $this->newline . $rel_boundary_header; + } + else + { + $hdr .= $rel_boundary_header; + } + + $last_boundary = $rel_boundary; + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + static::strlen($body) && $body .= $this->newline . $this->newline; + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $last_boundary . $this->newline + . 'Content-Type: multipart/alternative; boundary="' . $alt_boundary . '"' . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline + . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline + . '--' . $alt_boundary . '--' . $this->newline . $this->newline; + + if (! empty($rel_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $rel_boundary, 'related'); + } + + // multipart/mixed attachments + if (! empty($atc_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $atc_boundary, 'mixed'); + } + + break; + } + + $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; + } + + //-------------------------------------------------------------------- + + /** + * Prepares attachment string + * + * @param string &$body Message body to append to + * @param string $boundary Multipart boundary + * @param string|null $multipart When provided, only attachments of this type will be processed + * + * @return string + */ + protected function appendAttachments(&$body, $boundary, $multipart = null) + { + for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + { + if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) + { + continue; + } + + $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); + + $body .= '--' . $boundary . $this->newline + . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline + . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline + . 'Content-Transfer-Encoding: base64' . $this->newline + . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) + . $this->newline + . $this->attachments[$i]['content'] . $this->newline; + } + + // $name won't be set if no attachments were appended, + // and therefore a boundary wouldn't be necessary + empty($name) || $body .= '--' . $boundary . '--'; + } + + //-------------------------------------------------------------------- + + /** + * Prep Quoted Printable + * + * Prepares string for Quoted-Printable Content-Transfer-Encoding + * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt + * + * @param string $str + * + * @return string + */ + protected function prepQuotedPrintable($str) + { + // ASCII code numbers for "safe" characters that can always be + // used literally, without encoding, as described in RFC 2049. + // http://www.ietf.org/rfc/rfc2049.txt + static $ascii_safe_chars = [ + // ' ( ) + , - . / : = ? + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 47, + 58, + 61, + 63, + // numbers + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + // upper-case letters + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + // lower-case letters + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + ]; + + // We are intentionally wrapping so mail servers will encode characters + // properly and MUAs will behave, so {unwrap} must go! + $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); + + // RFC 2045 specifies CRLF as "\r\n". + // However, many developers choose to override that and violate + // the RFC rules due to (apparently) a bug in MS Exchange, + // which only works with "\n". + if ($this->CRLF === "\r\n") + { + return quoted_printable_encode($str); + } + + // Reduce multiple spaces & remove nulls + $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); + + // Standardize newlines + if (strpos($str, "\r") !== false) + { + $str = str_replace(["\r\n", "\r"], "\n", $str); + } + + $escape = '='; + $output = ''; + + foreach (explode("\n", $str) as $line) + { + $length = static::strlen($line); + $temp = ''; + + // Loop through each character in the line to add soft-wrap + // characters at the end of a line " =\r\n" and add the newly + // processed line(s) to the output (see comment on $crlf class property) + for ($i = 0; $i < $length; $i ++) + { + // Grab the next character + $char = $line[$i]; + $ascii = ord($char); + + // Convert spaces and tabs but only if it's the end of the line + if ($ascii === 32 || $ascii === 9) + { + if ($i === ($length - 1)) + { + $char = $escape . sprintf('%02s', dechex($ascii)); + } + } + // DO NOT move this below the $ascii_safe_chars line! + // + // = (equals) signs are allowed by RFC2049, but must be encoded + // as they are the encoding delimiter! + elseif ($ascii === 61) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D + } + elseif (! in_array($ascii, $ascii_safe_chars, true)) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + } + + // If we're at the character limit, add the line to the output, + // reset our temp variable, and keep on chuggin' + if ((static::strlen($temp) + static::strlen($char)) >= 76) + { + $output .= $temp . $escape . $this->CRLF; + $temp = ''; + } + + // Add the character to our temporary line + $temp .= $char; + } + + // Add our completed line to the output + $output .= $temp . $this->CRLF; + } + + // get rid of extra CRLF tacked onto the end + return static::substr($output, 0, static::strlen($this->CRLF) * -1); + } + + //-------------------------------------------------------------------- + + /** + * Send Email + * + * @param boolean $autoClear + * + * @return boolean + */ + public function send(Email $email, bool $autoClear = true, bool $reallySend = true) + { + if (! isset($this->headers['From']) && ! empty($this->fromEmail)) + { + $this->setFrom($this->fromEmail, $this->fromName); + } + + if (! isset($this->headers['From'])) + { + throw EmailException::forNoFrom(); + } + + if ($this->replyToFlag === false) + { + $this->setReplyTo($this->headers['From']); + } + + if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) + ) + { + throw EmailException::forNoRecipients(); + } + + $this->buildHeaders(); + + if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) + { + $this->batchBCCSend(); + + if ($autoClear) + { + $this->clear(); + } + + return true; + } + + $this->buildMessage(); + $result = $this->spoolEmail(); + + if ($result && $autoClear) + { + $this->clear(); + } + + return $result; + } + + //-------------------------------------------------------------------- + + /** + * Batch Bcc Send. Sends groups of BCCs in batches + */ + public function batchBCCSend() + { + $float = $this->BCCBatchSize - 1; + $set = ''; + $chunk = []; + + for ($i = 0, $c = count($this->BCCArray); $i < $c; $i ++) + { + if (isset($this->BCCArray[$i])) + { + $set .= ', ' . $this->BCCArray[$i]; + } + + if ($i === $float) + { + $chunk[] = static::substr($set, 1); + $float += $this->BCCBatchSize; + $set = ''; + } + + if ($i === $c - 1) + { + $chunk[] = static::substr($set, 1); + } + } + + for ($i = 0, $c = count($chunk); $i < $c; $i ++) + { + unset($this->headers['Bcc']); + + $bcc = $this->cleanEmail($this->stringToArray($chunk[$i])); + + if ($this->protocol !== 'smtp') + { + $this->setHeader('Bcc', implode(', ', $bcc)); + } + else + { + $this->BCCArray = $bcc; + } + + $this->buildMessage(); + $this->spoolEmail(); + } + } + + //-------------------------------------------------------------------- + + /** + * Unwrap special elements + */ + protected function unwrapSpecials() + { + $this->finalBody = preg_replace_callback( + '/\{unwrap\}(.*?)\{\/unwrap\}/si', [ + $this, + 'removeNLCallback', + ], $this->finalBody + ); + } + + //-------------------------------------------------------------------- + + /** + * Strip line-breaks via callback + * + * @param string $matches + * + * @return string + */ + protected function removeNLCallback($matches) + { + if (strpos($matches[1], "\r") !== false || strpos($matches[1], "\n") !== false) + { + $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]); + } + + return $matches[1]; + } + + //-------------------------------------------------------------------- + + /** + * Spool mail to the mail server + * + * @return boolean + */ + protected function spoolEmail() + { + $this->unwrapSpecials(); + + $protocol = $this->getProtocol(); + $method = 'sendWith' . ucfirst($protocol); + try + { + $success = $this->$method(); + } + catch (\ErrorException $e) + { + $success = false; + $this->logger->error('Email: ' . $method . ' throwed ' . $e->getMessage()); + } + + if (! $success) + { + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + } + + $this->setErrorMessage(lang('Email.sent', [$protocol])); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Validate email for shell + * + * Applies stricter, shell-safe validation to email addresses. + * Introduced to prevent RCE via sendmail's -f option. + * + * @see https://github.com/codeigniter4/CodeIgniter/issues/4963 + * @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36 + * @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain + * + * Credits for the base concept go to Paul Buonopane + * + * @param string &$email + * + * @return boolean + */ + protected function validateEmailForShell(&$email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( + static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); + } + + //-------------------------------------------------------------------- + + /** + * Send using mail() + * + * @return boolean + */ + protected function sendWithMail() + { + if (is_array($this->recipients)) + { + $this->recipients = implode(', ', $this->recipients); + } + + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['Return-Path']); + + if (! $this->validateEmailForShell($from)) + { + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); + } + + // most documentation of sendmail using the "-f" flag lacks a space after it, however + // we've encountered servers that seem to require it to be in place. + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); + } + + //-------------------------------------------------------------------- + + /** + * Send using Sendmail + * + * @return boolean + */ + protected function sendWithSendmail() + { + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['From']); + if ($this->validateEmailForShell($from)) + { + $from = '-f ' . $from; + } + else + { + $from = ''; + } + + // is popen() enabled? + if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) + { + // server probably has popen disabled, so nothing we can do to get a verbose error. + return false; + } + + fputs($fp, $this->headerStr); + fputs($fp, $this->finalBody); + + $status = pclose($fp); + + if ($status !== 0) + { + throw EmailException::forNosocket($status); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send using SMTP + * + * @return boolean + */ + protected function sendWithSmtp() + { + if ($this->SMTPHost === '') + { + throw EmailException::forNoHostname(); + } + + if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) + { + return false; + } + + if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) + { + $this->SMTPEnd(); + + return false; + } + + foreach ($this->recipients as $val) + { + if (! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->CCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->BCCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + if (! $this->sendCommand('data')) + { + $this->SMTPEnd(); + + return false; + } + + // perform dot transformation on any lines that begin with a dot + $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody)); + + $this->sendData('.'); + $reply = $this->getSMTPData(); + $this->setErrorMessage($reply); + + $this->SMTPEnd(); + + if (strpos($reply, '250') !== 0) + { + throw EmailException::forSMTPError($reply); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP End + * + * Shortcut to send RSET or QUIT depending on keep-alive + */ + protected function SMTPEnd() + { + $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit'); + } + + //-------------------------------------------------------------------- + + /** + * SMTP Connect + * + * @return string + */ + protected function SMTPConnect() + { + if (is_resource($this->SMTPConnect)) + { + return true; + } + + $ssl = ($this->SMTPCrypto === 'ssl') ? 'ssl://' : ''; + + $this->SMTPConnect = fsockopen( + $ssl . $this->SMTPHost, $this->SMTPPort, $errno, $errstr, $this->SMTPTimeout + ); + + if (! is_resource($this->SMTPConnect)) + { + throw EmailException::forSMTPError($errno . ' ' . $errstr); + } + + stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); + $this->setErrorMessage($this->getSMTPData()); + + if ($this->SMTPCrypto === 'tls') + { + $this->sendCommand('hello'); + $this->sendCommand('starttls'); + + $crypto = stream_socket_enable_crypto($this->SMTPConnect, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($crypto !== true) + { + throw EmailException::forSMTPError($this->getSMTPData()); + } + } + + return $this->sendCommand('hello'); + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP command + * + * @param string $cmd + * @param string $data + * + * @return boolean + */ + protected function sendCommand($cmd, $data = '') + { + switch ($cmd) + { + case 'hello': + if ($this->SMTPAuth || $this->getEncoding() === '8bit') + { + $this->sendData('EHLO ' . $this->getHostname()); + } + else + { + $this->sendData('HELO ' . $this->getHostname()); + } + + $resp = 250; + break; + case 'starttls': + $this->sendData('STARTTLS'); + $resp = 220; + break; + case 'from': + $this->sendData('MAIL FROM:<' . $data . '>'); + $resp = 250; + break; + case 'to': + if ($this->DSN) + { + $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data); + } + else + { + $this->sendData('RCPT TO:<' . $data . '>'); + } + $resp = 250; + break; + case 'data': + $this->sendData('DATA'); + $resp = 354; + break; + case 'reset': + $this->sendData('RSET'); + $resp = 250; + break; + case 'quit': + $this->sendData('QUIT'); + $resp = 221; + break; + } + + $reply = $this->getSMTPData(); + + $this->debugMessage[] = '
' . $cmd . ': ' . $reply . '
'; + + if ((int) static::substr($reply, 0, 3) !== $resp) + { + throw EmailException::forSMTPError($reply); + } + + if ($cmd === 'quit') + { + fclose($this->SMTPConnect); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP Authenticate + * + * @return boolean + */ + protected function SMTPAuthenticate() + { + if (! $this->SMTPAuth) + { + return true; + } + + if ($this->SMTPUser === '' && $this->SMTPPass === '') + { + throw EmailException::forNoSMTPAuth(); + } + + $this->sendData('AUTH LOGIN'); + $reply = $this->getSMTPData(); + + if (strpos($reply, '503') === 0) // Already authenticated + { + return true; + } + elseif (strpos($reply, '334') !== 0) + { + throw EmailException::forFailedSMTPLogin($reply); + } + + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '334') !== 0) + { + throw EmailException::forSMTPAuthUsername($reply); + } + + $this->sendData(base64_encode($this->SMTPPass)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '235') !== 0) + { + throw EmailException::forSMTPAuthPassword($reply); + } + + if ($this->SMTPKeepAlive) + { + $this->SMTPAuth = false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP data + * + * @param string $data + * + * @return boolean + */ + protected function sendData($data) + { + $data .= $this->newline; + for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) + { + if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) + { + break; + } + // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951 + elseif ($result === 0) + { + if ($timestamp === 0) + { + $timestamp = time(); + } + elseif ($timestamp < (time() - $this->SMTPTimeout)) + { + $result = false; + break; + } + + usleep(250000); + continue; + } + else + { + $timestamp = 0; + } + } + + if ($result === false) + { + throw EmailException::forSMTPDataFailure($data); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Get SMTP data + * + * @return string + */ + protected function getSMTPData() + { + $data = ''; + + while ($str = fgets($this->SMTPConnect, 512)) + { + $data .= $str; + + if ($str[3] === ' ') + { + break; + } + } + + return $data; + } + + //-------------------------------------------------------------------- + + /** + * Get Hostname + * + * There are only two legal types of hostname - either a fully + * qualified domain name (eg: "mail.example.com") or an IP literal + * (eg: "[1.2.3.4]"). + * + * @link https://tools.ietf.org/html/rfc5321#section-2.3.5 + * @link http://cbl.abuseat.org/namingproblems.html + * + * @return string + */ + protected function getHostname() + { + if (isset($_SERVER['SERVER_NAME'])) + { + return $_SERVER['SERVER_NAME']; + } + + return isset($_SERVER['SERVER_ADDR']) ? '[' . $_SERVER['SERVER_ADDR'] . ']' : '[127.0.0.1]'; + } + + //-------------------------------------------------------------------- + + /** + * Get Debug Message + * + * @param array $include List of raw data chunks to include in the output + * Valid options are: 'headers', 'subject', 'body' + * + * @return string + */ + public function printDebugger($include = ['headers', 'subject', 'body']) + { + $msg = implode('', $this->debugMessage); + + // Determine which parts of our raw data needs to be printed + $raw_data = ''; + is_array($include) || $include = [$include]; + + in_array('headers', $include, true) && $raw_data = htmlspecialchars($this->headerStr) . "\n"; + in_array('subject', $include, true) && $raw_data .= htmlspecialchars($this->subject) . "\n"; + in_array('body', $include, true) && $raw_data .= htmlspecialchars($this->finalBody); + + return $msg . ($raw_data === '' ? '' : '
' . $raw_data . '
'); + } + + //-------------------------------------------------------------------- + + /** + * Set Message + * + * @param string $msg + */ + protected function setErrorMessage($msg) + { + $this->debugMessage[] = $msg . '
'; + } + + //-------------------------------------------------------------------- + + /** + * Mime Types + * + * @param string $ext + * + * @return string + */ + protected function mimeTypes($ext = '') + { + $mime = Mimes::guessTypeFromExtension(strtolower($ext)); + + return ! empty($mime) ? $mime : 'application/x-unknown-content-type'; + } + + //-------------------------------------------------------------------- + + /** + * Destructor + */ + public function __destruct() + { + is_resource($this->SMTPConnect) && $this->sendCommand('quit'); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe strlen() + * + * @param string $str + * + * @return integer + */ + protected static function strlen($str) + { + return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe substr() + * + * @param string $str + * @param integer $start + * @param integer|null $length + * + * @return string + */ + protected static function substr($str, $start, $length = null) + { + if (static::$func_overload) + { + return mb_substr($str, $start, $length, '8bit'); + } + + return isset($length) ? substr($str, $start, $length) : substr($str, $start); + } + +} diff --git a/system/Email/Handlers/MailHandler.php b/system/Email/Handlers/MailHandler.php new file mode 100644 index 0000000000..8d2bb4815b --- /dev/null +++ b/system/Email/Handlers/MailHandler.php @@ -0,0 +1,1633 @@ + '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + + /** + * mbstring.func_overload flag + * + * @var boolean + */ + protected static $func_overload; + + /** + * Logger instance to record error messages and awarnings. + * + * @var \PSR\Log\LoggerInterface + */ + protected $logger; + + //-------------------------------------------------------------------- + + /** + * Constructor - Sets Email Preferences + * + * The constructor can be passed an array of config values + * + * @param array|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + + log_message('info', 'Email Class Initialized'); + } + + //-------------------------------------------------------------------- + + /** + * Initialize preferences + * + * @param array|\Config\Email $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + + if ($config instanceof \Config\Email) + { + $config = get_object_vars($config); + } + + foreach (get_class_vars(get_class($this)) as $key => $value) + { + if (property_exists($this, $key) && isset($config[$key])) + { + $method = 'set' . ucfirst($key); + + if (method_exists($this, $method)) + { + $this->$method($config[$key]); + } + } + } + + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Initialize the Email Data + * + * @param boolean $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + + // $this->setHeader('Date', $this->setDate()); + + if ($clearAttachments !== false) + { + $this->attachments = []; + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Build final headers + */ + protected function buildHeaders() + { + $this->setHeader('User-Agent', $this->userAgent); + $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From'])); + $this->setHeader('X-Mailer', $this->userAgent); + $this->setHeader('X-Priority', $this->priorities[$this->priority]); + $this->setHeader('Message-ID', $this->getMessageID()); + $this->setHeader('Mime-Version', '1.0'); + } + + //-------------------------------------------------------------------- + + /** + * Write Headers as a string + */ + protected function writeHeaders() + { + if ($this->protocol === 'mail') + { + if (isset($this->headers['Subject'])) + { + $this->subject = $this->headers['Subject']; + unset($this->headers['Subject']); + } + } + + reset($this->headers); + $this->headerStr = ''; + + foreach ($this->headers as $key => $val) + { + $val = trim($val); + + if ($val !== '') + { + $this->headerStr .= $key . ': ' . $val . $this->newline; + } + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr = rtrim($this->headerStr); + } + } + + //-------------------------------------------------------------------- + + /** + * Build Final Body and attachments + */ + protected function buildMessage() + { + if ($this->wordWrap === true && $this->mailType !== 'html') + { + $this->body = $this->wordWrap($this->body); + } + + $this->writeHeaders(); + + $hdr = ($this->getProtocol() === 'mail') ? $this->newline : ''; + $body = ''; + + switch ($this->getContentType()) + { + case 'plain': + + $hdr .= 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding(); + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + $this->finalBody = $this->body; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; + } + + return; + + case 'html': + + if ($this->sendMultipart === false) + { + $hdr .= 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable'; + } + else + { + $boundary = uniqid('B_ALT_', true); + $hdr .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; + + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline; + } + + $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody; + } + + if ($this->sendMultipart !== false) + { + $this->finalBody .= '--' . $boundary . '--'; + } + + return; + + case 'plain-attach': + + $boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + $body .= $this->getMimeMessage() . $this->newline + . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline + . $this->newline + . $this->body . $this->newline . $this->newline; + + $this->appendAttachments($body, $boundary); + + break; + case 'html-attach': + + $alt_boundary = uniqid('B_ALT_', true); + $last_boundary = null; + + if ($this->attachmentsHaveMultipart('mixed')) + { + $atc_boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atc_boundary . '"'; + $last_boundary = $atc_boundary; + } + + if ($this->attachmentsHaveMultipart('related')) + { + $rel_boundary = uniqid('B_REL_', true); + $rel_boundary_header = 'Content-Type: multipart/related; boundary="' . $rel_boundary . '"'; + + if (isset($last_boundary)) + { + $body .= '--' . $last_boundary . $this->newline . $rel_boundary_header; + } + else + { + $hdr .= $rel_boundary_header; + } + + $last_boundary = $rel_boundary; + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + static::strlen($body) && $body .= $this->newline . $this->newline; + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $last_boundary . $this->newline + . 'Content-Type: multipart/alternative; boundary="' . $alt_boundary . '"' . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline + . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline + . '--' . $alt_boundary . '--' . $this->newline . $this->newline; + + if (! empty($rel_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $rel_boundary, 'related'); + } + + // multipart/mixed attachments + if (! empty($atc_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $atc_boundary, 'mixed'); + } + + break; + } + + $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; + } + + //-------------------------------------------------------------------- + + /** + * Prepares attachment string + * + * @param string &$body Message body to append to + * @param string $boundary Multipart boundary + * @param string|null $multipart When provided, only attachments of this type will be processed + * + * @return string + */ + protected function appendAttachments(&$body, $boundary, $multipart = null) + { + for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + { + if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) + { + continue; + } + + $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); + + $body .= '--' . $boundary . $this->newline + . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline + . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline + . 'Content-Transfer-Encoding: base64' . $this->newline + . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) + . $this->newline + . $this->attachments[$i]['content'] . $this->newline; + } + + // $name won't be set if no attachments were appended, + // and therefore a boundary wouldn't be necessary + empty($name) || $body .= '--' . $boundary . '--'; + } + + //-------------------------------------------------------------------- + + /** + * Prep Quoted Printable + * + * Prepares string for Quoted-Printable Content-Transfer-Encoding + * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt + * + * @param string $str + * + * @return string + */ + protected function prepQuotedPrintable($str) + { + // ASCII code numbers for "safe" characters that can always be + // used literally, without encoding, as described in RFC 2049. + // http://www.ietf.org/rfc/rfc2049.txt + static $ascii_safe_chars = [ + // ' ( ) + , - . / : = ? + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 47, + 58, + 61, + 63, + // numbers + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + // upper-case letters + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + // lower-case letters + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + ]; + + // We are intentionally wrapping so mail servers will encode characters + // properly and MUAs will behave, so {unwrap} must go! + $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); + + // RFC 2045 specifies CRLF as "\r\n". + // However, many developers choose to override that and violate + // the RFC rules due to (apparently) a bug in MS Exchange, + // which only works with "\n". + if ($this->CRLF === "\r\n") + { + return quoted_printable_encode($str); + } + + // Reduce multiple spaces & remove nulls + $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); + + // Standardize newlines + if (strpos($str, "\r") !== false) + { + $str = str_replace(["\r\n", "\r"], "\n", $str); + } + + $escape = '='; + $output = ''; + + foreach (explode("\n", $str) as $line) + { + $length = static::strlen($line); + $temp = ''; + + // Loop through each character in the line to add soft-wrap + // characters at the end of a line " =\r\n" and add the newly + // processed line(s) to the output (see comment on $crlf class property) + for ($i = 0; $i < $length; $i ++) + { + // Grab the next character + $char = $line[$i]; + $ascii = ord($char); + + // Convert spaces and tabs but only if it's the end of the line + if ($ascii === 32 || $ascii === 9) + { + if ($i === ($length - 1)) + { + $char = $escape . sprintf('%02s', dechex($ascii)); + } + } + // DO NOT move this below the $ascii_safe_chars line! + // + // = (equals) signs are allowed by RFC2049, but must be encoded + // as they are the encoding delimiter! + elseif ($ascii === 61) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D + } + elseif (! in_array($ascii, $ascii_safe_chars, true)) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + } + + // If we're at the character limit, add the line to the output, + // reset our temp variable, and keep on chuggin' + if ((static::strlen($temp) + static::strlen($char)) >= 76) + { + $output .= $temp . $escape . $this->CRLF; + $temp = ''; + } + + // Add the character to our temporary line + $temp .= $char; + } + + // Add our completed line to the output + $output .= $temp . $this->CRLF; + } + + // get rid of extra CRLF tacked onto the end + return static::substr($output, 0, static::strlen($this->CRLF) * -1); + } + + //-------------------------------------------------------------------- + + /** + * Prep Q Encoding + * + * Performs "Q Encoding" on a string for use in email headers. + * It's related but not identical to quoted-printable, so it has its + * own method. + * + * @param string $str + * + * @return string + */ + protected function prepQEncoding($str) + { + $str = str_replace(["\r", "\n"], '', $str); + + if ($this->charset === 'UTF-8') + { + // Note: We used to have mb_encode_mimeheader() as the first choice + // here, but it turned out to be buggy and unreliable. DO NOT + // re-add it! -- Narf + if (extension_loaded('iconv')) + { + $output = @iconv_mime_encode( + '', $str, [ + 'scheme' => 'Q', + 'line-length' => 76, + 'input-charset' => $this->charset, + 'output-charset' => $this->charset, + 'line-break-chars' => $this->CRLF, + ]); + + // There are reports that iconv_mime_encode() might fail and return FALSE + if ($output !== false) + { + // iconv_mime_encode() will always put a header field name. + // We've passed it an empty one, but it still prepends our + // encoded string with ': ', so we need to strip it. + return static::substr($output, 2); + } + + $chars = iconv_strlen($str, 'UTF-8'); + } + elseif (extension_loaded('mbstring')) + { + $chars = mb_strlen($str, 'UTF-8'); + } + } + + // We might already have this set for UTF-8 + isset($chars) || $chars = static::strlen($str); + + $output = '=?' . $this->charset . '?Q?'; + for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) + { + $chr = ($this->charset === 'UTF-8' && ICONV_ENABLED === true) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); + + // RFC 2045 sets a limit of 76 characters per line. + // We'll append ?= to the end of each line though. + if ($length + ($l = static::strlen($chr)) > 74) + { + $output .= '?=' . $this->CRLF // EOL + . ' =?' . $this->charset . '?Q?' . $chr; // New line + $length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line + } + else + { + $output .= $chr; + $length += $l; + } + } + + // End the header + return $output . '?='; + } + + //-------------------------------------------------------------------- + + /** + * Send Email + * + * @param boolean $autoClear + * + * @return boolean + */ + public function send(Email $email, bool $autoClear = true, bool $reallySend = true) + { + if (! isset($this->headers['From']) && ! empty($this->fromEmail)) + { + $this->setFrom($this->fromEmail, $this->fromName); + } + + if (! isset($this->headers['From'])) + { + throw EmailException::forNoFrom(); + } + + if ($this->replyToFlag === false) + { + $this->setReplyTo($this->headers['From']); + } + + if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) + ) + { + throw EmailException::forNoRecipients(); + } + + $this->buildHeaders(); + + if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) + { + $this->batchBCCSend(); + + if ($autoClear) + { + $this->clear(); + } + + return true; + } + + $this->buildMessage(); + $result = $this->spoolEmail(); + + if ($result && $autoClear) + { + $this->clear(); + } + + return $result; + } + + //-------------------------------------------------------------------- + + /** + * Batch Bcc Send. Sends groups of BCCs in batches + */ + public function batchBCCSend() + { + $float = $this->BCCBatchSize - 1; + $set = ''; + $chunk = []; + + for ($i = 0, $c = count($this->BCCArray); $i < $c; $i ++) + { + if (isset($this->BCCArray[$i])) + { + $set .= ', ' . $this->BCCArray[$i]; + } + + if ($i === $float) + { + $chunk[] = static::substr($set, 1); + $float += $this->BCCBatchSize; + $set = ''; + } + + if ($i === $c - 1) + { + $chunk[] = static::substr($set, 1); + } + } + + for ($i = 0, $c = count($chunk); $i < $c; $i ++) + { + unset($this->headers['Bcc']); + + $bcc = $this->cleanEmail($this->stringToArray($chunk[$i])); + + if ($this->protocol !== 'smtp') + { + $this->setHeader('Bcc', implode(', ', $bcc)); + } + else + { + $this->BCCArray = $bcc; + } + + $this->buildMessage(); + $this->spoolEmail(); + } + } + + //-------------------------------------------------------------------- + + /** + * Unwrap special elements + */ + protected function unwrapSpecials() + { + $this->finalBody = preg_replace_callback( + '/\{unwrap\}(.*?)\{\/unwrap\}/si', [ + $this, + 'removeNLCallback', + ], $this->finalBody + ); + } + + //-------------------------------------------------------------------- + + /** + * Strip line-breaks via callback + * + * @param string $matches + * + * @return string + */ + protected function removeNLCallback($matches) + { + if (strpos($matches[1], "\r") !== false || strpos($matches[1], "\n") !== false) + { + $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]); + } + + return $matches[1]; + } + + //-------------------------------------------------------------------- + + /** + * Spool mail to the mail server + * + * @return boolean + */ + protected function spoolEmail() + { + $this->unwrapSpecials(); + + $protocol = $this->getProtocol(); + $method = 'sendWith' . ucfirst($protocol); + try + { + $success = $this->$method(); + } + catch (\ErrorException $e) + { + $success = false; + $this->logger->error('Email: ' . $method . ' throwed ' . $e->getMessage()); + } + + if (! $success) + { + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + } + + $this->setErrorMessage(lang('Email.sent', [$protocol])); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Validate email for shell + * + * Applies stricter, shell-safe validation to email addresses. + * Introduced to prevent RCE via sendmail's -f option. + * + * @see https://github.com/codeigniter4/CodeIgniter/issues/4963 + * @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36 + * @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain + * + * Credits for the base concept go to Paul Buonopane + * + * @param string &$email + * + * @return boolean + */ + protected function validateEmailForShell(&$email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( + static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); + } + + //-------------------------------------------------------------------- + + /** + * Send using mail() + * + * @return boolean + */ + protected function sendWithMail() + { + if (is_array($this->recipients)) + { + $this->recipients = implode(', ', $this->recipients); + } + + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['Return-Path']); + + if (! $this->validateEmailForShell($from)) + { + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); + } + + // most documentation of sendmail using the "-f" flag lacks a space after it, however + // we've encountered servers that seem to require it to be in place. + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); + } + + //-------------------------------------------------------------------- + + /** + * Send using Sendmail + * + * @return boolean + */ + protected function sendWithSendmail() + { + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['From']); + if ($this->validateEmailForShell($from)) + { + $from = '-f ' . $from; + } + else + { + $from = ''; + } + + // is popen() enabled? + if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) + { + // server probably has popen disabled, so nothing we can do to get a verbose error. + return false; + } + + fputs($fp, $this->headerStr); + fputs($fp, $this->finalBody); + + $status = pclose($fp); + + if ($status !== 0) + { + throw EmailException::forNosocket($status); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send using SMTP + * + * @return boolean + */ + protected function sendWithSmtp() + { + if ($this->SMTPHost === '') + { + throw EmailException::forNoHostname(); + } + + if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) + { + return false; + } + + if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) + { + $this->SMTPEnd(); + + return false; + } + + foreach ($this->recipients as $val) + { + if (! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->CCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->BCCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + if (! $this->sendCommand('data')) + { + $this->SMTPEnd(); + + return false; + } + + // perform dot transformation on any lines that begin with a dot + $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody)); + + $this->sendData('.'); + $reply = $this->getSMTPData(); + $this->setErrorMessage($reply); + + $this->SMTPEnd(); + + if (strpos($reply, '250') !== 0) + { + throw EmailException::forSMTPError($reply); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP End + * + * Shortcut to send RSET or QUIT depending on keep-alive + */ + protected function SMTPEnd() + { + $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit'); + } + + //-------------------------------------------------------------------- + + /** + * SMTP Connect + * + * @return string + */ + protected function SMTPConnect() + { + if (is_resource($this->SMTPConnect)) + { + return true; + } + + $ssl = ($this->SMTPCrypto === 'ssl') ? 'ssl://' : ''; + + $this->SMTPConnect = fsockopen( + $ssl . $this->SMTPHost, $this->SMTPPort, $errno, $errstr, $this->SMTPTimeout + ); + + if (! is_resource($this->SMTPConnect)) + { + throw EmailException::forSMTPError($errno . ' ' . $errstr); + } + + stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); + $this->setErrorMessage($this->getSMTPData()); + + if ($this->SMTPCrypto === 'tls') + { + $this->sendCommand('hello'); + $this->sendCommand('starttls'); + + $crypto = stream_socket_enable_crypto($this->SMTPConnect, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($crypto !== true) + { + throw EmailException::forSMTPError($this->getSMTPData()); + } + } + + return $this->sendCommand('hello'); + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP command + * + * @param string $cmd + * @param string $data + * + * @return boolean + */ + protected function sendCommand($cmd, $data = '') + { + switch ($cmd) + { + case 'hello': + if ($this->SMTPAuth || $this->getEncoding() === '8bit') + { + $this->sendData('EHLO ' . $this->getHostname()); + } + else + { + $this->sendData('HELO ' . $this->getHostname()); + } + + $resp = 250; + break; + case 'starttls': + $this->sendData('STARTTLS'); + $resp = 220; + break; + case 'from': + $this->sendData('MAIL FROM:<' . $data . '>'); + $resp = 250; + break; + case 'to': + if ($this->DSN) + { + $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data); + } + else + { + $this->sendData('RCPT TO:<' . $data . '>'); + } + $resp = 250; + break; + case 'data': + $this->sendData('DATA'); + $resp = 354; + break; + case 'reset': + $this->sendData('RSET'); + $resp = 250; + break; + case 'quit': + $this->sendData('QUIT'); + $resp = 221; + break; + } + + $reply = $this->getSMTPData(); + + $this->debugMessage[] = '
' . $cmd . ': ' . $reply . '
'; + + if ((int) static::substr($reply, 0, 3) !== $resp) + { + throw EmailException::forSMTPError($reply); + } + + if ($cmd === 'quit') + { + fclose($this->SMTPConnect); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP Authenticate + * + * @return boolean + */ + protected function SMTPAuthenticate() + { + if (! $this->SMTPAuth) + { + return true; + } + + if ($this->SMTPUser === '' && $this->SMTPPass === '') + { + throw EmailException::forNoSMTPAuth(); + } + + $this->sendData('AUTH LOGIN'); + $reply = $this->getSMTPData(); + + if (strpos($reply, '503') === 0) // Already authenticated + { + return true; + } + elseif (strpos($reply, '334') !== 0) + { + throw EmailException::forFailedSMTPLogin($reply); + } + + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '334') !== 0) + { + throw EmailException::forSMTPAuthUsername($reply); + } + + $this->sendData(base64_encode($this->SMTPPass)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '235') !== 0) + { + throw EmailException::forSMTPAuthPassword($reply); + } + + if ($this->SMTPKeepAlive) + { + $this->SMTPAuth = false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP data + * + * @param string $data + * + * @return boolean + */ + protected function sendData($data) + { + $data .= $this->newline; + for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) + { + if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) + { + break; + } + // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951 + elseif ($result === 0) + { + if ($timestamp === 0) + { + $timestamp = time(); + } + elseif ($timestamp < (time() - $this->SMTPTimeout)) + { + $result = false; + break; + } + + usleep(250000); + continue; + } + else + { + $timestamp = 0; + } + } + + if ($result === false) + { + throw EmailException::forSMTPDataFailure($data); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Get SMTP data + * + * @return string + */ + protected function getSMTPData() + { + $data = ''; + + while ($str = fgets($this->SMTPConnect, 512)) + { + $data .= $str; + + if ($str[3] === ' ') + { + break; + } + } + + return $data; + } + + //-------------------------------------------------------------------- + + /** + * Get Hostname + * + * There are only two legal types of hostname - either a fully + * qualified domain name (eg: "mail.example.com") or an IP literal + * (eg: "[1.2.3.4]"). + * + * @link https://tools.ietf.org/html/rfc5321#section-2.3.5 + * @link http://cbl.abuseat.org/namingproblems.html + * + * @return string + */ + protected function getHostname() + { + if (isset($_SERVER['SERVER_NAME'])) + { + return $_SERVER['SERVER_NAME']; + } + + return isset($_SERVER['SERVER_ADDR']) ? '[' . $_SERVER['SERVER_ADDR'] . ']' : '[127.0.0.1]'; + } + + //-------------------------------------------------------------------- + + /** + * Get Debug Message + * + * @param array $include List of raw data chunks to include in the output + * Valid options are: 'headers', 'subject', 'body' + * + * @return string + */ + public function printDebugger($include = ['headers', 'subject', 'body']) + { + $msg = implode('', $this->debugMessage); + + // Determine which parts of our raw data needs to be printed + $raw_data = ''; + is_array($include) || $include = [$include]; + + in_array('headers', $include, true) && $raw_data = htmlspecialchars($this->headerStr) . "\n"; + in_array('subject', $include, true) && $raw_data .= htmlspecialchars($this->subject) . "\n"; + in_array('body', $include, true) && $raw_data .= htmlspecialchars($this->finalBody); + + return $msg . ($raw_data === '' ? '' : '
' . $raw_data . '
'); + } + + //-------------------------------------------------------------------- + + /** + * Set Message + * + * @param string $msg + */ + protected function setErrorMessage($msg) + { + $this->debugMessage[] = $msg . '
'; + } + + //-------------------------------------------------------------------- + + /** + * Mime Types + * + * @param string $ext + * + * @return string + */ + protected function mimeTypes($ext = '') + { + $mime = Mimes::guessTypeFromExtension(strtolower($ext)); + + return ! empty($mime) ? $mime : 'application/x-unknown-content-type'; + } + + //-------------------------------------------------------------------- + + /** + * Destructor + */ + public function __destruct() + { + is_resource($this->SMTPConnect) && $this->sendCommand('quit'); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe strlen() + * + * @param string $str + * + * @return integer + */ + protected static function strlen($str) + { + return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe substr() + * + * @param string $str + * @param integer $start + * @param integer|null $length + * + * @return string + */ + protected static function substr($str, $start, $length = null) + { + if (static::$func_overload) + { + return mb_substr($str, $start, $length, '8bit'); + } + + return isset($length) ? substr($str, $start, $length) : substr($str, $start); + } + +} diff --git a/system/Email/Handlers/SMTPHandler.php b/system/Email/Handlers/SMTPHandler.php new file mode 100644 index 0000000000..e34336cf50 --- /dev/null +++ b/system/Email/Handlers/SMTPHandler.php @@ -0,0 +1,1637 @@ + '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + + /** + * mbstring.func_overload flag + * + * @var boolean + */ + protected static $func_overload; + + /** + * Logger instance to record error messages and awarnings. + * + * @var \PSR\Log\LoggerInterface + */ + protected $logger; + + //-------------------------------------------------------------------- + + /** + * Constructor - Sets Email Preferences + * + * The constructor can be passed an array of config values + * + * @param array|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + + log_message('info', 'Email Class Initialized'); + } + + //-------------------------------------------------------------------- + + /** + * Initialize preferences + * + * @param array|\Config\Email $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + + if ($config instanceof \Config\Email) + { + $config = get_object_vars($config); + } + + foreach (get_class_vars(get_class($this)) as $key => $value) + { + if (property_exists($this, $key) && isset($config[$key])) + { + $method = 'set' . ucfirst($key); + + if (method_exists($this, $method)) + { + $this->$method($config[$key]); + } + else + { + $this->$key = $config[$key]; + } + } + } + + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Initialize the Email Data + * + * @param boolean $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + + $this->setHeader('Date', $this->setDate()); + + if ($clearAttachments !== false) + { + $this->attachments = []; + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Build final headers + */ + protected function buildHeaders() + { + $this->setHeader('User-Agent', $this->userAgent); + $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From'])); + $this->setHeader('X-Mailer', $this->userAgent); + $this->setHeader('X-Priority', $this->priorities[$this->priority]); + $this->setHeader('Message-ID', $this->getMessageID()); + $this->setHeader('Mime-Version', '1.0'); + } + + //-------------------------------------------------------------------- + + /** + * Write Headers as a string + */ + protected function writeHeaders() + { + if ($this->protocol === 'mail') + { + if (isset($this->headers['Subject'])) + { + $this->subject = $this->headers['Subject']; + unset($this->headers['Subject']); + } + } + + reset($this->headers); + $this->headerStr = ''; + + foreach ($this->headers as $key => $val) + { + $val = trim($val); + + if ($val !== '') + { + $this->headerStr .= $key . ': ' . $val . $this->newline; + } + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr = rtrim($this->headerStr); + } + } + + //-------------------------------------------------------------------- + + /** + * Build Final Body and attachments + */ + protected function buildMessage() + { + if ($this->wordWrap === true && $this->mailType !== 'html') + { + $this->body = $this->wordWrap($this->body); + } + + $this->writeHeaders(); + + $hdr = ($this->getProtocol() === 'mail') ? $this->newline : ''; + $body = ''; + + switch ($this->getContentType()) + { + case 'plain': + + $hdr .= 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding(); + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + $this->finalBody = $this->body; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; + } + + return; + + case 'html': + + if ($this->sendMultipart === false) + { + $hdr .= 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable'; + } + else + { + $boundary = uniqid('B_ALT_', true); + $hdr .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; + + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline; + } + + $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody; + } + + if ($this->sendMultipart !== false) + { + $this->finalBody .= '--' . $boundary . '--'; + } + + return; + + case 'plain-attach': + + $boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + $body .= $this->getMimeMessage() . $this->newline + . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline + . $this->newline + . $this->body . $this->newline . $this->newline; + + $this->appendAttachments($body, $boundary); + + break; + case 'html-attach': + + $alt_boundary = uniqid('B_ALT_', true); + $last_boundary = null; + + if ($this->attachmentsHaveMultipart('mixed')) + { + $atc_boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atc_boundary . '"'; + $last_boundary = $atc_boundary; + } + + if ($this->attachmentsHaveMultipart('related')) + { + $rel_boundary = uniqid('B_REL_', true); + $rel_boundary_header = 'Content-Type: multipart/related; boundary="' . $rel_boundary . '"'; + + if (isset($last_boundary)) + { + $body .= '--' . $last_boundary . $this->newline . $rel_boundary_header; + } + else + { + $hdr .= $rel_boundary_header; + } + + $last_boundary = $rel_boundary; + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + static::strlen($body) && $body .= $this->newline . $this->newline; + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $last_boundary . $this->newline + . 'Content-Type: multipart/alternative; boundary="' . $alt_boundary . '"' . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline + . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline + . '--' . $alt_boundary . '--' . $this->newline . $this->newline; + + if (! empty($rel_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $rel_boundary, 'related'); + } + + // multipart/mixed attachments + if (! empty($atc_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $atc_boundary, 'mixed'); + } + + break; + } + + $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; + } + + //-------------------------------------------------------------------- + + /** + * Prepares attachment string + * + * @param string &$body Message body to append to + * @param string $boundary Multipart boundary + * @param string|null $multipart When provided, only attachments of this type will be processed + * + * @return string + */ + protected function appendAttachments(&$body, $boundary, $multipart = null) + { + for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + { + if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) + { + continue; + } + + $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); + + $body .= '--' . $boundary . $this->newline + . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline + . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline + . 'Content-Transfer-Encoding: base64' . $this->newline + . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) + . $this->newline + . $this->attachments[$i]['content'] . $this->newline; + } + + // $name won't be set if no attachments were appended, + // and therefore a boundary wouldn't be necessary + empty($name) || $body .= '--' . $boundary . '--'; + } + + //-------------------------------------------------------------------- + + /** + * Prep Quoted Printable + * + * Prepares string for Quoted-Printable Content-Transfer-Encoding + * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt + * + * @param string $str + * + * @return string + */ + protected function prepQuotedPrintable($str) + { + // ASCII code numbers for "safe" characters that can always be + // used literally, without encoding, as described in RFC 2049. + // http://www.ietf.org/rfc/rfc2049.txt + static $ascii_safe_chars = [ + // ' ( ) + , - . / : = ? + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 47, + 58, + 61, + 63, + // numbers + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + // upper-case letters + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + // lower-case letters + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + ]; + + // We are intentionally wrapping so mail servers will encode characters + // properly and MUAs will behave, so {unwrap} must go! + $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); + + // RFC 2045 specifies CRLF as "\r\n". + // However, many developers choose to override that and violate + // the RFC rules due to (apparently) a bug in MS Exchange, + // which only works with "\n". + if ($this->CRLF === "\r\n") + { + return quoted_printable_encode($str); + } + + // Reduce multiple spaces & remove nulls + $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); + + // Standardize newlines + if (strpos($str, "\r") !== false) + { + $str = str_replace(["\r\n", "\r"], "\n", $str); + } + + $escape = '='; + $output = ''; + + foreach (explode("\n", $str) as $line) + { + $length = static::strlen($line); + $temp = ''; + + // Loop through each character in the line to add soft-wrap + // characters at the end of a line " =\r\n" and add the newly + // processed line(s) to the output (see comment on $crlf class property) + for ($i = 0; $i < $length; $i ++) + { + // Grab the next character + $char = $line[$i]; + $ascii = ord($char); + + // Convert spaces and tabs but only if it's the end of the line + if ($ascii === 32 || $ascii === 9) + { + if ($i === ($length - 1)) + { + $char = $escape . sprintf('%02s', dechex($ascii)); + } + } + // DO NOT move this below the $ascii_safe_chars line! + // + // = (equals) signs are allowed by RFC2049, but must be encoded + // as they are the encoding delimiter! + elseif ($ascii === 61) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D + } + elseif (! in_array($ascii, $ascii_safe_chars, true)) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + } + + // If we're at the character limit, add the line to the output, + // reset our temp variable, and keep on chuggin' + if ((static::strlen($temp) + static::strlen($char)) >= 76) + { + $output .= $temp . $escape . $this->CRLF; + $temp = ''; + } + + // Add the character to our temporary line + $temp .= $char; + } + + // Add our completed line to the output + $output .= $temp . $this->CRLF; + } + + // get rid of extra CRLF tacked onto the end + return static::substr($output, 0, static::strlen($this->CRLF) * -1); + } + + //-------------------------------------------------------------------- + + /** + * Prep Q Encoding + * + * Performs "Q Encoding" on a string for use in email headers. + * It's related but not identical to quoted-printable, so it has its + * own method. + * + * @param string $str + * + * @return string + */ + protected function prepQEncoding($str) + { + $str = str_replace(["\r", "\n"], '', $str); + + if ($this->charset === 'UTF-8') + { + // Note: We used to have mb_encode_mimeheader() as the first choice + // here, but it turned out to be buggy and unreliable. DO NOT + // re-add it! -- Narf + if (extension_loaded('iconv')) + { + $output = @iconv_mime_encode( + '', $str, [ + 'scheme' => 'Q', + 'line-length' => 76, + 'input-charset' => $this->charset, + 'output-charset' => $this->charset, + 'line-break-chars' => $this->CRLF, + ]); + + // There are reports that iconv_mime_encode() might fail and return FALSE + if ($output !== false) + { + // iconv_mime_encode() will always put a header field name. + // We've passed it an empty one, but it still prepends our + // encoded string with ': ', so we need to strip it. + return static::substr($output, 2); + } + + $chars = iconv_strlen($str, 'UTF-8'); + } + elseif (extension_loaded('mbstring')) + { + $chars = mb_strlen($str, 'UTF-8'); + } + } + + // We might already have this set for UTF-8 + isset($chars) || $chars = static::strlen($str); + + $output = '=?' . $this->charset . '?Q?'; + for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) + { + $chr = ($this->charset === 'UTF-8' && ICONV_ENABLED === true) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); + + // RFC 2045 sets a limit of 76 characters per line. + // We'll append ?= to the end of each line though. + if ($length + ($l = static::strlen($chr)) > 74) + { + $output .= '?=' . $this->CRLF // EOL + . ' =?' . $this->charset . '?Q?' . $chr; // New line + $length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line + } + else + { + $output .= $chr; + $length += $l; + } + } + + // End the header + return $output . '?='; + } + + //-------------------------------------------------------------------- + + /** + * Send Email + * + * @param boolean $autoClear + * + * @return boolean + */ + public function send(Email $email, bool $autoClear = true, bool $reallySend = true) + { + if (! isset($this->headers['From']) && ! empty($this->fromEmail)) + { + $this->setFrom($this->fromEmail, $this->fromName); + } + + if (! isset($this->headers['From'])) + { + throw EmailException::forNoFrom(); + } + + if ($this->replyToFlag === false) + { + $this->setReplyTo($this->headers['From']); + } + + if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) + ) + { + throw EmailException::forNoRecipients(); + } + + $this->buildHeaders(); + + if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) + { + $this->batchBCCSend(); + + if ($autoClear) + { + $this->clear(); + } + + return true; + } + + $this->buildMessage(); + $result = $this->spoolEmail(); + + if ($result && $autoClear) + { + $this->clear(); + } + + return $result; + } + + //-------------------------------------------------------------------- + + /** + * Batch Bcc Send. Sends groups of BCCs in batches + */ + public function batchBCCSend() + { + $float = $this->BCCBatchSize - 1; + $set = ''; + $chunk = []; + + for ($i = 0, $c = count($this->BCCArray); $i < $c; $i ++) + { + if (isset($this->BCCArray[$i])) + { + $set .= ', ' . $this->BCCArray[$i]; + } + + if ($i === $float) + { + $chunk[] = static::substr($set, 1); + $float += $this->BCCBatchSize; + $set = ''; + } + + if ($i === $c - 1) + { + $chunk[] = static::substr($set, 1); + } + } + + for ($i = 0, $c = count($chunk); $i < $c; $i ++) + { + unset($this->headers['Bcc']); + + $bcc = $this->cleanEmail($this->stringToArray($chunk[$i])); + + if ($this->protocol !== 'smtp') + { + $this->setHeader('Bcc', implode(', ', $bcc)); + } + else + { + $this->BCCArray = $bcc; + } + + $this->buildMessage(); + $this->spoolEmail(); + } + } + + //-------------------------------------------------------------------- + + /** + * Unwrap special elements + */ + protected function unwrapSpecials() + { + $this->finalBody = preg_replace_callback( + '/\{unwrap\}(.*?)\{\/unwrap\}/si', [ + $this, + 'removeNLCallback', + ], $this->finalBody + ); + } + + //-------------------------------------------------------------------- + + /** + * Strip line-breaks via callback + * + * @param string $matches + * + * @return string + */ + protected function removeNLCallback($matches) + { + if (strpos($matches[1], "\r") !== false || strpos($matches[1], "\n") !== false) + { + $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]); + } + + return $matches[1]; + } + + //-------------------------------------------------------------------- + + /** + * Spool mail to the mail server + * + * @return boolean + */ + protected function spoolEmail() + { + $this->unwrapSpecials(); + + $protocol = $this->getProtocol(); + $method = 'sendWith' . ucfirst($protocol); + try + { + $success = $this->$method(); + } + catch (\ErrorException $e) + { + $success = false; + $this->logger->error('Email: ' . $method . ' throwed ' . $e->getMessage()); + } + + if (! $success) + { + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + } + + $this->setErrorMessage(lang('Email.sent', [$protocol])); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Validate email for shell + * + * Applies stricter, shell-safe validation to email addresses. + * Introduced to prevent RCE via sendmail's -f option. + * + * @see https://github.com/codeigniter4/CodeIgniter/issues/4963 + * @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36 + * @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain + * + * Credits for the base concept go to Paul Buonopane + * + * @param string &$email + * + * @return boolean + */ + protected function validateEmailForShell(&$email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( + static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); + } + + //-------------------------------------------------------------------- + + /** + * Send using mail() + * + * @return boolean + */ + protected function sendWithMail() + { + if (is_array($this->recipients)) + { + $this->recipients = implode(', ', $this->recipients); + } + + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['Return-Path']); + + if (! $this->validateEmailForShell($from)) + { + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); + } + + // most documentation of sendmail using the "-f" flag lacks a space after it, however + // we've encountered servers that seem to require it to be in place. + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); + } + + //-------------------------------------------------------------------- + + /** + * Send using Sendmail + * + * @return boolean + */ + protected function sendWithSendmail() + { + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['From']); + if ($this->validateEmailForShell($from)) + { + $from = '-f ' . $from; + } + else + { + $from = ''; + } + + // is popen() enabled? + if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) + { + // server probably has popen disabled, so nothing we can do to get a verbose error. + return false; + } + + fputs($fp, $this->headerStr); + fputs($fp, $this->finalBody); + + $status = pclose($fp); + + if ($status !== 0) + { + throw EmailException::forNosocket($status); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send using SMTP + * + * @return boolean + */ + protected function sendWithSmtp() + { + if ($this->SMTPHost === '') + { + throw EmailException::forNoHostname(); + } + + if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) + { + return false; + } + + if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) + { + $this->SMTPEnd(); + + return false; + } + + foreach ($this->recipients as $val) + { + if (! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->CCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->BCCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + if (! $this->sendCommand('data')) + { + $this->SMTPEnd(); + + return false; + } + + // perform dot transformation on any lines that begin with a dot + $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody)); + + $this->sendData('.'); + $reply = $this->getSMTPData(); + $this->setErrorMessage($reply); + + $this->SMTPEnd(); + + if (strpos($reply, '250') !== 0) + { + throw EmailException::forSMTPError($reply); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP End + * + * Shortcut to send RSET or QUIT depending on keep-alive + */ + protected function SMTPEnd() + { + $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit'); + } + + //-------------------------------------------------------------------- + + /** + * SMTP Connect + * + * @return string + */ + protected function SMTPConnect() + { + if (is_resource($this->SMTPConnect)) + { + return true; + } + + $ssl = ($this->SMTPCrypto === 'ssl') ? 'ssl://' : ''; + + $this->SMTPConnect = fsockopen( + $ssl . $this->SMTPHost, $this->SMTPPort, $errno, $errstr, $this->SMTPTimeout + ); + + if (! is_resource($this->SMTPConnect)) + { + throw EmailException::forSMTPError($errno . ' ' . $errstr); + } + + stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); + $this->setErrorMessage($this->getSMTPData()); + + if ($this->SMTPCrypto === 'tls') + { + $this->sendCommand('hello'); + $this->sendCommand('starttls'); + + $crypto = stream_socket_enable_crypto($this->SMTPConnect, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($crypto !== true) + { + throw EmailException::forSMTPError($this->getSMTPData()); + } + } + + return $this->sendCommand('hello'); + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP command + * + * @param string $cmd + * @param string $data + * + * @return boolean + */ + protected function sendCommand($cmd, $data = '') + { + switch ($cmd) + { + case 'hello': + if ($this->SMTPAuth || $this->getEncoding() === '8bit') + { + $this->sendData('EHLO ' . $this->getHostname()); + } + else + { + $this->sendData('HELO ' . $this->getHostname()); + } + + $resp = 250; + break; + case 'starttls': + $this->sendData('STARTTLS'); + $resp = 220; + break; + case 'from': + $this->sendData('MAIL FROM:<' . $data . '>'); + $resp = 250; + break; + case 'to': + if ($this->DSN) + { + $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data); + } + else + { + $this->sendData('RCPT TO:<' . $data . '>'); + } + $resp = 250; + break; + case 'data': + $this->sendData('DATA'); + $resp = 354; + break; + case 'reset': + $this->sendData('RSET'); + $resp = 250; + break; + case 'quit': + $this->sendData('QUIT'); + $resp = 221; + break; + } + + $reply = $this->getSMTPData(); + + $this->debugMessage[] = '
' . $cmd . ': ' . $reply . '
'; + + if ((int) static::substr($reply, 0, 3) !== $resp) + { + throw EmailException::forSMTPError($reply); + } + + if ($cmd === 'quit') + { + fclose($this->SMTPConnect); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP Authenticate + * + * @return boolean + */ + protected function SMTPAuthenticate() + { + if (! $this->SMTPAuth) + { + return true; + } + + if ($this->SMTPUser === '' && $this->SMTPPass === '') + { + throw EmailException::forNoSMTPAuth(); + } + + $this->sendData('AUTH LOGIN'); + $reply = $this->getSMTPData(); + + if (strpos($reply, '503') === 0) // Already authenticated + { + return true; + } + elseif (strpos($reply, '334') !== 0) + { + throw EmailException::forFailedSMTPLogin($reply); + } + + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '334') !== 0) + { + throw EmailException::forSMTPAuthUsername($reply); + } + + $this->sendData(base64_encode($this->SMTPPass)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '235') !== 0) + { + throw EmailException::forSMTPAuthPassword($reply); + } + + if ($this->SMTPKeepAlive) + { + $this->SMTPAuth = false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP data + * + * @param string $data + * + * @return boolean + */ + protected function sendData($data) + { + $data .= $this->newline; + for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) + { + if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) + { + break; + } + // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951 + elseif ($result === 0) + { + if ($timestamp === 0) + { + $timestamp = time(); + } + elseif ($timestamp < (time() - $this->SMTPTimeout)) + { + $result = false; + break; + } + + usleep(250000); + continue; + } + else + { + $timestamp = 0; + } + } + + if ($result === false) + { + throw EmailException::forSMTPDataFailure($data); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Get SMTP data + * + * @return string + */ + protected function getSMTPData() + { + $data = ''; + + while ($str = fgets($this->SMTPConnect, 512)) + { + $data .= $str; + + if ($str[3] === ' ') + { + break; + } + } + + return $data; + } + + //-------------------------------------------------------------------- + + /** + * Get Hostname + * + * There are only two legal types of hostname - either a fully + * qualified domain name (eg: "mail.example.com") or an IP literal + * (eg: "[1.2.3.4]"). + * + * @link https://tools.ietf.org/html/rfc5321#section-2.3.5 + * @link http://cbl.abuseat.org/namingproblems.html + * + * @return string + */ + protected function getHostname() + { + if (isset($_SERVER['SERVER_NAME'])) + { + return $_SERVER['SERVER_NAME']; + } + + return isset($_SERVER['SERVER_ADDR']) ? '[' . $_SERVER['SERVER_ADDR'] . ']' : '[127.0.0.1]'; + } + + //-------------------------------------------------------------------- + + /** + * Get Debug Message + * + * @param array $include List of raw data chunks to include in the output + * Valid options are: 'headers', 'subject', 'body' + * + * @return string + */ + public function printDebugger($include = ['headers', 'subject', 'body']) + { + $msg = implode('', $this->debugMessage); + + // Determine which parts of our raw data needs to be printed + $raw_data = ''; + is_array($include) || $include = [$include]; + + in_array('headers', $include, true) && $raw_data = htmlspecialchars($this->headerStr) . "\n"; + in_array('subject', $include, true) && $raw_data .= htmlspecialchars($this->subject) . "\n"; + in_array('body', $include, true) && $raw_data .= htmlspecialchars($this->finalBody); + + return $msg . ($raw_data === '' ? '' : '
' . $raw_data . '
'); + } + + //-------------------------------------------------------------------- + + /** + * Set Message + * + * @param string $msg + */ + protected function setErrorMessage($msg) + { + $this->debugMessage[] = $msg . '
'; + } + + //-------------------------------------------------------------------- + + /** + * Mime Types + * + * @param string $ext + * + * @return string + */ + protected function mimeTypes($ext = '') + { + $mime = Mimes::guessTypeFromExtension(strtolower($ext)); + + return ! empty($mime) ? $mime : 'application/x-unknown-content-type'; + } + + //-------------------------------------------------------------------- + + /** + * Destructor + */ + public function __destruct() + { + is_resource($this->SMTPConnect) && $this->sendCommand('quit'); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe strlen() + * + * @param string $str + * + * @return integer + */ + protected static function strlen($str) + { + return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe substr() + * + * @param string $str + * @param integer $start + * @param integer|null $length + * + * @return string + */ + protected static function substr($str, $start, $length = null) + { + if (static::$func_overload) + { + return mb_substr($str, $start, $length, '8bit'); + } + + return isset($length) ? substr($str, $start, $length) : substr($str, $start); + } + +} diff --git a/system/Email/Handlers/SendmailHandler.php b/system/Email/Handlers/SendmailHandler.php new file mode 100644 index 0000000000..35c26ae4cb --- /dev/null +++ b/system/Email/Handlers/SendmailHandler.php @@ -0,0 +1,1637 @@ + '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)', + ]; + + /** + * mbstring.func_overload flag + * + * @var boolean + */ + protected static $func_overload; + + /** + * Logger instance to record error messages and awarnings. + * + * @var \PSR\Log\LoggerInterface + */ + protected $logger; + + //-------------------------------------------------------------------- + + /** + * Constructor - Sets Email Preferences + * + * The constructor can be passed an array of config values + * + * @param array|null $config + */ + public function __construct($config = null) + { + $this->initialize($config); + + log_message('info', 'Email Class Initialized'); + } + + //-------------------------------------------------------------------- + + /** + * Initialize preferences + * + * @param array|\Config\Email $config + * + * @return Email + */ + public function initialize($config) + { + $this->clear(); + + if ($config instanceof \Config\Email) + { + $config = get_object_vars($config); + } + + foreach (get_class_vars(get_class($this)) as $key => $value) + { + if (property_exists($this, $key) && isset($config[$key])) + { + $method = 'set' . ucfirst($key); + + if (method_exists($this, $method)) + { + $this->$method($config[$key]); + } + else + { + $this->$key = $config[$key]; + } + } + } + + $this->charset = strtoupper($this->charset); + $this->SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Initialize the Email Data + * + * @param boolean $clearAttachments + * + * @return Email + */ + public function clear($clearAttachments = false) + { + $this->subject = ''; + $this->body = ''; + $this->finalBody = ''; + $this->headerStr = ''; + $this->replyToFlag = false; + $this->recipients = []; + $this->CCArray = []; + $this->BCCArray = []; + $this->headers = []; + $this->debugMessage = []; + + $this->setHeader('Date', $this->setDate()); + + if ($clearAttachments !== false) + { + $this->attachments = []; + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Build final headers + */ + protected function buildHeaders() + { + $this->setHeader('User-Agent', $this->userAgent); + $this->setHeader('X-Sender', $this->cleanEmail($this->headers['From'])); + $this->setHeader('X-Mailer', $this->userAgent); + $this->setHeader('X-Priority', $this->priorities[$this->priority]); + $this->setHeader('Message-ID', $this->getMessageID()); + $this->setHeader('Mime-Version', '1.0'); + } + + //-------------------------------------------------------------------- + + /** + * Write Headers as a string + */ + protected function writeHeaders() + { + if ($this->protocol === 'mail') + { + if (isset($this->headers['Subject'])) + { + $this->subject = $this->headers['Subject']; + unset($this->headers['Subject']); + } + } + + reset($this->headers); + $this->headerStr = ''; + + foreach ($this->headers as $key => $val) + { + $val = trim($val); + + if ($val !== '') + { + $this->headerStr .= $key . ': ' . $val . $this->newline; + } + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr = rtrim($this->headerStr); + } + } + + //-------------------------------------------------------------------- + + /** + * Build Final Body and attachments + */ + protected function buildMessage() + { + if ($this->wordWrap === true && $this->mailType !== 'html') + { + $this->body = $this->wordWrap($this->body); + } + + $this->writeHeaders(); + + $hdr = ($this->getProtocol() === 'mail') ? $this->newline : ''; + $body = ''; + + switch ($this->getContentType()) + { + case 'plain': + + $hdr .= 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding(); + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + $this->finalBody = $this->body; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->body; + } + + return; + + case 'html': + + if ($this->sendMultipart === false) + { + $hdr .= 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable'; + } + else + { + $boundary = uniqid('B_ALT_', true); + $hdr .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; + + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline; + } + + $this->finalBody = $body . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + else + { + $this->finalBody = $hdr . $this->newline . $this->newline . $this->finalBody; + } + + if ($this->sendMultipart !== false) + { + $this->finalBody .= '--' . $boundary . '--'; + } + + return; + + case 'plain-attach': + + $boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + $body .= $this->getMimeMessage() . $this->newline + . $this->newline + . '--' . $boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline + . $this->newline + . $this->body . $this->newline . $this->newline; + + $this->appendAttachments($body, $boundary); + + break; + case 'html-attach': + + $alt_boundary = uniqid('B_ALT_', true); + $last_boundary = null; + + if ($this->attachmentsHaveMultipart('mixed')) + { + $atc_boundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atc_boundary . '"'; + $last_boundary = $atc_boundary; + } + + if ($this->attachmentsHaveMultipart('related')) + { + $rel_boundary = uniqid('B_REL_', true); + $rel_boundary_header = 'Content-Type: multipart/related; boundary="' . $rel_boundary . '"'; + + if (isset($last_boundary)) + { + $body .= '--' . $last_boundary . $this->newline . $rel_boundary_header; + } + else + { + $hdr .= $rel_boundary_header; + } + + $last_boundary = $rel_boundary; + } + + if ($this->getProtocol() === 'mail') + { + $this->headerStr .= $hdr; + } + + static::strlen($body) && $body .= $this->newline . $this->newline; + $body .= $this->getMimeMessage() . $this->newline . $this->newline + . '--' . $last_boundary . $this->newline + . 'Content-Type: multipart/alternative; boundary="' . $alt_boundary . '"' . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/plain; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: ' . $this->getEncoding() . $this->newline . $this->newline + . $this->getAltMessage() . $this->newline . $this->newline + . '--' . $alt_boundary . $this->newline + . 'Content-Type: text/html; charset=' . $this->charset . $this->newline + . 'Content-Transfer-Encoding: quoted-printable' . $this->newline . $this->newline + . $this->prepQuotedPrintable($this->body) . $this->newline . $this->newline + . '--' . $alt_boundary . '--' . $this->newline . $this->newline; + + if (! empty($rel_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $rel_boundary, 'related'); + } + + // multipart/mixed attachments + if (! empty($atc_boundary)) + { + $body .= $this->newline . $this->newline; + $this->appendAttachments($body, $atc_boundary, 'mixed'); + } + + break; + } + + $this->finalBody = ($this->getProtocol() === 'mail') ? $body : $hdr . $this->newline . $this->newline . $body; + } + + //-------------------------------------------------------------------- + + /** + * Prepares attachment string + * + * @param string &$body Message body to append to + * @param string $boundary Multipart boundary + * @param string|null $multipart When provided, only attachments of this type will be processed + * + * @return string + */ + protected function appendAttachments(&$body, $boundary, $multipart = null) + { + for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + { + if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) + { + continue; + } + + $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); + + $body .= '--' . $boundary . $this->newline + . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline + . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline + . 'Content-Transfer-Encoding: base64' . $this->newline + . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) + . $this->newline + . $this->attachments[$i]['content'] . $this->newline; + } + + // $name won't be set if no attachments were appended, + // and therefore a boundary wouldn't be necessary + empty($name) || $body .= '--' . $boundary . '--'; + } + + //-------------------------------------------------------------------- + + /** + * Prep Quoted Printable + * + * Prepares string for Quoted-Printable Content-Transfer-Encoding + * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt + * + * @param string $str + * + * @return string + */ + protected function prepQuotedPrintable($str) + { + // ASCII code numbers for "safe" characters that can always be + // used literally, without encoding, as described in RFC 2049. + // http://www.ietf.org/rfc/rfc2049.txt + static $ascii_safe_chars = [ + // ' ( ) + , - . / : = ? + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 47, + 58, + 61, + 63, + // numbers + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + // upper-case letters + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + // lower-case letters + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + ]; + + // We are intentionally wrapping so mail servers will encode characters + // properly and MUAs will behave, so {unwrap} must go! + $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); + + // RFC 2045 specifies CRLF as "\r\n". + // However, many developers choose to override that and violate + // the RFC rules due to (apparently) a bug in MS Exchange, + // which only works with "\n". + if ($this->CRLF === "\r\n") + { + return quoted_printable_encode($str); + } + + // Reduce multiple spaces & remove nulls + $str = preg_replace(['| +|', '/\x00+/'], [' ', ''], $str); + + // Standardize newlines + if (strpos($str, "\r") !== false) + { + $str = str_replace(["\r\n", "\r"], "\n", $str); + } + + $escape = '='; + $output = ''; + + foreach (explode("\n", $str) as $line) + { + $length = static::strlen($line); + $temp = ''; + + // Loop through each character in the line to add soft-wrap + // characters at the end of a line " =\r\n" and add the newly + // processed line(s) to the output (see comment on $crlf class property) + for ($i = 0; $i < $length; $i ++) + { + // Grab the next character + $char = $line[$i]; + $ascii = ord($char); + + // Convert spaces and tabs but only if it's the end of the line + if ($ascii === 32 || $ascii === 9) + { + if ($i === ($length - 1)) + { + $char = $escape . sprintf('%02s', dechex($ascii)); + } + } + // DO NOT move this below the $ascii_safe_chars line! + // + // = (equals) signs are allowed by RFC2049, but must be encoded + // as they are the encoding delimiter! + elseif ($ascii === 61) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D + } + elseif (! in_array($ascii, $ascii_safe_chars, true)) + { + $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + } + + // If we're at the character limit, add the line to the output, + // reset our temp variable, and keep on chuggin' + if ((static::strlen($temp) + static::strlen($char)) >= 76) + { + $output .= $temp . $escape . $this->CRLF; + $temp = ''; + } + + // Add the character to our temporary line + $temp .= $char; + } + + // Add our completed line to the output + $output .= $temp . $this->CRLF; + } + + // get rid of extra CRLF tacked onto the end + return static::substr($output, 0, static::strlen($this->CRLF) * -1); + } + + //-------------------------------------------------------------------- + + /** + * Prep Q Encoding + * + * Performs "Q Encoding" on a string for use in email headers. + * It's related but not identical to quoted-printable, so it has its + * own method. + * + * @param string $str + * + * @return string + */ + protected function prepQEncoding($str) + { + $str = str_replace(["\r", "\n"], '', $str); + + if ($this->charset === 'UTF-8') + { + // Note: We used to have mb_encode_mimeheader() as the first choice + // here, but it turned out to be buggy and unreliable. DO NOT + // re-add it! -- Narf + if (extension_loaded('iconv')) + { + $output = @iconv_mime_encode( + '', $str, [ + 'scheme' => 'Q', + 'line-length' => 76, + 'input-charset' => $this->charset, + 'output-charset' => $this->charset, + 'line-break-chars' => $this->CRLF, + ]); + + // There are reports that iconv_mime_encode() might fail and return FALSE + if ($output !== false) + { + // iconv_mime_encode() will always put a header field name. + // We've passed it an empty one, but it still prepends our + // encoded string with ': ', so we need to strip it. + return static::substr($output, 2); + } + + $chars = iconv_strlen($str, 'UTF-8'); + } + elseif (extension_loaded('mbstring')) + { + $chars = mb_strlen($str, 'UTF-8'); + } + } + + // We might already have this set for UTF-8 + isset($chars) || $chars = static::strlen($str); + + $output = '=?' . $this->charset . '?Q?'; + for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) + { + $chr = ($this->charset === 'UTF-8' && ICONV_ENABLED === true) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); + + // RFC 2045 sets a limit of 76 characters per line. + // We'll append ?= to the end of each line though. + if ($length + ($l = static::strlen($chr)) > 74) + { + $output .= '?=' . $this->CRLF // EOL + . ' =?' . $this->charset . '?Q?' . $chr; // New line + $length = 6 + static::strlen($this->charset) + $l; // Reset the length for the new line + } + else + { + $output .= $chr; + $length += $l; + } + } + + // End the header + return $output . '?='; + } + + //-------------------------------------------------------------------- + + /** + * Send Email + * + * @param boolean $autoClear + * + * @return boolean + */ + public function send(Email $email, bool $autoClear = true, bool $reallySend = true) + { + if (! isset($this->headers['From']) && ! empty($this->fromEmail)) + { + $this->setFrom($this->fromEmail, $this->fromName); + } + + if (! isset($this->headers['From'])) + { + throw EmailException::forNoFrom(); + } + + if ($this->replyToFlag === false) + { + $this->setReplyTo($this->headers['From']); + } + + if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) + ) + { + throw EmailException::forNoRecipients(); + } + + $this->buildHeaders(); + + if ($this->BCCBatchMode && count($this->BCCArray) > $this->BCCBatchSize) + { + $this->batchBCCSend(); + + if ($autoClear) + { + $this->clear(); + } + + return true; + } + + $this->buildMessage(); + $result = $this->spoolEmail(); + + if ($result && $autoClear) + { + $this->clear(); + } + + return $result; + } + + //-------------------------------------------------------------------- + + /** + * Batch Bcc Send. Sends groups of BCCs in batches + */ + public function batchBCCSend() + { + $float = $this->BCCBatchSize - 1; + $set = ''; + $chunk = []; + + for ($i = 0, $c = count($this->BCCArray); $i < $c; $i ++) + { + if (isset($this->BCCArray[$i])) + { + $set .= ', ' . $this->BCCArray[$i]; + } + + if ($i === $float) + { + $chunk[] = static::substr($set, 1); + $float += $this->BCCBatchSize; + $set = ''; + } + + if ($i === $c - 1) + { + $chunk[] = static::substr($set, 1); + } + } + + for ($i = 0, $c = count($chunk); $i < $c; $i ++) + { + unset($this->headers['Bcc']); + + $bcc = $this->cleanEmail($this->stringToArray($chunk[$i])); + + if ($this->protocol !== 'smtp') + { + $this->setHeader('Bcc', implode(', ', $bcc)); + } + else + { + $this->BCCArray = $bcc; + } + + $this->buildMessage(); + $this->spoolEmail(); + } + } + + //-------------------------------------------------------------------- + + /** + * Unwrap special elements + */ + protected function unwrapSpecials() + { + $this->finalBody = preg_replace_callback( + '/\{unwrap\}(.*?)\{\/unwrap\}/si', [ + $this, + 'removeNLCallback', + ], $this->finalBody + ); + } + + //-------------------------------------------------------------------- + + /** + * Strip line-breaks via callback + * + * @param string $matches + * + * @return string + */ + protected function removeNLCallback($matches) + { + if (strpos($matches[1], "\r") !== false || strpos($matches[1], "\n") !== false) + { + $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]); + } + + return $matches[1]; + } + + //-------------------------------------------------------------------- + + /** + * Spool mail to the mail server + * + * @return boolean + */ + protected function spoolEmail() + { + $this->unwrapSpecials(); + + $protocol = $this->getProtocol(); + $method = 'sendWith' . ucfirst($protocol); + try + { + $success = $this->$method(); + } + catch (\ErrorException $e) + { + $success = false; + $this->logger->error('Email: ' . $method . ' throwed ' . $e->getMessage()); + } + + if (! $success) + { + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + } + + $this->setErrorMessage(lang('Email.sent', [$protocol])); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Validate email for shell + * + * Applies stricter, shell-safe validation to email addresses. + * Introduced to prevent RCE via sendmail's -f option. + * + * @see https://github.com/codeigniter4/CodeIgniter/issues/4963 + * @see https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9ab36 + * @license https://creativecommons.org/publicdomain/zero/1.0/ CC0 1.0, Public Domain + * + * Credits for the base concept go to Paul Buonopane + * + * @param string &$email + * + * @return boolean + */ + protected function validateEmailForShell(&$email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = static::substr($email, 0, ++ $atpos) . idn_to_ascii( + static::substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); + } + + //-------------------------------------------------------------------- + + /** + * Send using mail() + * + * @return boolean + */ + protected function sendWithMail() + { + if (is_array($this->recipients)) + { + $this->recipients = implode(', ', $this->recipients); + } + + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['Return-Path']); + + if (! $this->validateEmailForShell($from)) + { + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); + } + + // most documentation of sendmail using the "-f" flag lacks a space after it, however + // we've encountered servers that seem to require it to be in place. + return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); + } + + //-------------------------------------------------------------------- + + /** + * Send using Sendmail + * + * @return boolean + */ + protected function sendWithSendmail() + { + // _validate_email_for_shell() below accepts by reference, + // so this needs to be assigned to a variable + $from = $this->cleanEmail($this->headers['From']); + if ($this->validateEmailForShell($from)) + { + $from = '-f ' . $from; + } + else + { + $from = ''; + } + + // is popen() enabled? + if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) + { + // server probably has popen disabled, so nothing we can do to get a verbose error. + return false; + } + + fputs($fp, $this->headerStr); + fputs($fp, $this->finalBody); + + $status = pclose($fp); + + if ($status !== 0) + { + throw EmailException::forNosocket($status); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send using SMTP + * + * @return boolean + */ + protected function sendWithSmtp() + { + if ($this->SMTPHost === '') + { + throw EmailException::forNoHostname(); + } + + if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) + { + return false; + } + + if (! $this->sendCommand('from', $this->cleanEmail($this->headers['From']))) + { + $this->SMTPEnd(); + + return false; + } + + foreach ($this->recipients as $val) + { + if (! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->CCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + foreach ($this->BCCArray as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + + if (! $this->sendCommand('data')) + { + $this->SMTPEnd(); + + return false; + } + + // perform dot transformation on any lines that begin with a dot + $this->sendData($this->headerStr . preg_replace('/^\./m', '..$1', $this->finalBody)); + + $this->sendData('.'); + $reply = $this->getSMTPData(); + $this->setErrorMessage($reply); + + $this->SMTPEnd(); + + if (strpos($reply, '250') !== 0) + { + throw EmailException::forSMTPError($reply); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP End + * + * Shortcut to send RSET or QUIT depending on keep-alive + */ + protected function SMTPEnd() + { + $this->sendCommand($this->SMTPKeepAlive ? 'reset' : 'quit'); + } + + //-------------------------------------------------------------------- + + /** + * SMTP Connect + * + * @return string + */ + protected function SMTPConnect() + { + if (is_resource($this->SMTPConnect)) + { + return true; + } + + $ssl = ($this->SMTPCrypto === 'ssl') ? 'ssl://' : ''; + + $this->SMTPConnect = fsockopen( + $ssl . $this->SMTPHost, $this->SMTPPort, $errno, $errstr, $this->SMTPTimeout + ); + + if (! is_resource($this->SMTPConnect)) + { + throw EmailException::forSMTPError($errno . ' ' . $errstr); + } + + stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); + $this->setErrorMessage($this->getSMTPData()); + + if ($this->SMTPCrypto === 'tls') + { + $this->sendCommand('hello'); + $this->sendCommand('starttls'); + + $crypto = stream_socket_enable_crypto($this->SMTPConnect, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($crypto !== true) + { + throw EmailException::forSMTPError($this->getSMTPData()); + } + } + + return $this->sendCommand('hello'); + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP command + * + * @param string $cmd + * @param string $data + * + * @return boolean + */ + protected function sendCommand($cmd, $data = '') + { + switch ($cmd) + { + case 'hello': + if ($this->SMTPAuth || $this->getEncoding() === '8bit') + { + $this->sendData('EHLO ' . $this->getHostname()); + } + else + { + $this->sendData('HELO ' . $this->getHostname()); + } + + $resp = 250; + break; + case 'starttls': + $this->sendData('STARTTLS'); + $resp = 220; + break; + case 'from': + $this->sendData('MAIL FROM:<' . $data . '>'); + $resp = 250; + break; + case 'to': + if ($this->DSN) + { + $this->sendData('RCPT TO:<' . $data . '> NOTIFY=SUCCESS,DELAY,FAILURE ORCPT=rfc822;' . $data); + } + else + { + $this->sendData('RCPT TO:<' . $data . '>'); + } + $resp = 250; + break; + case 'data': + $this->sendData('DATA'); + $resp = 354; + break; + case 'reset': + $this->sendData('RSET'); + $resp = 250; + break; + case 'quit': + $this->sendData('QUIT'); + $resp = 221; + break; + } + + $reply = $this->getSMTPData(); + + $this->debugMessage[] = '
' . $cmd . ': ' . $reply . '
'; + + if ((int) static::substr($reply, 0, 3) !== $resp) + { + throw EmailException::forSMTPError($reply); + } + + if ($cmd === 'quit') + { + fclose($this->SMTPConnect); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP Authenticate + * + * @return boolean + */ + protected function SMTPAuthenticate() + { + if (! $this->SMTPAuth) + { + return true; + } + + if ($this->SMTPUser === '' && $this->SMTPPass === '') + { + throw EmailException::forNoSMTPAuth(); + } + + $this->sendData('AUTH LOGIN'); + $reply = $this->getSMTPData(); + + if (strpos($reply, '503') === 0) // Already authenticated + { + return true; + } + elseif (strpos($reply, '334') !== 0) + { + throw EmailException::forFailedSMTPLogin($reply); + } + + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '334') !== 0) + { + throw EmailException::forSMTPAuthUsername($reply); + } + + $this->sendData(base64_encode($this->SMTPPass)); + $reply = $this->getSMTPData(); + + if (strpos($reply, '235') !== 0) + { + throw EmailException::forSMTPAuthPassword($reply); + } + + if ($this->SMTPKeepAlive) + { + $this->SMTPAuth = false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP data + * + * @param string $data + * + * @return boolean + */ + protected function sendData($data) + { + $data .= $this->newline; + for ($written = $timestamp = 0, $length = static::strlen($data); $written < $length; $written += $result) + { + if (($result = fwrite($this->SMTPConnect, static::substr($data, $written))) === false) + { + break; + } + // See https://bugs.php.net/bug.php?id=39598 and http://php.net/manual/en/function.fwrite.php#96951 + elseif ($result === 0) + { + if ($timestamp === 0) + { + $timestamp = time(); + } + elseif ($timestamp < (time() - $this->SMTPTimeout)) + { + $result = false; + break; + } + + usleep(250000); + continue; + } + else + { + $timestamp = 0; + } + } + + if ($result === false) + { + throw EmailException::forSMTPDataFailure($data); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Get SMTP data + * + * @return string + */ + protected function getSMTPData() + { + $data = ''; + + while ($str = fgets($this->SMTPConnect, 512)) + { + $data .= $str; + + if ($str[3] === ' ') + { + break; + } + } + + return $data; + } + + //-------------------------------------------------------------------- + + /** + * Get Hostname + * + * There are only two legal types of hostname - either a fully + * qualified domain name (eg: "mail.example.com") or an IP literal + * (eg: "[1.2.3.4]"). + * + * @link https://tools.ietf.org/html/rfc5321#section-2.3.5 + * @link http://cbl.abuseat.org/namingproblems.html + * + * @return string + */ + protected function getHostname() + { + if (isset($_SERVER['SERVER_NAME'])) + { + return $_SERVER['SERVER_NAME']; + } + + return isset($_SERVER['SERVER_ADDR']) ? '[' . $_SERVER['SERVER_ADDR'] . ']' : '[127.0.0.1]'; + } + + //-------------------------------------------------------------------- + + /** + * Get Debug Message + * + * @param array $include List of raw data chunks to include in the output + * Valid options are: 'headers', 'subject', 'body' + * + * @return string + */ + public function printDebugger($include = ['headers', 'subject', 'body']) + { + $msg = implode('', $this->debugMessage); + + // Determine which parts of our raw data needs to be printed + $raw_data = ''; + is_array($include) || $include = [$include]; + + in_array('headers', $include, true) && $raw_data = htmlspecialchars($this->headerStr) . "\n"; + in_array('subject', $include, true) && $raw_data .= htmlspecialchars($this->subject) . "\n"; + in_array('body', $include, true) && $raw_data .= htmlspecialchars($this->finalBody); + + return $msg . ($raw_data === '' ? '' : '
' . $raw_data . '
'); + } + + //-------------------------------------------------------------------- + + /** + * Set Message + * + * @param string $msg + */ + protected function setErrorMessage($msg) + { + $this->debugMessage[] = $msg . '
'; + } + + //-------------------------------------------------------------------- + + /** + * Mime Types + * + * @param string $ext + * + * @return string + */ + protected function mimeTypes($ext = '') + { + $mime = Mimes::guessTypeFromExtension(strtolower($ext)); + + return ! empty($mime) ? $mime : 'application/x-unknown-content-type'; + } + + //-------------------------------------------------------------------- + + /** + * Destructor + */ + public function __destruct() + { + is_resource($this->SMTPConnect) && $this->sendCommand('quit'); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe strlen() + * + * @param string $str + * + * @return integer + */ + protected static function strlen($str) + { + return (static::$func_overload) ? mb_strlen($str, '8bit') : strlen($str); + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe substr() + * + * @param string $str + * @param integer $start + * @param integer|null $length + * + * @return string + */ + protected static function substr($str, $start, $length = null) + { + if (static::$func_overload) + { + return mb_substr($str, $start, $length, '8bit'); + } + + return isset($length) ? substr($str, $start, $length) : substr($str, $start); + } + +} diff --git a/system/Email/TransporterInterface.php b/system/Email/TransporterInterface.php index 92de170bea..62c6324167 100644 --- a/system/Email/TransporterInterface.php +++ b/system/Email/TransporterInterface.php @@ -45,9 +45,16 @@ interface TransporterInterface /** * Send an email * - * @param boolean $destroy Should old session data be destroyed? + * @param Email The email to send + * @param boolean $autoClear Should old session data be destroyed? + * @param boolean $reallySend Should the email really be sent? or everything but that */ - public function send(Email $email, bool $autoClear = true); + public function send(Email $email, bool $autoClear = true, bool $reallySend = true); + + /** + * Which protocol is this a handler for? + */ + public function getProtocol() : string; //-------------------------------------------------------------------- } diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php new file mode 100644 index 0000000000..b861e031ea --- /dev/null +++ b/tests/system/Email/EmailTest.php @@ -0,0 +1,55 @@ + true, 'truth' => 'out there']); + $this->assertTrue($email->wordWrap); + $this->assertEquals(76, $email->wrapChars); + $this->assertEquals('text', $email->mailType); + $this->assertEquals('UTF-8', $email->charset); + $this->assertEquals('', $email->altMessage); + $this->assertTrue($email->validate); + $this->assertNull($email->truth); + } + + public function testDefaultWithEmptyConfig() + { + $email = new Email(); + $this->assertTrue($email->wordWrap); + $this->assertEquals(76, $email->wrapChars); + $this->assertEquals('text', $email->mailType); + $this->assertEquals('UTF-8', $email->charset); + $this->assertEquals('', $email->altMessage); + $this->assertFalse($email->validate); // this one differs + $this->assertNull($email->truth); + } + + public function testSetFromEmailOnly() + { + $email = new Email(); + $email->setFrom(''); + $this->assertEquals(' ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + + public function testSetFromEmailAndName() + { + $email = new Email(); + $email->setFrom('', 'Princess Leia'); + $this->assertEquals('"Princess Leia" ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + + public function testSetFromEmailAndFunkyName() + { + $email = new Email(); + $email->setFrom('', 'Princess Leià'); + $this->assertEquals('=?UTF-8?Q?Princess=20Lei=C3=A0?= ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + +} From bc552682ccf89ae3e4ade63bd2e8c639d5f41d16 Mon Sep 17 00:00:00 2001 From: Jim Parry Date: Mon, 17 Dec 2018 12:53:52 -0800 Subject: [PATCH 3/5] Test email->to --- system/Email/Email.php | 88 +++++++++++++++--- system/Email/Exceptions/EmailException.php | 7 +- system/Email/Handlers/MailHandler.php | 1 + system/Email/Handlers/SMTPHandler.php | 29 +++--- system/Email/Handlers/SendmailHandler.php | 29 +++--- tests/system/Email/EmailTest.php | 103 ++++++++++++++++++++- 6 files changed, 212 insertions(+), 45 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index d506d6e9ab..472cb9b765 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -238,6 +238,13 @@ class Email */ protected $attachments = []; + /** + * Which protocol are we using? + * + * @var string + */ + protected $protocol = 'mail'; + /** * Valid $protocol values * @@ -305,6 +312,13 @@ class Email */ protected $handler; + /** + * Config settings, used if protocol changed. + * + * @var Config\Email + */ + protected $config = null; + //-------------------------------------------------------------------- /** @@ -318,7 +332,17 @@ class Email { if ($config === null) { - $config = new \Config\Email(); + $config = new \Config\Email(); + $this->config = $config; + } + else if (is_array($config)) + { + $mergedConfig = new \Config\Email(); + foreach ($config as $key => $value) + { + $mergedConfig->$key = $value; + } + $this->config = $mergedConfig; } $this->initialize($config); } @@ -365,6 +389,41 @@ class Email //-------------------------------------------------------------------- + /** + * Set the email protocol to use. + * If valid, get an appropriate handler for it. + * + * @param string $protocol + * + * @returns Email + */ + public function setProtocol(string $protocol = 'mail') + { + if (! in_array($protocol, $this->protocols)) + { + throw EmailException::forInvalidProtocol($protocol); + } + + $this->protocol = $protocol; + $this->config->protocol = $protocol; // update config too + $this->handler = Services::transporter($this->config, false); + + return $this; + } + + /** + * Get the protocol our handler is using. + * Note: the protocol property here is only the requested one. + * + * @return string + */ + public function getProtocol(): string + { + return $this->handler->getProtocol(); + } + + //-------------------------------------------------------------------- + /** * Initialize the Email Data * @@ -414,10 +473,10 @@ class Email if ($this->validate) { - $this->validateEmail($this->stringToArray($from)); + $this->validateEmail($from); if ($returnPath) { - $this->validateEmail($this->stringToArray($returnPath)); + $this->validateEmail($returnPath); } } @@ -965,9 +1024,9 @@ class Email //-------------------------------------------------------------------- /** - * Validate Email Address + * Validate Email Address(es) * - * @param string $email + * @param string|array $email * * @return boolean */ @@ -975,7 +1034,7 @@ class Email { if (! is_array($email)) { - throw EmailException::forMustBeArray(); + $email = [$email]; } foreach ($email as $val) @@ -1000,12 +1059,13 @@ class Email */ public function isValidEmail($email) { - if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46') && $atpos = strpos($email, '@')) - { - $email = mb_substr($email, 0, ++ $atpos, '8bit') . idn_to_ascii( - mb_substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 - ); - } + $email = $this->cleanEMail($email); + + // sanitize the domain name + $atpos = strpos($email, '@'); + $email = mb_substr($email, 0, ++ $atpos, '8bit') . idn_to_ascii( + mb_substr($email, $atpos), 0, INTL_IDNA_VARIANT_UTS46 + ); return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); } @@ -1015,9 +1075,9 @@ class Email /** * Clean Extended Email Address: Joe Smith * - * @param string $email + * @param string|array $email An email string, or array of them * - * @return string + * @return string|array The "clean" email-only address(es) */ public function cleanEmail($email) { diff --git a/system/Email/Exceptions/EmailException.php b/system/Email/Exceptions/EmailException.php index f64a39eae2..eff2976af1 100644 --- a/system/Email/Exceptions/EmailException.php +++ b/system/Email/Exceptions/EmailException.php @@ -1,5 +1,5 @@ protocol = 'mail'; $this->initialize($config); log_message('info', 'Email Class Initialized'); diff --git a/system/Email/Handlers/SMTPHandler.php b/system/Email/Handlers/SMTPHandler.php index e34336cf50..74569a1ba0 100644 --- a/system/Email/Handlers/SMTPHandler.php +++ b/system/Email/Handlers/SMTPHandler.php @@ -317,6 +317,7 @@ class SMTPHandler extends BaseHandler */ public function __construct($config = null) { + $this->protocol = 'smtp'; $this->initialize($config); log_message('info', 'Email Class Initialized'); @@ -385,7 +386,7 @@ class SMTPHandler extends BaseHandler $this->headers = []; $this->debugMessage = []; - $this->setHeader('Date', $this->setDate()); + // $this->setHeader('Date', $this->setDate()); if ($clearAttachments !== false) { @@ -917,7 +918,7 @@ class SMTPHandler extends BaseHandler if (! isset($this->headers['From'])) { - throw EmailException::forNoFrom(); + throw EmailException::forNoFrom(); } if ($this->replyToFlag === false) @@ -928,7 +929,7 @@ class SMTPHandler extends BaseHandler if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) ) { - throw EmailException::forNoRecipients(); + throw EmailException::forNoRecipients(); } $this->buildHeaders(); @@ -1066,7 +1067,7 @@ class SMTPHandler extends BaseHandler if (! $success) { - throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); } $this->setErrorMessage(lang('Email.sent', [$protocol])); @@ -1167,7 +1168,7 @@ class SMTPHandler extends BaseHandler if ($status !== 0) { - throw EmailException::forNosocket($status); + throw EmailException::forNosocket($status); } return true; @@ -1184,7 +1185,7 @@ class SMTPHandler extends BaseHandler { if ($this->SMTPHost === '') { - throw EmailException::forNoHostname(); + throw EmailException::forNoHostname(); } if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) @@ -1247,7 +1248,7 @@ class SMTPHandler extends BaseHandler if (strpos($reply, '250') !== 0) { - throw EmailException::forSMTPError($reply); + throw EmailException::forSMTPError($reply); } return true; @@ -1287,7 +1288,7 @@ class SMTPHandler extends BaseHandler if (! is_resource($this->SMTPConnect)) { - throw EmailException::forSMTPError($errno . ' ' . $errstr); + throw EmailException::forSMTPError($errno . ' ' . $errstr); } stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); @@ -1374,7 +1375,7 @@ class SMTPHandler extends BaseHandler if ((int) static::substr($reply, 0, 3) !== $resp) { - throw EmailException::forSMTPError($reply); + throw EmailException::forSMTPError($reply); } if ($cmd === 'quit') @@ -1401,7 +1402,7 @@ class SMTPHandler extends BaseHandler if ($this->SMTPUser === '' && $this->SMTPPass === '') { - throw EmailException::forNoSMTPAuth(); + throw EmailException::forNoSMTPAuth(); } $this->sendData('AUTH LOGIN'); @@ -1413,7 +1414,7 @@ class SMTPHandler extends BaseHandler } elseif (strpos($reply, '334') !== 0) { - throw EmailException::forFailedSMTPLogin($reply); + throw EmailException::forFailedSMTPLogin($reply); } $this->sendData(base64_encode($this->SMTPUser)); @@ -1421,7 +1422,7 @@ class SMTPHandler extends BaseHandler if (strpos($reply, '334') !== 0) { - throw EmailException::forSMTPAuthUsername($reply); + throw EmailException::forSMTPAuthUsername($reply); } $this->sendData(base64_encode($this->SMTPPass)); @@ -1429,7 +1430,7 @@ class SMTPHandler extends BaseHandler if (strpos($reply, '235') !== 0) { - throw EmailException::forSMTPAuthPassword($reply); + throw EmailException::forSMTPAuthPassword($reply); } if ($this->SMTPKeepAlive) @@ -1482,7 +1483,7 @@ class SMTPHandler extends BaseHandler if ($result === false) { - throw EmailException::forSMTPDataFailure($data); + throw EmailException::forSMTPDataFailure($data); } return true; diff --git a/system/Email/Handlers/SendmailHandler.php b/system/Email/Handlers/SendmailHandler.php index 35c26ae4cb..a743711182 100644 --- a/system/Email/Handlers/SendmailHandler.php +++ b/system/Email/Handlers/SendmailHandler.php @@ -317,6 +317,7 @@ class SendmailHandler extends BaseHandler */ public function __construct($config = null) { + $this->protocol = 'sendmail'; $this->initialize($config); log_message('info', 'Email Class Initialized'); @@ -385,7 +386,7 @@ class SendmailHandler extends BaseHandler $this->headers = []; $this->debugMessage = []; - $this->setHeader('Date', $this->setDate()); + // $this->setHeader('Date', $this->setDate()); if ($clearAttachments !== false) { @@ -917,7 +918,7 @@ class SendmailHandler extends BaseHandler if (! isset($this->headers['From'])) { - throw EmailException::forNoFrom(); + throw EmailException::forNoFrom(); } if ($this->replyToFlag === false) @@ -928,7 +929,7 @@ class SendmailHandler extends BaseHandler if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) ) { - throw EmailException::forNoRecipients(); + throw EmailException::forNoRecipients(); } $this->buildHeaders(); @@ -1066,7 +1067,7 @@ class SendmailHandler extends BaseHandler if (! $success) { - throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); + throw EmailException::forSendFailure($protocol === 'mail' ? 'PHPMail' : ucfirst($protocol)); } $this->setErrorMessage(lang('Email.sent', [$protocol])); @@ -1167,7 +1168,7 @@ class SendmailHandler extends BaseHandler if ($status !== 0) { - throw EmailException::forNosocket($status); + throw EmailException::forNosocket($status); } return true; @@ -1184,7 +1185,7 @@ class SendmailHandler extends BaseHandler { if ($this->SMTPHost === '') { - throw EmailException::forNoHostname(); + throw EmailException::forNoHostname(); } if (! $this->SMTPConnect() || ! $this->SMTPAuthenticate()) @@ -1247,7 +1248,7 @@ class SendmailHandler extends BaseHandler if (strpos($reply, '250') !== 0) { - throw EmailException::forSMTPError($reply); + throw EmailException::forSMTPError($reply); } return true; @@ -1287,7 +1288,7 @@ class SendmailHandler extends BaseHandler if (! is_resource($this->SMTPConnect)) { - throw EmailException::forSMTPError($errno . ' ' . $errstr); + throw EmailException::forSMTPError($errno . ' ' . $errstr); } stream_set_timeout($this->SMTPConnect, $this->SMTPTimeout); @@ -1374,7 +1375,7 @@ class SendmailHandler extends BaseHandler if ((int) static::substr($reply, 0, 3) !== $resp) { - throw EmailException::forSMTPError($reply); + throw EmailException::forSMTPError($reply); } if ($cmd === 'quit') @@ -1401,7 +1402,7 @@ class SendmailHandler extends BaseHandler if ($this->SMTPUser === '' && $this->SMTPPass === '') { - throw EmailException::forNoSMTPAuth(); + throw EmailException::forNoSMTPAuth(); } $this->sendData('AUTH LOGIN'); @@ -1413,7 +1414,7 @@ class SendmailHandler extends BaseHandler } elseif (strpos($reply, '334') !== 0) { - throw EmailException::forFailedSMTPLogin($reply); + throw EmailException::forFailedSMTPLogin($reply); } $this->sendData(base64_encode($this->SMTPUser)); @@ -1421,7 +1422,7 @@ class SendmailHandler extends BaseHandler if (strpos($reply, '334') !== 0) { - throw EmailException::forSMTPAuthUsername($reply); + throw EmailException::forSMTPAuthUsername($reply); } $this->sendData(base64_encode($this->SMTPPass)); @@ -1429,7 +1430,7 @@ class SendmailHandler extends BaseHandler if (strpos($reply, '235') !== 0) { - throw EmailException::forSMTPAuthPassword($reply); + throw EmailException::forSMTPAuthPassword($reply); } if ($this->SMTPKeepAlive) @@ -1482,7 +1483,7 @@ class SendmailHandler extends BaseHandler if ($result === false) { - throw EmailException::forSMTPDataFailure($data); + throw EmailException::forSMTPDataFailure($data); } return true; diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index b861e031ea..3f3e32f515 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -4,6 +4,9 @@ namespace CodeIgniter\Email; class EmailTest extends \CIUnitTestCase { + //-------------------------------------------------------------------- + // Test constructor & configs + public function testDefaultWithCustomConfig() { $email = new Email(['validate' => true, 'truth' => 'out there']); @@ -28,10 +31,13 @@ class EmailTest extends \CIUnitTestCase $this->assertNull($email->truth); } + //-------------------------------------------------------------------- + // Test setting the "from" property + public function testSetFromEmailOnly() { $email = new Email(); - $email->setFrom(''); + $email->setFrom('leia@alderaan.org'); $this->assertEquals(' ', $email->getHeader('From')); $this->assertEquals('', $email->getHeader('Return-Path')); } @@ -39,7 +45,7 @@ class EmailTest extends \CIUnitTestCase public function testSetFromEmailAndName() { $email = new Email(); - $email->setFrom('', 'Princess Leia'); + $email->setFrom('leia@alderaan.org', 'Princess Leia'); $this->assertEquals('"Princess Leia" ', $email->getHeader('From')); $this->assertEquals('', $email->getHeader('Return-Path')); } @@ -52,4 +58,97 @@ class EmailTest extends \CIUnitTestCase $this->assertEquals('', $email->getHeader('Return-Path')); } + public function testSetFromWithValidation() + { + $email = new Email(['validation' => true]); + $email->setFrom('leia@alderaan.org', 'Princess Leia'); + $this->assertEquals('"Princess Leia" ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + + public function testSetFromWithValidationAndReturnPath() + { + $email = new Email(['validation' => true]); + $email->setFrom('leia@alderaan.org', 'Princess Leia', 'leia@alderaan.org'); + $this->assertEquals('"Princess Leia" ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + + public function testSetFromWithValidationAndDifferentReturnPath() + { + $email = new Email(['validation' => true]); + $email->setFrom('leia@alderaan.org', 'Princess Leia', 'padme@naboo.org'); + $this->assertEquals('"Princess Leia" ', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('Return-Path')); + } + + //-------------------------------------------------------------------- + // Test setting the "to" property + + public function testSetToBasic() + { + $email = new Email(); + $email->setTo('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); + } + + public function testSetToValid() + { + $email = new Email(['validate' => true]); + $email->setTo('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); + } + + public function testSetToInvalid() + { + $email = new Email(['validate' => false]); + $email->setTo('Luke '); + $this->assertTrue(in_array('luke@tatooine', $email->recipients)); + } + + /** + * @expectedException \CodeIgniter\Email\Exceptions\EmailException + */ + public function testDontSetToInvalid() + { + $email = new Email(['validate' => true]); + $email->setTo('Luke '); + } + + public function testSetToHeader() + { + $email = new Email(['validate' => true]); + $email->setProtocol('sendmail'); + $email->setTo('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); + $this->assertEquals('luke@tatooine.org', $email->getHeader('To')); + } + + //-------------------------------------------------------------------- + // Test changing the protocol + + public function testSetProtocol() + { + $email = new Email(); + $this->assertEquals('mail', $email->getProtocol()); // default + $email->setProtocol('smtp'); + $this->assertEquals('smtp', $email->getProtocol()); + $email->setProtocol('mail'); + $this->assertEquals('mail', $email->getProtocol()); + } + + //-------------------------------------------------------------------- + // Test support methods + + public function testValidEmail() + { + $email = new Email(); + $this->assertTrue($email->isValidEmail('"Princess Leia" ')); + $this->assertTrue($email->isValidEmail('leia@alderaan.org')); + $this->assertTrue($email->isValidEmail('')); + $this->assertFalse($email->isValidEmail('')); + $this->assertFalse($email->isValidEmail('')); + $this->assertFalse($email->isValidEmail('')); + } + } From c9d8eff637d9a3ddd59ec4c58589791bed9c3665 Mon Sep 17 00:00:00 2001 From: Jim Parry Date: Mon, 17 Dec 2018 16:58:24 -0800 Subject: [PATCH 4/5] Test recipients & message --- system/Email/Email.php | 31 ++--- tests/system/Email/EmailTest.php | 216 ++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 16 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index 472cb9b765..411cd5bfc9 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -626,7 +626,7 @@ class Email /** * Set Recipients * - * @param string $to + * @param string|array $to One or more recipients * * @return Email */ @@ -655,7 +655,7 @@ class Email /** * Set CC * - * @param string $cc + * @param string|array $cc * * @return Email */ @@ -670,10 +670,10 @@ class Email $this->setHeader('Cc', implode(', ', $cc)); - if ($this->handler->getProtocol() === 'smtp') - { + // if ($this->handler->getProtocol() === 'smtp') + // { $this->CCArray = $cc; - } + // } return $this; } @@ -683,14 +683,14 @@ class Email /** * Set BCC * - * @param string $bcc - * @param string $limit + * @param string|array $bcc + * @param integer|null $limit * * @return Email */ - public function setBCC($bcc, $limit = '') + public function setBCC($bcc, int $limit = null) { - if ($limit !== '' && is_numeric($limit)) + if ($limit !== null) { $this->BCCBatchMode = true; $this->BCCBatchSize = $limit; @@ -703,14 +703,15 @@ class Email $this->validateEmail($bcc); } - if ($this->handler->getProtocol() === 'smtp' || ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize)) - { + // if ($this->handler->getProtocol() === 'smtp' || + // if ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize) + // { $this->BCCArray = $bcc; - } - else - { + // } + // else + // { $this->setHeader('Bcc', implode(', ', $bcc)); - } + // } return $this; } diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index 3f3e32f515..764c48d6eb 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -83,7 +83,7 @@ class EmailTest extends \CIUnitTestCase } //-------------------------------------------------------------------- - // Test setting the "to" property + // Test setting the "to" property (recipients) public function testSetToBasic() { @@ -92,6 +92,14 @@ class EmailTest extends \CIUnitTestCase $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); } + public function testSetToArray() + { + $email = new Email(); + $email->setTo(['Luke ', 'padme@naboo.org']); + $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); + $this->assertTrue(in_array('padme@naboo.org', $email->recipients)); + } + public function testSetToValid() { $email = new Email(['validate' => true]); @@ -124,6 +132,203 @@ class EmailTest extends \CIUnitTestCase $this->assertEquals('luke@tatooine.org', $email->getHeader('To')); } + //-------------------------------------------------------------------- + // Test setting the "cc" property (copied recipients) + + public function testSetCCBasic() + { + $email = new Email(); + $email->setCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->CCArray)); + } + + public function testSetCCArray() + { + $email = new Email(); + $email->setCC(['Luke ', 'padme@naboo.org']); + $this->assertTrue(in_array('luke@tatooine.org', $email->CCArray)); + $this->assertTrue(in_array('padme@naboo.org', $email->CCArray)); + $this->assertEquals('luke@tatooine.org, padme@naboo.org', $email->getHeader('Cc')); + } + + public function testSetCCValid() + { + $email = new Email(['validate' => true]); + $email->setCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->CCArray)); + } + + public function testSetCCInvalid() + { + $email = new Email(['validate' => false]); + $email->setCC('Luke '); + $this->assertTrue(in_array('luke@tatooine', $email->CCArray)); + } + + /** + * @expectedException \CodeIgniter\Email\Exceptions\EmailException + */ + public function testDontSetCCInvalid() + { + $email = new Email(['validate' => true]); + $email->setCC('Luke '); + } + + public function testSetCCHeader() + { + $email = new Email(['validate' => true]); + $email->setCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->CCArray)); + $this->assertEquals('luke@tatooine.org', $email->getHeader('Cc')); + } + + public function testSetCCForSMTP() + { + $email = new Email(['validate' => true]); + $email->setProtocol('smtp'); + $email->setCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->CCArray)); + $this->assertEquals('luke@tatooine.org', $email->getHeader('Cc')); + } + + //-------------------------------------------------------------------- + // Test setting the "bcc" property (blind-copied recipients) + + public function testSetBCCBasic() + { + $email = new Email(); + $email->setBCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + } + + public function testSetBCCArray() + { + $email = new Email(); + $email->setBCC(['Luke ', 'padme@naboo.org']); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + $this->assertTrue(in_array('padme@naboo.org', $email->BCCArray)); + $this->assertEquals('luke@tatooine.org, padme@naboo.org', $email->getHeader('Bcc')); + } + + public function testSetBCCValid() + { + $email = new Email(['validate' => true]); + $email->setBCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + } + + public function testSetBCCInvalid() + { + $email = new Email(['validate' => false]); + $email->setBCC('Luke '); + $this->assertTrue(in_array('luke@tatooine', $email->BCCArray)); + } + + /** + * @expectedException \CodeIgniter\Email\Exceptions\EmailException + */ + public function testDontSetBCCInvalid() + { + $email = new Email(['validate' => true]); + $email->setBCC('Luke '); + } + + public function testSetBCCHeader() + { + $email = new Email(['validate' => true]); + $email->setBCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + $this->assertEquals('luke@tatooine.org', $email->getHeader('Bcc')); + } + + public function testSetBCCForSMTP() + { + $email = new Email(['validate' => true]); + $email->setProtocol('smtp'); + $email->setBCC('Luke '); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + $this->assertEquals('luke@tatooine.org', $email->getHeader('Bcc')); + } + + public function testSetBCCBatch() + { + $email = new Email(); + $email->setBCC(['Luke ', 'padme@naboo.org'], 2); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + $this->assertTrue(in_array('padme@naboo.org', $email->BCCArray)); + $this->assertEquals('luke@tatooine.org, padme@naboo.org', $email->getHeader('Bcc')); + } + + public function testSetBCCBiggerBatch() + { + $email = new Email(); + $email->setBCC(['Luke ', 'padme@naboo.org', 'leia@alderaan.org'], 2); + $this->assertTrue(in_array('luke@tatooine.org', $email->BCCArray)); + $this->assertTrue(in_array('padme@naboo.org', $email->BCCArray)); + $this->assertEquals('luke@tatooine.org, padme@naboo.org, leia@alderaan.org', $email->getHeader('Bcc')); + } + + //-------------------------------------------------------------------- + // Test setting the subject + + public function testSetSubject() + { + $email = new Email(); + $original = 'Just a silly love song'; + $expected = '=?UTF-8?Q?Just=20a=20silly=20love=20so?==?UTF-8?Q?ng?='; + $email->setSubject($original); + $this->assertEquals($expected, $email->getHeader('Subject')); + } + + public function testSetEncodedSubject() + { + $email = new Email(); + $original = 'Just a silly Leià song'; + $expected = '=?UTF-8?Q?Just=20a=20silly=20Lei=C3=A0=20s?==?UTF-8?Q?ong?='; + $email->setSubject($original); + $this->assertEquals($expected, $email->getHeader('Subject')); + } + + //-------------------------------------------------------------------- + // Test setting the body + + public function testSetMessage() + { + $email = new Email(); + $original = 'Just a silly love song'; + $expected = $original; + $email->setMessage($original); + $this->assertEquals($expected, $email->body); + } + + public function testSetMultilineMessage() + { + $email = new Email(); + $original = "Just a silly love song\n\rIt's just two lines long"; + $expected = "Just a silly love song\nIt's just two lines long"; + $email->setMessage($original); + $this->assertEquals($expected, $email->body); + } + + //-------------------------------------------------------------------- + // Test clearing the email + + public function testClearing() + { + $email = new Email(); + $email->setFrom('leia@alderaan.org'); + $this->assertEquals(' ', $email->getHeader('From')); + $email->setTo('luke@tatooine.org'); + $this->assertTrue(in_array('luke@tatooine.org', $email->recipients)); + + $email->clear(true); + $this->assertEquals('', $email->getHeader('From')); + $this->assertEquals('', $email->getHeader('To')); + + $email->setFrom('leia@alderaan.org'); + $this->assertEquals(' ', $email->getHeader('From')); + } + //-------------------------------------------------------------------- // Test changing the protocol @@ -137,6 +342,15 @@ class EmailTest extends \CIUnitTestCase $this->assertEquals('mail', $email->getProtocol()); } + /** + * @expectedException \CodeIgniter\Email\Exceptions\EmailException + */ + public function testSetBadProtocol() + { + $email = new Email(); + $email->setProtocol('mind-reader'); + } + //-------------------------------------------------------------------- // Test support methods From 7a80150900630edcebe4b110246a15a94c800a49 Mon Sep 17 00:00:00 2001 From: Jim Parry Date: Wed, 19 Dec 2018 01:55:59 -0800 Subject: [PATCH 5/5] Finish Email testing --- system/Email/Email.php | 206 +---------------- system/Email/Handlers/BaseHandler.php | 106 +++++++++ tests/_support/Email/ci-logo-not-readable.png | Bin 0 -> 4537 bytes tests/_support/Email/ci-logo.png | Bin 0 -> 4537 bytes tests/system/Config/ServicesTest.php | 16 +- tests/system/Email/EmailTest.php | 214 +++++++++++++++++- 6 files changed, 332 insertions(+), 210 deletions(-) create mode 100644 tests/_support/Email/ci-logo-not-readable.png create mode 100644 tests/_support/Email/ci-logo.png diff --git a/system/Email/Email.php b/system/Email/Email.php index 411cd5bfc9..307b35125b 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -672,7 +672,7 @@ class Email // if ($this->handler->getProtocol() === 'smtp') // { - $this->CCArray = $cc; + $this->CCArray = $cc; // } return $this; @@ -706,11 +706,11 @@ class Email // if ($this->handler->getProtocol() === 'smtp' || // if ($this->BCCBatchMode && count($bcc) > $this->BCCBatchSize) // { - $this->BCCArray = $bcc; + $this->BCCArray = $bcc; // } // else // { - $this->setHeader('Bcc', implode(', ', $bcc)); + $this->setHeader('Bcc', implode(', ', $bcc)); // } return $this; @@ -757,7 +757,7 @@ class Email * @param string $file Can be local path, URL or buffered content * @param string $disposition 'attachment' * @param string $newName - * @param string $mime Empty if $file is buffered content + * @param string $mime Not-empty if $file is buffered content * * @return Email */ @@ -787,7 +787,7 @@ class Email // declare names on their own, to make phpcbf happy $namesAttached = [ $file, - $newname, + $newName, ]; $this->attachments[] = [ 'name' => $namesAttached, @@ -935,66 +935,6 @@ class Email //-------------------------------------------------------------------- - /** - * Get the Message ID - * - * @return string - */ - protected function getMessageID() - { - $from = str_replace(['>', '<'], '', $this->headers['Return-Path']); - - return '<' . uniqid('', true) . strstr($from, '@') . '>'; - } - - //-------------------------------------------------------------------- - - /** - * Get Mail Encoding - * - * @return string - */ - protected function getEncoding() - { - in_array($this->encoding, $this->bitDepths) || $this->encoding = '8bit'; - - foreach ($this->baseCharsets as $charset) - { - if (strpos($this->charset, $charset) === 0) - { - $this->encoding = '7bit'; - break; - } - } - - return $this->encoding; - } - - //-------------------------------------------------------------------- - - /** - * Get content type (text/html/attachment) - * - * @return string - */ - protected function getContentType() - { - if ($this->mailType === 'html') - { - return empty($this->attachments) ? 'html' : 'html-attach'; - } - elseif ($this->mailType === 'text' && ! empty($this->attachments)) - { - return 'plain-attach'; - } - else - { - return 'plain'; - } - } - - //-------------------------------------------------------------------- - /** * Set RFC 822 Date * @@ -1012,18 +952,6 @@ class Email //-------------------------------------------------------------------- - /** - * Mime message - * - * @return string - */ - protected function getMimeMessage() - { - return 'This is a multi-part message in MIME format.' . $this->newline . 'Your email application may not support this format.'; - } - - //-------------------------------------------------------------------- - /** * Validate Email Address(es) * @@ -1099,39 +1027,6 @@ class Email //-------------------------------------------------------------------- - /** - * Build alternative plain text message - * - * Provides the raw message for use in plain-text headers of - * HTML-formatted emails. - * If the user hasn't specified his own alternative message - * it creates one by stripping the HTML - * - * @return string - */ - protected function getAltMessage() - { - if (! empty($this->altMessage)) - { - return ($this->wordWrap) ? $this->wordWrap($this->altMessage, 76) : $this->altMessage; - } - - $body = preg_match('/\(.*)\<\/body\>/si', $this->body, $match) ? $match[1] : $this->body; - $body = str_replace("\t", '', preg_replace('#