diff --git a/.gitignore b/.gitignore index bafdbd3df9d4..ff62c093abed 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ writable/uploads/* !writable/uploads/index.html !writable/uploads/.htaccess +writable/email/* + #------------------------- # User Guide Temp Files #------------------------- @@ -85,9 +87,6 @@ composer.lock # IDE / Development Files #------------------------- -# Modules Testing -_modules/* - # phpenv local config .php-version diff --git a/application/Config/Mail.php b/application/Config/Mail.php new file mode 100644 index 000000000000..3ef11c9d3984 --- /dev/null +++ b/application/Config/Mail.php @@ -0,0 +1,67 @@ + 'CodeIgniter', + 'email' => 'codeigniter@example.com' + ]; + + //-------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------- + // Specifies the Handler used to send mail with if none are + // otherwise specified. Must be the full class name. + // + public $group = 'default'; + + //-------------------------------------------------------------------- + // Groups + //-------------------------------------------------------------------- + // Setup multiple configuration settings, for different servers, + // or when transactional emails are sent by a third-party service, + // but you want to send other messages locally. + // + public $groups = [ + 'default' => [ + 'handler' => 'simple', + 'protocol' => 'mail', + ], +// 'example' => [ +// 'handler' => 'simple', +// 'protocol' => 'smtp', +// 'SMTPHost' => '', +// 'SMTPUser' => '', +// 'SMTPPass' => '', +// 'SMTPPort' => 25, +// 'SMTPCrypto' => 'tls' +// ] + ]; + + //-------------------------------------------------------------------- + // User Agent + //-------------------------------------------------------------------- + // The "user agent", or the software used to send your message. + // + public $userAgent = 'CodeIgniter'; + + //-------------------------------------------------------------------- + // Available Handlers + //-------------------------------------------------------------------- + // The classes and their aliases that can be used to send mail with. + // This defaults to the built-in MailHandler which can send through + // mail(), sendmail() or smtp. + // + public $availableHandlers = [ + 'simple' => \CodeIgniter\Mail\Handlers\MailHandler::class, + 'logger' => \CodeIgniter\Mail\Handlers\LogHandler::class, + ]; +} diff --git a/application/Controllers/Checks.php b/application/Controllers/Checks.php index 5135805a8e9a..162bb163f3fe 100644 --- a/application/Controllers/Checks.php +++ b/application/Controllers/Checks.php @@ -44,68 +44,6 @@ public function password() die(var_dump($result)); } - - public function forms() - { - helper('form'); - - var_dump(form_open()); - } - - public function api() - { - $data = array( - "total_users" => 3, - "users" => array( - array( - "id" => 1, - "name" => "Nitya", - "address" => array( - "country" => "India", - "city" => "Kolkata", - "zip" => 700102, - ) - ), - array( - "id" => 2, - "name" => "John", - "address" => array( - "country" => "USA", - "city" => "Newyork", - "zip" => "NY1234", - ) - ), - array( - "id" => 3, - "name" => "Viktor", - "address" => array( - "country" => "Australia", - "city" => "Sydney", - "zip" => 123456, - ) - ), - ) - ); - - return $this->respond($data); - } - - public function db() - { - $db = Database::connect(); - $db->initialize(); - - $query = $db->prepare(function($db){ - return $db->table('user')->insert([ - 'name' => 'a', - 'email' => 'b@example.com', - 'country' => 'x' - ]); - }); - - $query->execute('foo', 'foo@example.com', 'US'); - } - public function format() { echo '
';
@@ -147,4 +85,79 @@ public function catch()
     }
 
 
+    public function forms()
+    {
+        helper('form');
+
+        var_dump(form_open());
+    }
+
+    public function api()
+    {
+        $data = array(
+            "total_users" => 3,
+            "users" => array(
+                array(
+                    "id" => 1,
+                    "name" => "Nitya",
+                    "address" => array(
+                        "country" => "India",
+                        "city" => "Kolkata",
+                        "zip" => 700102,
+                    )
+                ),
+                array(
+                    "id" => 2,
+                    "name" => "John",
+                    "address" => array(
+                        "country" => "USA",
+                        "city" => "Newyork",
+                        "zip" => "NY1234",
+                    )
+                ),
+                array(
+                    "id" => 3,
+                    "name" => "Viktor",
+                    "address" => array(
+                        "country" => "Australia",
+                        "city" => "Sydney",
+                        "zip" => 123456,
+                    )
+                ),
+            )
+        );
+
+        return $this->respond($data);
+    }
+
+    public function db()
+    {
+        $db = Database::connect();
+        $db->initialize();
+
+        $query = $db->prepare(function($db){
+            return $db->table('user')->insert([
+                'name' => 'a',
+                'email' => 'b@example.com',
+                'country' => 'x'
+            ]);
+        });
+
+        $query->execute('foo', 'foo@example.com', 'US');
+    }
+
+    public function mail()
+    {
+        $mail = email(new \App\Mail\TestMail())
+                    ->setTo('foo@example.com', 'foo')
+                    ->setSubject('Checking In')
+                    ->send();
+
+        if ($mail->hasErrors())
+        {
+            die($mail->getDebugger());
+        }
+        ddd($mail);
+    }
+
 }
diff --git a/application/Filters/temp.php b/application/Filters/temp.php
new file mode 100644
index 000000000000..74693f1be992
--- /dev/null
+++ b/application/Filters/temp.php
@@ -0,0 +1,24 @@
+isLoggedIn())
+        {
+            return redirect('login');
+        }
+    }
+
+    public function after(RequestInterface $request, ResponseInterface $response)
+    {
+
+    }
+
+}
diff --git a/application/Mail/.gitkeep b/application/Mail/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/application/Mail/TestMail.php b/application/Mail/TestMail.php
new file mode 100644
index 000000000000..d19a59396ee9
--- /dev/null
+++ b/application/Mail/TestMail.php
@@ -0,0 +1,15 @@
+setSubject("It's me, Margaret");
+
+        $this->setHTMLMessage("

Hello World

"); + $this->setTextMessage("Hello World"); + } + +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index e55cebd3ef5c..094c04aef972 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -156,7 +156,7 @@ public function initialize() // Setup Exception Handling Config\Services::exceptions($this->config, true) - ->initialize(); + ->initialize(); $this->detectEnvironment(); $this->bootstrapEnvironment(); @@ -250,11 +250,11 @@ protected function handleRequest(RouteCollectionInterface $routes = null, $cache $returned = $this->runController($controller); } - else - { - $this->benchmark->stop('controller_constructor'); - $this->benchmark->stop('controller'); - } + else + { + $this->benchmark->stop('controller_constructor'); + $this->benchmark->stop('controller'); + } // If $returned is a string, then the controller output something, // probably a view, instead of echoing it directly. Send it along diff --git a/system/Common.php b/system/Common.php index c6e7f8dbe911..001b5af3d689 100644 --- a/system/Common.php +++ b/system/Common.php @@ -144,6 +144,33 @@ function view_cell(string $library, $params = null, int $ttl = 0, string $cacheN //-------------------------------------------------------------------- +if (! function_exists('email')) +{ + /** + * A convenience method for sending emails. Takes a + * Mail Message class as the only parameter that defines + * the message itself. Further revisions can be made + * through + * + * @param \CodeIgniter\Mail\BaseMessage $message + * @param string $group + * + * @return mixed + */ + function email(\CodeIgniter\Mail\BaseMessage $message, string $group = null) + { + $config = new \Config\Mail(); + $group = $group ?? $config->group; + + $handler = Services::mailer($group, $config, false); + + $message = $message->setHandler($handler); + $message = $message->setDefaultFrom($config->from['email'], $config->from['name']); + + return $message; + } +} + if ( ! function_exists('env')) { /** @@ -845,3 +872,48 @@ function slash_item($item) } } //-------------------------------------------------------------------- + +if ( ! function_exists('function_usable')) +{ + /** + * Function usable + * + * Executes a function_exists() check, and if the Suhosin PHP + * extension is loaded - checks whether the function that is + * checked might be disabled in there as well. + * + * This is useful as function_exists() will return FALSE for + * functions disabled via the *disable_functions* php.ini + * setting, but not for *suhosin.executor.func.blacklist* and + * *suhosin.executor.disable_eval*. These settings will just + * terminate script execution if a disabled function is executed. + * + * The above described behavior turned out to be a bug in Suhosin, + * but even though a fix was committed for 0.9.34 on 2012-02-12, + * that version is yet to be released. This function will therefore + * be just temporary, but would probably be kept for a few years. + * + * @link http://www.hardened-php.net/suhosin/ + * @param string $functionName Function to check for + * @return bool TRUE if the function exists and is safe to call, + * FALSE otherwise. + */ + function function_usable($functionName) + { + static $suhosinFuncBlacklist; + + if (function_exists($functionName)) + { + if ( ! isset($suhosinFuncBlacklist)) + { + $suhosinFuncBlacklist = extension_loaded('suhosin') + ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) + : array(); + } + + return ! in_array($functionName, $suhosinFuncBlacklist, true); + } + + return false; + } +} diff --git a/system/Config/Mail.php b/system/Config/Mail.php new file mode 100644 index 000000000000..fb1873390dee --- /dev/null +++ b/system/Config/Mail.php @@ -0,0 +1,28 @@ +groups[$group]) || ! is_array($config->groups[$group])) + { + throw new \InvalidArgumentException(sprintf(lang('mail.invalidGroup'), $group)); + } + + // Ensure we have a valid Handler class + $handler = $config->groups[$group]['handler'] ?? null; + if (empty($handler)) + { + throw new \BadMethodCallException(sprintf(lang('mail.invalidHandlerName'), $handler)); + } + + $handler = $config->availableHandlers[$handler]; + + // Make sure we pass the group config settings into the handler here. + $handler = new $handler($config->groups[$group]); + + return $handler; + } + +} diff --git a/system/Config/Services.php b/system/Config/Services.php index 43024dd79f07..569cbfab108c 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -275,6 +275,23 @@ public static function logger($getShared = true) //-------------------------------------------------------------------- + public static function mailer(string $handler=null, $config = null, $getShared = true) + { + if ($getShared) + { + return self::getSharedInstance('mailer', $handler, $config); + } + + if (empty($config)) + { + $config = new \Config\Mail(); + } + + return $config->factory($handler, $config); + } + + //-------------------------------------------------------------------- + public static function migrations(BaseConfig $config = null, ConnectionInterface $db = null, bool $getShared = true) { if ($getShared) diff --git a/system/Language/en/Mail.php b/system/Language/en/Mail.php new file mode 100644 index 000000000000..ef3d005d0fe0 --- /dev/null +++ b/system/Language/en/Mail.php @@ -0,0 +1,62 @@ + '%s is not a valid Mail group.', + 'invalidHandlerName' => '%s is not a valid Mail Handler.', + 'invalidHandler' => 'Unable to send message. No valid Handler provided.', + 'emptyMessage' => 'Mail messages must have a body, from, and to addresses specified.', + 'errorWritingFile' => 'Unable to write email message to disk: %s', + 'mustBeArray' => 'The email validation method must be passed an array.', + 'invalidEmail' => 'Invalid email address: %s', + 'noFrom' => 'Cannot send mail with no "From" header.', + 'noRecipients' => 'You must include recipients: To, Cc, or Bcc', + 'sendFailurePhpmail' => 'Unable to send email using PHP mail(). Your server might not be configured to send mail using this method', + 'sendFailureSendmail' => 'Unable to send email using PHP Sendmail. Your server might not be configured to send mail using this method.', + 'sendFailureSmtp' => 'Unable to send email using PHP SMTP. Your server might not be configured to send mail using this method.', + 'sent' => 'Your message has been successfully sent using the following protocol: ', + 'smtpError' => 'The following SMTP error was encountered: ', + 'noSMTPUnpw' => 'Error: You must assign a SMTP username and password.', + 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: ', + 'smtpAuthUn' => 'Failed to authenticate username. Error: ', + 'smtpAuthPw' => 'Failed to authenticate password. Error: ', + 'smtpDataFailure' => 'Unable to send data: ', + 'exitStatus' => 'Exit status code: ', + 'attachmentMissing' => 'Unable to locate the following email attachment: %s', + 'attachmentUnreadable' => 'Unable to open this attachment: %s', +]; diff --git a/system/Mail/BaseHandler.php b/system/Mail/BaseHandler.php new file mode 100644 index 000000000000..b8ca74d3381c --- /dev/null +++ b/system/Mail/BaseHandler.php @@ -0,0 +1,590 @@ + '1 (Highest)', + 2 => '2 (High)', + 3 => '3 (Normal)', + 4 => '4 (Low)', + 5 => '5 (Lowest)' + ); + + //-------------------------------------------------------------------- + + public function __construct(array $config=[]) + { + $this->reset(); + + foreach ($config as $key => $value) + { + if (isset($this->$key)) + { + $this->$key = $value; + } + } + + $this->charset = strtoupper($this->charset); + $this->config = $config; + } + + /** + * Sets the Mail Message class that represents the message details. + * + * @param \CodeIgniter\Mail\BaseMessage $message + * + * @return mixed + */ + public function setMessage(BaseMessage $message) + { + $this->message = $message; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns the current message instance. + * + * @return \CodeIgniter\Mail\MessageInterface + */ + public function getMessage() + { + return $this->message; + } + + //-------------------------------------------------------------------- + + /** + * Does the actual delivery of a message. + * + * @param \CodeIgniter\Mail\MessageInterface $message + * @param bool $clear_after If TRUE, will reset the class after sending. + * + * @return mixed + */ + public abstract function send(MessageInterface $message, bool $clear_after = true); + + //-------------------------------------------------------------------- + + /** + * Sets a header value for the email. Not every service will provide this. + * + * @param $field + * @param $value + * + * @return mixed + */ + public function setHeader(string $field, $value) + { + $this->headers[$field] = $value; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns all headers that have been set. + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + //-------------------------------------------------------------------- + + //-------------------------------------------------------------------- + // Utility Methods + //-------------------------------------------------------------------- + + /** + * Returns whether the message encountered any errors or not. + * + * @return bool + */ + public function hasErrors(): bool + { + return ! empty($this->debugMsg); + } + + + /** + * 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 getDebugger($include = ['headers', 'subject', 'body']) + { + $msg = ''; + + if (count($this->debugMsg) > 0) + { + foreach ($this->debugMsg as $val) + { + $msg .= $val; + } + } + + // Determine which parts of our raw data needs to be printed + $raw_data = ''; + is_array($include) OR $include = array($include); + + if (in_array('headers', $include, TRUE)) + { + $raw_data = htmlspecialchars(implode("\n", $this->getHeaders()))."\n"; + } + + if (in_array('subject', $include, TRUE)) + { + $raw_data .= htmlspecialchars($this->message->getSubject())."\n"; + } + + if (in_array('body', $include, TRUE)) + { + // @todo build a final message string! + $raw_data .= htmlspecialchars($this->message->getHTMLMessage()); + } + + return $msg.($raw_data === '' ? '' : '
'.$raw_data.'
'); + } + + /** + * Prep Quoted Printable + * + * Prepares string for Quoted-Printable Content-Transfer-Encoding + * Refer to RFC 2045 http://www.ietf.org/rfc/rfc2045.txt + * + * @param string + * @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 = array( + // ' ( ) + , - . / : = ? + 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(array('{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(array('| +|', '/\x00+/'), array(' ', ''), $str); + + // Standardize newlines + if (strpos($str, "\r") !== FALSE) + { + $str = str_replace(array("\r\n", "\r"), "\n", $str); + } + + $escape = '='; + $output = ''; + + foreach (explode("\n", $str) as $line) + { + $length = mb_strlen($line, '8bit'); + $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 OR $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 ((mb_strlen($temp, '8bit') + mb_strlen($char, '8bit')) >= 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 self::substr($output, 0, mb_strlen($this->crlf, '8bit') * -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 + * + * @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 self::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) OR $chars = self::strlen($str); + + $output = '=?'.$this->charset.'?Q?'; + for ($i = 0, $length = self::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 = self::strlen($chr)) > 74) + { + $output .= '?='.$this->crlf // EOL + .' =?'.$this->charset.'?Q?'.$chr; // New line + $length = 6+self::strlen($this->charset)+$l; // Reset the length for the new line + } else + { + $output .= $chr; + $length += $l; + } + } + + // End the header + return $output.'?='; + } + + //-------------------------------------------------------------------- + + /** + * Byte-safe substr() + * + * @param string $str + * @param int $start + * @param int $length + * + * @return string + */ + protected static function substr($str, $start, $length = null) + { + return mb_substr($str, $start, $length, '8bit'); + } + + //-------------------------------------------------------------------- + + /** + * Stores an error message for later debug info, with optional + * string replacement. + * + * @param string $message + * @param string $val + */ + protected function setErrorMessage(string $message, string $val = '') + { + $this->debugMsg[] = str_replace('%s', $val, $message).'
'; + } + + //-------------------------------------------------------------------- + + /** + * Resets the state to blank, ready for a new email. Useful when + * sending emails in a loop and you need to make sure that the + * email is reset. + * + * @param bool $clear_attachments + * + * @return mixed + */ + public function reset(bool $clear_attachments = true) + { + $this->to = null; + $this->from = null; + $this->reply_to = null; + $this->cc = null; + $this->bcc = null; + $this->subject = null; + $this->html_message = null; + $this->text_message = null; + $this->headers = []; + + return $this; + } + + //-------------------------------------------------------------------- +} diff --git a/system/Mail/BaseMessage.php b/system/Mail/BaseMessage.php new file mode 100644 index 000000000000..29523a72c352 --- /dev/null +++ b/system/Mail/BaseMessage.php @@ -0,0 +1,753 @@ + 'John Doe ', + * 'from' => 'Jane Doe ' + * ]); + * + * @param array|null $options + */ + public function __construct(array $options = null) + { + if (is_array($options)) + { + $this->setOptions($options); + } + } + + //-------------------------------------------------------------------- + + /** + * Takes an array of options whose keys should match + * any of the class properties. The property values will be set. + * Any unrecognized elements will be stored in the $data array. + * + * @param array $options + */ + public function setOptions(array $options) + { + foreach ($options as $key => $value) + { + if (property_exists($this, $key)) + { + if (is_array($this->$key)) + { + $value = is_array($value) ? $value : [$value]; + } + + $this->$key = $value; + + continue; + } + + $this->data[$key] = $value; + } + } + + //-------------------------------------------------------------------- + + /** + * Sets the active Handler instance that will be used to send. + * + * @param \CodeIgniter\Mail\MailHandlerInterface $handler + * + * @return $this + */ + public function setHandler(MailHandlerInterface $handler) + { + $this->handler = $handler; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Called by the mailers prior to sending the message. Gives the message + * a chance to do any custom setup, like loading views for the content, etc. + * + * @return mixed + */ + abstract public function build(); + + //-------------------------------------------------------------------- + + /** + * Works with the handler to actually send the message. + * + * @return bool + */ + public function send() + { + if (! $this->handler instanceof MailHandlerInterface) + { + throw new \BadMethodCallException(lang('mail.invalidHandler')); + } + + // run the build step so it can parse any view templates, etc. + // and, generally, get the message ready to go. + $this->build(); + + // Ensure we have enough data to actually write a message for. + if (! $this->isValid()) + { + throw new \RuntimeException(lang('mail.emptyMessage')); + } + + // Ensure we have a 'from' address or use the defaults from the config file. + if (empty($this->from)) + { + $this->from = $this->defaultFrom; + } + + return $this->handler->send($this); + } + + //-------------------------------------------------------------------- + + /** + * Returns the name / address of the person/people the email is from. + * Return array MUST be formatted as name => email. + * + * @return array + */ + public function getFrom(): array + { + return $this->from; + } + + //-------------------------------------------------------------------- + + /** + * Returns the 'ReturnPath' portion, which is automatically set + * when setting the "from" value. + * + * @return string + */ + public function getReturnPath(): string + { + return ! empty($this->returnPath) + ? $this->returnPath + : ''; + } + + + /** + * Sets the name and email address of one person this is from. + * If this method is called multiple times, it adds multiple people + * to the from value, it does not overwrite the previous one. + * + * @param string $email + * @param string|null $name + * + * @param string $returnPath + * + * @return \CodeIgniter\Mail\BaseMessage + */ + public function setFrom(string $email, string $name = null, string $returnPath = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'from'); + + if (empty($returnPath)) + { + $returnPath = $email; + } + + $this->returnPath = '<'.$returnPath.'>'; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Allows multiple From name/email pairs to be set at once. + * Arrays MUST be in name => email format. + * + * @param array $emails + * + * @return self + */ + public function setFromMany(array $emails) + { + $this->setRecipients($emails, 'from'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Sets the default from value that will be used if nothing else is provided. + * This typically comes from Config\Mail. + * + * @param string $email + * @param string|null $name + * + * @return $this + */ + public function setDefaultFrom(string $email, string $name = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'defaultFrom'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns an array of all people the message is to. The array MUST + * be formatted in name => email format. + * + * @return array + */ + public function getTo(): array + { + return $this->to; + } + + //-------------------------------------------------------------------- + + /** + * Adds a single person to the list of recipients. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setTo(string $email, string $name = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'to'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Allows multiple recipients to be added at once. The array MUST + * be formatted in name => email pairs. + * + * @param array $emails + * + * @return mixed + */ + public function setToMany(array $emails) + { + $this->setRecipients($emails, 'to'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns all recipients listed in the Reply-To field. + * + * @return array + */ + public function getReplyTo(): array + { + return $this->replyTo; + } + + //-------------------------------------------------------------------- + + /** + * Adds a new recipient to the Reply-To header for this message. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setReplyTo(string $email, string $name = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'replyTo'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Sets multiple Reply-To addresses at once. The array MUST be + * formatted as name => email pairs. + * + * @param array $emails + * + * @return self + */ + public function setReplyToMany(array $emails) + { + $this->setRecipients($emails, 'replyTo'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Gets a list of all names and addresses that should be CC'd + * on this message. + * + * @return array + */ + public function getCC(): array + { + return $this->cc; + } + + //-------------------------------------------------------------------- + + /** + * Sets a single email/name to CC the message to. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setCC(string $email, string $name = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'cc'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Sets multiple CC address/name pairs at once. The array MUST + * be in name => email format: + * + * [ + * 'John Doe' => 'john.doe@example.com' + * ] + * + * @param array $emails + * + * @return self + */ + public function setCCMany(array $emails) + { + $this->setRecipients($emails, 'cc'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns an array of all people that should be BCC'd on this message. + * The array is in name => email format: + * + * $bccs = [ + * 'John Doe' => 'john.doe@example.com' + * ]; + * + * @return array + */ + public function getBCC(): array + { + return $this->bcc; + } + + //-------------------------------------------------------------------- + + /** + * Adds another email address/name to the list of people to BCC. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setBCC(string $email, string $name = null) + { + $recipient = is_null($name) + ? [$email] + : [$name => $email]; + + $this->setRecipients($recipient, 'bcc'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Takes an array email address/names that should be set as + * the BCC addresses. + * + * @param array $emails + * + * @return self + */ + public function setBCCMany(array $emails) + { + $this->setRecipients($emails, 'bcc'); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Retrieves the subject line of the message. + * + * @return string + */ + public function getSubject(): string + { + return $this->subject ?? ''; + } + + //-------------------------------------------------------------------- + + /** + * Sets the subject line of the message. + * + * @param string $subject + * + * @return self + */ + public function setSubject(string $subject) + { + $this->subject = $subject; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Retrieves the contents of the HTML portion of the message. + * + * @return string + */ + public function getHTMLMessage(): string + { + return $this->messageHTML ?? ''; + } + + //-------------------------------------------------------------------- + + /** + * Sets the contents of the HTML portion of the email message. + * + * @param string $html + * + * @return self + */ + public function setHTMLMessage(string $html) + { + $this->messageHTML = $html; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Gets the current contents of the Text message. + * + * @return string + */ + public function getTextMessage(): string + { + return $this->messageText ?? ''; + } + + //-------------------------------------------------------------------- + + /** + * Sets the Text content of the email. + * + * @param string $text + * + * @return self + */ + public function setTextMessage(string $text) + { + $this->messageText = strip_tags($text); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Simple determination about the message type based on which + * elements actually have content. + * + * @return string + */ + public function messageType() + { + $type = ! empty($this->messageHTML) + ? 'html' + : 'plain'; + + if (count($this->attachments) > 0) + { + $type .= '-attach'; + } + + return $type; + } + + /** + * Gets any viewdata that has been set. + * + * @return array + */ + public function getData(): array + { + return $this->data; + } + + //-------------------------------------------------------------------- + + /** + * Sets an array of key/value pairs that are used like dynamic view + * data to replace placeholders in the HTML and Text Messages when + * they are parsed. + * + * @param array $data + * + * @return self + */ + public function setData(array $data) + { + $this->data = array_merge($this->data, $data); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Determines if this message has the bare minimum information needed + * to send a message, i.e. to, from, subject and some message. + * + * @return bool + */ + public function isValid(): bool + { + return empty($this->to) + || empty($this->from) + || empty($this->subject) + || (empty($this->messageHTML) && empty($this->messageText)); + } + + //-------------------------------------------------------------------- + + /** + * Assign file attachments + * + * @param string $file Can be local path, URL or buffered content + * @param string $disposition = 'attachment' + * @param string $newname = NULL + * @param string $mime = '' + * + * @return $this + */ + public function attach($file, string $disposition = '', string $newname = null, string $mime = '') + { + if ($mime === '') + { + if (strpos($file, '://') === false && ! file_exists($file)) + { + $this->handler->setErrorMessage(lang('email.attachmentMissing'), $file); + + return $this; + } + + if (! $fp = @fopen($file, 'rb')) + { + $this->handler->setErrorMessage(lang('email.attachmentUnreadable', $file)); + + return $this; + } + + $fileContent = stream_get_contents($fp); + $mime = $this->determineMimeType(pathinfo($file, PATHINFO_EXTENSION)); + fclose($fp); + } + else + { + $fileContent =& $file; // buffered file + } + + $this->attachments[] = [ + 'name' => [$file, $newname], + 'disposition' => empty($disposition) ? 'attachment' : $disposition, + // Can also be 'inline' Not sure if it matters + 'type' => $mime, + 'content' => chunk_split(base64_encode($fileContent)), + 'multipart' => 'mixed', + ]; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Set and return attachment Content-ID + * + * Useful for attached inline pictures + * + * @param string $filename + * @return string + */ + public function attachmentCID($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]).'@'); + return $this->attachments[$i]['cid']; + } + } + + return FALSE; + } + + //-------------------------------------------------------------------- + + /** + * Determine the mime type + * + * @param string $ext + * + * @return mixed|string + */ + protected function determineMimeType(string $ext) + { + return Mimes::guessTypeFromExtension($ext) ?? 'application/x-unknown-content-type'; + } + + //-------------------------------------------------------------------- + + /** + * Used by all of the setCC, setTo, etc methods that deal with email + * addresses to validate and store the actual values. If any email + * addresses are found that are not valid emails, will throw an exception + * with a list of the ones that are not. + * + * @param array $recipients + * @param string $type + * + * @throws \CodeIgniter\Mail\InvalidEmailAddress + */ + protected function setRecipients(array $recipients, string $type) + { + $invalids = []; + + foreach ($recipients as $name => $email) + { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) + { + $invalids[] = $email; + continue; + } + + if (is_string($name) && ! empty($name)) + { + $this->$type[$name] = $email; + continue; + } + + $this->$type[] = $email; + } + + if (count($invalids)) + { + throw new InvalidEmailAddress('The following email addresses are invalid: '.implode(', ', $invalids)); + } + } + + //-------------------------------------------------------------------- +} diff --git a/system/Mail/Handlers/LogHandler.php b/system/Mail/Handlers/LogHandler.php new file mode 100644 index 000000000000..0137e141cd29 --- /dev/null +++ b/system/Mail/Handlers/LogHandler.php @@ -0,0 +1,181 @@ +logPath = $this->config['logPath'] ?? WRITEPATH; + } + + /** + * Does the actual delivery of a message. In this case, though, we simply + * write the html and text files out to the log folder/emails. + * + * The filename format is: yyyymmddhhiiss_email.{format} + * + * @param \CodeIgniter\Mail\MessageInterface $message + * @param bool $clear_after If TRUE, will reset the class after sending. + * + * @return mixed + */ + public function send(MessageInterface $message, bool $clear_after=true) + { + $this->message = $message; + + // If there is more than one email address listed in $to, + // only use the first one. + $email = $message->getTo(); + + if (is_array($email)) + { + $email = array_shift($email); + } + + // Clean up the to address so we can use it as the filename + $symbols = ['#', '%', '&', '{', '}', '\\', '/', '<', '>', '*', '?', ' ', '$', '!', '\'', '"', ':', '@', '+', '`', '=']; + $email = str_replace($symbols, '.', strtolower($email) ); + + $filename = date('YmdHis_'). $email; + + // Ensure the emails folder exists in the log folder. + $path = $this->logPath; + $path = rtrim($path, '/ ') .'/email/'; + + if (! is_dir($path)) + { + mkdir($path, 0777, true); + } + + helper('filesystem'); + + $this->writeHTMLFile($message->getHTMLMessage(), $path, $filename); + $this->writeTextFile($message->getTextMessage(), $path, $filename); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Generates the file that represents the HTML version of the email + * with headers at top. + * + * @param string $html + * @param string $path + * @param string $filename + */ + protected function writeHTMLFile(string $html, string $path, string $filename) + { + $headers = $this->describeHeaders("
", true); + + if (strpos($html, 'writeFile($html, $path, $filename.'.html'); + } + + //-------------------------------------------------------------------- + + /** + * Generates the file that represents that Text version of the email + * with headers at top. + * + * @param string $text + * @param string $path + * @param string $filename + */ + protected function writeTextFile(string $text, string $path, string $filename) + { + $headers = $this->describeHeaders("\n"); + + $this->writeFile($headers . $text, $path, $filename.'.text'); + } + + //-------------------------------------------------------------------- + + /** + * Describes the basic headers (to, from, cc, bcc, replyTo) of the message. + * + * @param string $linebreak + * @param bool $escape + * + * @return string + */ + protected function describeHeaders(string $linebreak="\n", bool $escape = false) + { + $headers = []; + + $fields = ['From', 'To', 'CC', 'BCC', 'ReplyTo']; + + foreach ($fields as $field) + { + $rows = []; + + if (empty($field)) continue; + + $method = "get{$field}"; + + $header = $this->message->$method(); + + if (! empty($header)) + { + foreach ($header as $name => $email) + { + $rows[] = empty($name) + ? $email + : $escape === true + ? htmlspecialchars("{$name} <{$email}>") + : "{$name} <{$email}>"; + } + } + + $rows = implode(', ', $rows); + + $headers[] = "{$field}: {$rows} {$linebreak}"; + } + + $headers[] = ''; + $headers[] = '--------------------------------------------------------------------'. $linebreak; + $headers[] = ''; + + return implode($linebreak, $headers); + } + + //-------------------------------------------------------------------- + + /** + * Handles writing out the file. + * + * @param string $body + * @param string $path + * @param string $filename + */ + protected function writeFile(string $body, string $path, string $filename) + { + if (! empty($body) && ! write_file( $path . $filename, $body ) ) + { + throw new \RuntimeException( sprintf( lang('mail.errorWritingFile'), $path, $filename) ); + } + } + + //-------------------------------------------------------------------- + +} diff --git a/system/Mail/Handlers/MailHandler.php b/system/Mail/Handlers/MailHandler.php new file mode 100644 index 000000000000..3b8aee4b8bad --- /dev/null +++ b/system/Mail/Handlers/MailHandler.php @@ -0,0 +1,1593 @@ +SMTPAuth = isset($this->SMTPUser[0], $this->SMTPPass[0]); + $this->protocol = strtolower($this->protocol); + } + + /** + * Does the actual delivery of a message. + * + * @param \CodeIgniter\Mail\MessageInterface $message + * @param bool $clear_after If TRUE, will reset the class after sending. + * + * @return mixed + */ + public function send(MessageInterface $message, bool $clear_after = true) + { + $this->setMessage($message); + + // First, get and format all of our emails (from, to, cc, etc) + $this->initialize(); + + if (! isset($this->headers['From'])) + { + $this->setErrorMessage(lang('mail.noFrom')); + + return $this; + } + + if ($this->ReplyToFlag === false) + { + $this->setReplyTo($this->headers['From']); + } + + if (empty($this->recipients) && ! isset($this->headers['To']) + && empty($this->BCC) + && ! isset($this->headers['Bcc']) + && ! isset($this->headers['Cc']) + ) + { + $this->setErrorMessage(lang('mail.noRecipients')); + + return $this; + } + + $this->buildHeaders(); + + if ($this->buildMessage() === false) + { + return $this; + } + + $result = $this->spoolEmail(); + + if ($result && $clear_after) + { + $this->reset(); + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Reads in our emails and other data from the message and converting + * into the format we need it in. + */ + protected function initialize() + { + // Set the appropriate headers with formatted versions + // of all of our recipients and senders. + foreach (['From', 'To', 'ReplyTo', 'CC', 'BCC'] as $group) + { + $emails = $this->message->{'get'.$group}(); + + if (empty($emails)) + { + continue; + } + + if (method_exists($this, 'set'.$group)) + { + $this->{'set'.$group}($emails); + } + } + + $this->setSubject($this->message->getSubject()); + } + + //-------------------------------------------------------------------- + + /** + * Sets and formats the email(s) the message should be sent to. + * + * @param array $emails + */ + protected function setTo(array $emails) + { + if ($this->validate) + { + $this->validateEmail($emails); + } + + $emails = $this->formatEmails($emails); + + if ($this->protocol != 'mail') + { + $this->setHeader('To', implode(', ', $emails)); + } + + $this->recipients = $emails; + } + + //-------------------------------------------------------------------- + + /** + * Sets and formats the email(s) the message is being sent from. + * + * @param array $emails + */ + protected function setFrom(array $emails) + { + if ($this->validate) + { + $this->validateEmail($emails); + } + + $emails = $this->cleanNames($emails); + $emails = $this->formatEmails($emails); + + $this->setHeader('From', implode(', ', $emails)); + } + + //-------------------------------------------------------------------- + + /** + * Sets and formats the email(s) the message should be replied to. + * + * @param array|string $emails + */ + protected function setReplyTo($emails) + { + if (is_string($emails)) + { + $emails = [$emails]; + } + + if ($this->validate) + { + $this->validateEmail($emails); + } + + $emails = $this->cleanNames($emails); + $emails = $this->formatEmails($emails); + + $this->setHeader('Reply-To', implode(', ', $emails)); + + $this->ReplyToFlag = true; + } + + //-------------------------------------------------------------------- + + /** + * Sets and formats the email(s) the message should be CC'd to. + * + * @param array $emails + */ + protected function setCC(array $emails) + { + if ($this->validate) + { + $this->validateEmail($emails); + } + + $emails = $this->cleanNames($emails); + $emails = $this->formatEmails($emails); + + $this->setHeader('Cc', implode(', ', $emails)); + + if ($this->protocol == 'smtp') + { + $this->CC = $emails; + } + } + + //-------------------------------------------------------------------- + + /** + * Sets and formats the email(s) the message should be BCC'd to. + * + * @param array $emails + */ + protected function setBCC(array $emails) + { + if ($this->validate) + { + $this->validateEmail($emails); + } + + $emails = $this->cleanNames($emails); + $emails = $this->formatEmails($emails); + + $this->setHeader('Bcc', implode(', ', $emails)); + + if ($this->protocol == 'smtp') + { + $this->BCC = $emails; + } + } + + //-------------------------------------------------------------------- + + /** + * Sets the email subject header. + * + * @param string $subject + * + * @return $this + */ + protected function setSubject(string $subject) + { + $subject = $this->prepQEncoding($subject); + $this->setHeader('Subject', $subject); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Validates an email address. + * + * @param array $emails + * + * @internal param string $email + */ + protected function validateEmail(array $emails) + { + if (! is_array($emails)) + { + $this->setErrorMessage(lang('mail.mustBeArray')); + } + + foreach ($emails as $email) + { + if (is_array($email)) + { + $email = array_shift($email); + } + + // If the email isn't valid, then log it so + // we can show the user in debug info. + if (! $this->isValidEmail($email)) + { + $this->setErrorMessage(lang('mail.invalidEmail'), $email); + } + } + } + + //-------------------------------------------------------------------- + + /** + * 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/bcit-ci/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 bool + */ + protected function validateEmailForShell(&$email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = mb_substr($email, 0, ++$atpos).idn_to_ascii(mb_substr($email, $atpos)); + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) === $email + && preg_match('#\A[a-z0-9._+-]+@[a-z0-9.-]{1,253}\z#i', $email)); + } + + //-------------------------------------------------------------------- + + /** + * Validates an email address. + * + * @param string $email + * + * @return bool + */ + protected function isValidEmail(string $email) + { + if (function_exists('idn_to_ascii') && $atpos = strpos($email, '@')) + { + $email = self::substr($email, 0, ++$atpos).idn_to_ascii(self::substr($email, $atpos)); + } + + return (bool)filter_var($email, FILTER_VALIDATE_EMAIL); + } + + //-------------------------------------------------------------------- + + /** + * Takes an array of 'name' => 'email' pairs and + * formats them into a proper email string: + * + * 'name ' + * + * @param array $emails + * + * @return array + */ + protected function formatEmails(array $emails) + { + $formatted = []; + + foreach ($emails as $name => $email) + { + $formatted[] = trim("$name <{$email}>"); + } + + return $formatted; + } + + //-------------------------------------------------------------------- + + /** + * Cleans the Names associated with email addresses to prepare them for display + * and sanitize them a bit, and standardize for mail delivery. + * + * @param array $emails + * + * @return array + */ + protected function cleanNames(array $emails) + { + $cleaned = []; + + foreach ($emails as $name => $email) + { + if ($name !== '') + { + // only use Q encoding if there are characters that would require it + if (! preg_match('/[\200-\377]/', $name)) + { + // add slashes for non-printing characters, slashes, and double quotes, and surround it in double quotes + $name = '"'.addcslashes($name, "\0..\37\177'\"\\").'"'; + } + else + { + $name = $this->prepQEncoding($name); + } + + $cleaned[$name] = $email; + } + else + { + $cleaned[] = $email; + } + } + + return $cleaned; + } + + //-------------------------------------------------------------------- + + /** + * Clean Extended Email address: Joe Smith + * + * @param string $email + * + * @return string + */ + protected function cleanEmail(string $email): string + { + if (! is_array($email)) + { + return preg_match('/\<(.*)\>/', $email, $match) ? $match[1] : $email; + } + + $cleanEmail = []; + + foreach ($email as $addy) + { + $cleanEmail[] = preg_match('/\<(.*)\>/', $addy, $match) ? $match[1] : $addy; + } + + return $cleanEmail; + } + + //-------------------------------------------------------------------- + /** + * Sets a header value for the email. Not every service will provide this. + * + * @param string $header + * @param $value + * + * @return mixed + * @internal param $field + */ + public function setHeader(string $header, $value) + { + $this->headers[$header] = str_replace(["\n", "\r"], '', $value); + } + + //-------------------------------------------------------------------- + + /** + * Build the final headers. + */ + protected function buildHeaders() + { + $this->setHeader('User-Agent', $this->useragent); + $this->setHeader('X-Sender', $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'); + } + + //-------------------------------------------------------------------- + + /** + * Get the Message ID + * + * @return string + */ + protected function getMessageID() + { + $from = $this->headers['Return-Path'] ?? $this->headers['From']; + + $from = str_replace(['>', '<'], '', $from); + + return '<'.uniqid('').strstr($from, '@').'>'; + } + + //-------------------------------------------------------------------- + + protected function buildMessage() + { + if ($this->wordwrap === true) + { + $this->message->setTextMessage($this->wordWrap($this->message->getTextMessage())); + } + + $this->writeHeaders(); + + $header = ($this->protocol === 'mail') + ? $this->newline + : ''; + + $body = ''; + + switch ($this->message->messageType()) + { + case 'plain': + $body = $this->buildPlainMessage($header); + break; + case 'html': + $body = $this->buildHTMLMessage($header); + break; + case 'plain-attach': + $body = $this->buildPlainAttachMessage($header); + break; + case 'html-attach': + $body = $this->buildHTMLAttachMessage($header); + break; + } + + $this->finalBody = $this->protocol === 'mail' + ? $body + : $header.$this->newline.$this->newline.$body; + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Build the final body for a Plain (text-only) message. + * + * @param string $header + * + * @return string + */ + protected function buildPlainMessage(string &$header): string + { + $header .= 'Content-Type: text/plain; charset='.$this->charset.$this->newline + .'Content-Transfer-Encoding: '.$this->getEncoding(true); + + if ($this->protocol === 'mail') + { + $this->headerString .= $header; + + return $this->message->getTextMessage(); + } + + return $header.$this->newline.$this->newline.$this->message->getTextMessage(); + } + + //-------------------------------------------------------------------- + + /** + * Build the final body for an HTML message. + * + * @param string $header + * + * @return string + */ + protected function buildHTMLMessage(string &$header): string + { + $body = ''; + + if ($this->sendMultipart === false) + { + $header .= 'Content-Type: text/html; charset='.$this->charset.$this->newline + .'Content-Transfer-Encoding: quoted-printable'; + } + else + { + $boundary = uniqid('B_ALT_'); + $header = '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(true).$this->newline.$this->newline + .$this->message->getTextMessage().$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; + } + + $body = $body.$this->prepQuotedPrintable($this->message->getHTMLMessage()).$this->newline.$this->newline; + + if ($this->protocol === 'mail') + { + $this->headerString .= $header; + } + else + { + $body = $header.$this->newline.$this->newline.$body; + } + + if ($this->sendMultipart !== false) + { + $body .= '--'.$boundary.'--'; + } + + return $body; + } + + //-------------------------------------------------------------------- + + /** + * Build the final body for a plain email with attachments. + * + * @param string $header + * + * @return string + */ + protected function buildPlainAttachMessage(string &$header): string + { + $boundary = uniqid('B_ATAC_'); + $header .= 'Content-Type: multipart/mixed; boundary="'.$boundary.'"'; + + if ($this->protocol === 'mail') + { + $this->headerString .= $header; + } + + $body = $this->getMimeMessage().$this->newline + .$this->newline + .'--'.$boundary.$this->newline + .'Content-Type: text/plain; charset='.$this->charset.$this->newline + .'Content-Transfer-Encoding: '.$this->getEncoding(true).$this->newline + .$this->newline + .$this->message->getTextMessage().$this->newline.$this->newline; + + return $this->appendAttachments($body, $boundary); + } + + //-------------------------------------------------------------------- + + /** + * Build the final body for an HTML message with attachments. + * + * @param string $header + * + * @return string + */ + protected function buildHTMLAttachMessage(string &$header): string + { + $altBoundary = uniqid('B_ALT_'); + $lastBoundary = null; + + $body = ''; + + if ($this->attachmentsHaveMultipart('mixed')) + { + $atcBoundary = uniqid('B_ATC_'); + $header .= 'Content-Type: multipart/mixed; boundary="'.$atcBoundary.'"'; + $lastBoundary = $atcBoundary; + } + + if ($this->attachmentsHaveMultipart('related')) + { + $relBoundary = uniqid('B_REL_'); + $relBoundaryHeader = 'Content-Type: multipart/related; boundary="'.$relBoundary.'"'; + + if (isset($lastBoundary)) + { + $body .= '--'.$lastBoundary.$this->newline.$relBoundaryHeader; + } + else + { + $header .= $relBoundaryHeader; + } + + $lastBoundary = $relBoundary; + } + + if ($this->protocol === 'mail') + { + $this->headerString .= $header; + } + + if (mb_strlen($body)) + { + $body .= $this->newline.$this->newline; + } + + $body .= $this->getMimeMessage().$this->newline.$this->newline + .'--'.$lastBoundary.$this->newline + + .'Content-Type: multipart/alternative; boundary="'.$altBoundary.'"'.$this->newline.$this->newline + .'--'.$altBoundary.$this->newline + + .'Content-Type: text/plain; charset='.$this->charset.$this->newline + .'Content-Transfer-Encoding: '.$this->getEncoding(true).$this->newline.$this->newline + .$this->message->getTextMessage().$this->newline.$this->newline + .'--'.$altBoundary.$this->newline + + .'Content-Type: text/html; charset='.$this->charset.$this->newline + .'Content-Transfer-Encoding: quoted-printable'.$this->newline.$this->newline + + .$this->prepQuotedPrintable($this->message->getHTMLMessage()).$this->newline.$this->newline + .'--'.$altBoundary.'--'.$this->newline.$this->newline; + + if (! empty($relBoundary)) + { + $body .= $this->newline.$this->newline; + $body = $this->appendAttachments($body, $relBoundary, 'related'); + } + + // multipart/mixed attachments + if (! empty($atcBoundary)) + { + $body .= $this->newline.$this->newline; + $body = $this->appendAttachments($body, $atcBoundary, 'mixed'); + } + + return $body; + } + + //-------------------------------------------------------------------- + + /** + * Checks whether we have any attachments of the specified type. + * + * @param string $type + * + * @return bool + */ + protected function attachmentsHaveMultipart(string $type): bool + { + 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(string $body, string $boundary, string $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 + if (! empty($name)) + { + $body .= '--'.$boundary.'--'; + } + + return $body; + } + + /** + * Get the mail encoding. + * + * @param bool $return + * + * @return string + */ + protected function getEncoding($return = true) + { + if (! in_array($this->encoding, $this->bitDepths)) + { + $this->encoding = '8bit'; + } + + foreach ($this->baseCharsets as $charset) + { + if (strpos($charset, $this->charset) === 0) + { + $this->encoding = '7bit'; + } + } + + if ($return === true) + { + return $this->encoding; + } + } + + /** + * Handle word-wrapping. + * + * @param string $str + * @param null $charLimit + * + * @return string + */ + protected function wordWrap(string $str, $charLimit = null): string + { + // Set the character limit, if not already present + if (empty($charLimit)) + { + $charLimit = empty($this->wrapchars) + ? 76 + : $this->wrapchars; + } + + // Standardize newlines + if (strpos($str, "\r") !== false) + { + $str = str_replace(["\r\n", "\r"], "\n", $str); + } + + // reduce multiple spaces at end of line + $str = preg_replace('| +\n|', "\n", $str); + + // If the current word is surround by {unwrap} tags we'll + // strip the entire chunk and replace it with a marker. + $unwrap = []; + if (preg_match_all('|\{unwrap\}(.+?)\{/unwrap\}|s', $str, $matches)) + { + for ($i = 0, $c = count($matches[0]); $i < $c; $i++) + { + $unwrap[] = $matches[1][$i]; + $str = str_replace($matches[0][$i], '{{unwrapped'.$i.'}}', $str); + } + } + + // We'll use PHP's native function to do the initial wordwrap. + // We set the cut flag to false so that any individual words that are + // too long get left alone. In the next step we'll deal with them. + $str = wordwrap($str, $charLimit, "\n", false); + + // Split the string into individual lines of tet and cycle through them. + $output = ''; + foreach (explode("\n", $str) as $line) + { + // Is the line within the allowed character count? + // If so we'll join it to the output and continue. + if (mb_strlen($line) <= $charLimit) + { + $output .= $line.$this->newline; + continue; + } + + $temp = ''; + do + { + // If the over-length word is a URL we son't wrap it + if (preg_match('!\[url.+\]|://|www\.!', $line)) + { + break; + } + + // Trim the word down + $temp .= mb_substr($line, 0, $charLimit-1); + $line = mb_substr($line, $charLimit-1); + } while (mb_strlen($line) > $charLimit); + + // 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 + if ($temp !== '') + { + $output .= $temp.$this->newline; + } + } + + // Put our markers back + if (count($unwrap) > 0) + { + foreach ($unwrap as $key => $val) + { + $output = str_replace('{{unwrapped'.$key.'}}', $val, $output); + } + } + + return $output; + } + + //-------------------------------------------------------------------- + + public function writeHeaders() + { + if ($this->protocol === 'mail') + { + // Get Subject out of the header and into the message itself. + if (isset($this->headers['Subject'])) + { + $this->message->setSubject($this->headers['Subject']); + unset($this->headers['Subject']); + } + } + + reset($this->headers); + $this->headerString = ''; + + foreach ($this->headers as $key => $val) + { + $val = trim($val); + + if ($val !== '') + { + $this->headerString .= $key.': '.$val.$this->newline; + } + } + + if ($this->protocol === 'mail') + { + $this->headerString = rtrim($this->headerString); + } + } + + //-------------------------------------------------------------------- + + /** + * Mime message + * + * @return string + */ + protected function getMimeMessage(): string + { + return 'This is a multi-part message in MIME format.'.$this->newline.'Your email application may not support this format.'; + } + + //-------------------------------------------------------------------- + + /** + * Spool mail to the mail server. + * + * @return bool + */ + protected function spoolEmail(): bool + { + $this->unwrapSpecials(); + + $method = 'sendWith'.ucfirst(strtolower($this->protocol)); + + if (! $this->$method()) + { + $this->setErrorMessage(lang('email.sendFailure'.($this->protocol == 'mail' ? 'phpmail' : $this->protocol))); + + return false; + } + + $this->setErrorMessage(lang('email.sent'.$this->protocol)); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Unwrap special elements. + */ + protected function unwrapSpecials() + { + $this->finalBody = preg_replace_callback('/\{unwrap\}(.*?)\{\/unwrap\}/si', [$this, 'removeNLCallback'], + $this->finalBody); + } + + //-------------------------------------------------------------------- + + /** + * Strip line-breaks via callback + * + * @param array $matches + * + * @return string + */ + protected function removeNLCallback(array $matches): string + { + if (strpos($matches[1], "\r") !== false OR strpos($matches[1], "\n") !== false) + { + $matches[1] = str_replace(["\r\n", "\r", "\n"], '', $matches[1]); + } + + return $matches[1]; + } + + //-------------------------------------------------------------------- + + /** + * Send using mail() + * + * return bool + */ + protected function sendWithMail(): bool + { + if (is_array($this->recipients)) + { + $this->recipients = implode(', ', $this->recipients); + } + + // validateEmailForShell below accepts by reference + // so this needs to be assigned to a variable. + $from = $this->cleanEmail($this->message->getReturnPath()); + + if (! $this->validateEmailForShell($from)) + { + return mail( + $this->recipients, + $this->message->getSubject(), + $this->finalBody, + $this->headerString + ); + } + + // 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->message->getSubject(), + $this->finalBody, + $this->headerString, + '-f '.$from + ); + } + + //-------------------------------------------------------------------- + + public 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') OR 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->headerString); + fputs($fp, $this->finalBody); + + $status = pclose($fp); + + if ($status !== 0) + { + $this->setErrorMessage('lang:email_exit_status', $status); + $this->setErrorMessage('lang:email_no_socket'); + + return false; + } + + return true; + } + + //-------------------------------------------------------------------- + + public function sendWithSmtp() + { + if ($this->config['SMTPHost'] === '') + { + $this->setErrorMessage('lang:email_no_hostname'); + + return false; + } + + if (! $this->SMTPConnect() OR ! $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; + } + } + + if (count($this->message->getCC()) > 0) + { + foreach ($this->message->getCC() as $val) + { + if ($val !== '' && ! $this->sendCommand('to', $val)) + { + $this->SMTPEnd(); + + return false; + } + } + } + + if (count($this->message->getBCC()) > 0) + { + foreach ($this->message->getBCC() 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->headerString.preg_replace('/^\./m', '..$1', $this->finalBody)); + + $this->sendData('.'); + + $reply = $this->getSMTPData(); + $this->setErrorMessage($reply); + + $this->SMTPEnd(); + + if (strpos($reply, '250') !== 0) + { + $this->setErrorMessage('lang:email_smtp_error', $reply); + + return false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * SMTP End + * + * Shortcut to send RSET or QUIT depending on keepalive + */ + protected function SMTPEnd() + { + $this->SMTPKeepalive + ? $this->sendCommand('reset') + : $this->sendCommand('quit'); + } + + //-------------------------------------------------------------------- + + 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)) + { + $this->setErrorMessage(lang('email.smtpError'.$errno.' '.$errstr)); + + return false; + } + + 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) + { + $this->setErrorMessage(lang('email.smtp_error'.$this->_get_smtp_data())); + + return false; + } + } + + return $this->sendCommand('hello'); + } + + //-------------------------------------------------------------------- + + /** + * Authenticate SMTP connections + * + * @return bool + */ + protected function SMTPAuthenticate() + { + if (! $this->SMTPAuth) + { + return true; + } + + if ($this->SMTPUser === '' && $this->SMTPPass === '') + { + $this->setErrorMessage(lang('email.noSMTPUnpw')); + + return false; + } + + $this->sendData('AUTH LOGIN'); + + $reply = $this->getSMTPData(); + + if (strpos($reply, '503') === 0) // Already authenticated + { + return true; + } + elseif (strpos($reply, '334') !== 0) + { + $this->setErrorMessage(lang('email.failedSMTPLogin', $reply)); + + return false; + } + + $this->sendData(base64_encode($this->SMTPUser)); + + $reply = $this->getSMTPData(); + + if (strpos($reply, '334') !== 0) + { + $this->setErrorMessage(lang('email.smtpAuthUn', $reply)); + + return false; + } + + $this->sendData(base64_encode($this->SMTPPass)); + + $reply = $this->getSMTPData(); + + if (strpos($reply, '235') !== 0) + { + $this->setErrorMessage('lang:email_smtp_auth_pw', $reply); + + return false; + } + + if ($this->SMTPKeepalive) + { + $this->SMTPAuth = false; + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP Command + * + * @param string $cmd + * @param string $data + * + * @return bool + */ + protected function sendCommand(string $cmd, string $data = null): bool + { + switch ($cmd) + { + case 'hello' : + + if ($this->SMTPAuth OR $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->debugMsg[] = '
'.$cmd.': '.$reply.'
'; + + if ((int)mb_substr($reply, 0, 3) !== $resp) + { + $this->setErrorMessage(lang('email.smtpError'.$reply)); + + return false; + } + + if ($cmd === 'quit') + { + fclose($this->SMTPConnect); + } + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Send SMTP data + * + * @param string $data + * + * @return bool + */ + protected function sendData($data) + { + $data .= $this->newline; + + for ($written = $timestamp = 0, $length = mb_strlen($data); $written < $length; $written += $result) + { + if (($result = fwrite($this->SMTPConnect, mb_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) + { + $this->setErrorMessage(lang('email.smtpDataFailure', $data)); + + return false; + } + + 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]'; + } + + //-------------------------------------------------------------------- + + /** + * Resets the state to blank, ready for a new email. Useful when + * sending emails in a loop and you need to make sure that the + * email is reset. + * + * @param bool $clear_attachments + * + * @return mixed + */ + public function reset(bool $clear_attachments = true) + { + $this->finalBody = ''; + $this->headerString = ''; + $this->recipients = []; + $this->headers = []; + $this->debugMsg = []; + + $this->setHeader('Date', $this->setDate()); + + if ($clear_attachments !== false) + { + $this->attachments = []; + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Set RFC 822 Date + * + * @return string + */ + protected function setDate(): string + { + $timezone = date('Z'); + $operator = ($timezone[0] === '-') ? '-' : '+'; + $timezone = abs($timezone); + $timezone = floor($timezone/3600)*100+($timezone%3600)/60; + + return sprintf('%s %s%04d', date('D, j M Y H:i:s'), $operator, $timezone); + } + + //-------------------------------------------------------------------- +} diff --git a/system/Mail/MailHandlerInterface.php b/system/Mail/MailHandlerInterface.php new file mode 100644 index 000000000000..7af104d97b16 --- /dev/null +++ b/system/Mail/MailHandlerInterface.php @@ -0,0 +1,52 @@ + 'John Doe ', + * 'from' => 'Jane Doe ' + * ]); + * + * @param array|null $options + */ + public function __construct(array $options=null); + + //-------------------------------------------------------------------- + + /** + * Takes an array of options whose keys should match + * any of the class properties. The property values will be set. + * Any unrecognized elements will be stored in the $data array. + * + * @param array $options + */ + public function setOptions(array $options); + + //-------------------------------------------------------------------- + + /** + * Called by the mailers prior to sending the message. Gives the message + * a chance to do any custom setup, like loading views for the content, etc. + * + * @return mixed + */ + public function build(); + + //-------------------------------------------------------------------- + + /** + * Returns the name / address of the person/people the email is from. + * Return array MUST be formatted as name => email. + * + * @return array + */ + public function getFrom(): array; + + //-------------------------------------------------------------------- + + /** + * Sets the name and email address of one person this is from. + * If this method is called multiple times, it adds multiple people + * to the from value, it does not overwrite the previous one. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setFrom(string $email, string $name=null); + + //-------------------------------------------------------------------- + + /** + * Allows multiple From name/email pairs to be set at once. + * Arrays MUST be in name => email format. + * + * @param array $emails + * + * @return self + */ + public function setFromMany(array $emails); + + //-------------------------------------------------------------------- + + /** + * Returns an array of all people the message is to. The array MUST + * be formatted in name => email format. + * + * @return array + */ + public function getTo(): array; + + //-------------------------------------------------------------------- + + /** + * Adds a single person to the list of recipients. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setTo(string $email, string $name=null); + + //-------------------------------------------------------------------- + + /** + * Allows multiple recipients to be added at once. The array MUST + * be formatted in name => email pairs. + * + * @param array $emails + * + * @return mixed + */ + public function setToMany(array $emails); + + //-------------------------------------------------------------------- + + /** + * Returns all recipients listed in the Reply-To field. + * + * @return array + */ + public function getReplyTo(): array; + + //-------------------------------------------------------------------- + + /** + * Adds a new recipient to the Reply-To header for this message. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setReplyTo(string $email, string $name=null); + + //-------------------------------------------------------------------- + + /** + * Sets multiple Reply-To addresses at once. The array MUST be + * formatted as name => email pairs. + * + * @param array $emails + * + * @return self + */ + public function setReplyToMany(array $emails); + + //-------------------------------------------------------------------- + + /** + * Gets a list of all names and addresses that should be CC'd + * on this message. + * + * @return array + */ + public function getCC(): array; + + //-------------------------------------------------------------------- + + /** + * Sets a single email/name to CC the message to. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setCC(string $email, string $name=null); + + //-------------------------------------------------------------------- + + /** + * Sets multiple CC address/name pairs at once. The array MUST + * be in name => email format: + * + * [ + * 'John Doe' => 'john.doe@example.com' + * ] + * + * @param array $emails + * + * @return self + */ + public function setCCMany(array $emails); + + //-------------------------------------------------------------------- + + /** + * Returns an array of all people that should be BCC'd on this message. + * The array is in name => email format: + * + * $bccs = [ + * 'John Doe' => 'john.doe@example.com' + * ]; + * + * @return array + */ + public function getBCC(): array; + + //-------------------------------------------------------------------- + + /** + * Adds another email address/name to the list of people to BCC. + * + * @param string $email + * @param string|null $name + * + * @return self + */ + public function setBCC(string $email, string $name=null); + + //-------------------------------------------------------------------- + + /** + * Takes an array email address/names that should be set as + * the BCC addresses. + * + * @param array $emails + * + * @return self + */ + public function setBCCMany(array $emails); + + //-------------------------------------------------------------------- + + /** + * Retrieves the subject line of the message. + * + * @return string + */ + public function getSubject(): string; + + //-------------------------------------------------------------------- + + /** + * Sets the subject line of the message. + * + * @param string $subject + * + * @return self + */ + public function setSubject(string $subject); + + //-------------------------------------------------------------------- + + /** + * Retrieves the contents of the HTML portion of the message. + * + * @return string + */ + public function getHTMLMessage(): string; + + //-------------------------------------------------------------------- + + /** + * Sets the contents of the HTML portion of the email message. + * + * @param string $html + * + * @return self + */ + public function setHTMLMessage(string $html); + + //-------------------------------------------------------------------- + + /** + * Gets the current contents of the Text message. + * + * @return string + */ + public function getTextMessage(): string; + + //-------------------------------------------------------------------- + + /** + * Sets the Text content of the email. + * + * @param string $text + * + * @return self + */ + public function setTextMessage(string $text); + + //-------------------------------------------------------------------- + + /** + * Gets any viewdata that has been set. + * + * @return array + */ + public function getData(): array; + + //-------------------------------------------------------------------- + + /** + * Sets an array of key/value pairs that are used like dynamic view + * data to replace placeholders in the HTML and Text Messages when + * they are parsed. + * + * @param array $data + * + * @return self + */ + public function setData(array $data); + + //-------------------------------------------------------------------- + + /** + * Determines if this message has the bare minimum information needed + * to send a message, i.e. to, from, subject and some message. + * + * @return bool + */ + public function isValid(): bool; + + //-------------------------------------------------------------------- +} diff --git a/tests/_support/Mail/SimpleMessage.php b/tests/_support/Mail/SimpleMessage.php new file mode 100644 index 000000000000..65f18856c285 --- /dev/null +++ b/tests/_support/Mail/SimpleMessage.php @@ -0,0 +1,14 @@ +messageHTML = '

Hello World!

'; + $this->messageText = 'Hello World!'; + + $this->subject = 'Howdy'; + } +} diff --git a/tests/system/Mail/BaseHandlerTest.php b/tests/system/Mail/BaseHandlerTest.php new file mode 100644 index 000000000000..d248a826d72c --- /dev/null +++ b/tests/system/Mail/BaseHandlerTest.php @@ -0,0 +1,66 @@ + '007', + 'mailtype' => 'html', + 'charset' => 'utf-16', + 'validate' => false, + 'SMTPAuth' => true, + 'ReplyToFlag' => true, + 'foo' => 'bar' + ]; + + $handler = $this->getHandler($options); + + $this->assertEquals('007', $handler->useragent); + $this->assertEquals('html', $handler->mailtype); + $this->assertEquals('UTF-16', $handler->charset); + $this->assertEquals(false, $handler->validate); + $this->assertEquals(true, $this->getPrivateProperty($handler, 'SMTPAuth')); + $this->assertEquals(true, $this->getPrivateProperty($handler, 'ReplyToFlag')); + $this->assertEquals($options, $this->getPrivateProperty($handler, 'config')); + } + + public function testSetMessage() + { + $handler = $this->getHandler(); + + $message = $this->getMessage(); + + $handler->setMessage($message); + $this->assertEquals($message, $handler->getMessage()); + } + + public function testSetHeader() + { + $handler = $this->getHandler(); + + $handler->setHeader('foo', 'bar'); + + $headers = $handler->getHeaders(); + + $this->assertTrue($headers['foo'] == 'bar'); + } + +} diff --git a/tests/system/Mail/BaseMessageTest.php b/tests/system/Mail/BaseMessageTest.php new file mode 100644 index 000000000000..a16a5e0d6130 --- /dev/null +++ b/tests/system/Mail/BaseMessageTest.php @@ -0,0 +1,119 @@ + ['John Doe' => 'john.doe@example.com'], + 'to' => ['Jane Doe' => 'jane.doe@example.com'], + 'replyTo' => 'foo@example.com', + 'cc' => 'bar@example.com', + 'bcc' => 'baz@example.com', + 'subject' => 'Foo Dog', + ]); + + $this->assertEquals([['Jane Doe' => 'jane.doe@example.com']], $message->getTo()); + $this->assertEquals([['John Doe' => 'john.doe@example.com']], $message->getFrom()); + $this->assertEquals(['foo@example.com'], $message->getReplyTo()); + $this->assertEquals(['bar@example.com'], $message->getCC()); + $this->assertEquals(['baz@example.com'], $message->getBCC()); + $this->assertEquals('Foo Dog', $message->getSubject()); + } + + public function testSingleSettersGetters() + { + $message = new SimpleMessage(); + + $message->setFrom('john.doe@example.com', 'John Doe') + ->setTo('jane.doe@example.com', 'Jane Doe') + ->setReplyTo('foo@example.com') + ->setCC('bar@example.com') + ->setBCC('baz@example.com') + ->setSubject('Foo Dog'); + + $this->assertEquals([['Jane Doe' => 'jane.doe@example.com']], $message->getTo()); + $this->assertEquals([['John Doe' => 'john.doe@example.com']], $message->getFrom()); + $this->assertEquals(['foo@example.com'], $message->getReplyTo()); + $this->assertEquals(['bar@example.com'], $message->getCC()); + $this->assertEquals(['baz@example.com'], $message->getBCC()); + $this->assertEquals('Foo Dog', $message->getSubject()); + } + + public function testManySettersGetters() + { + $message = new SimpleMessage(); + + $message->setFromMany(['John Doe' => 'john.doe@example.com', 'jane@example.com']) + ->setToMany(['John Doe' => 'john.doe@example.com', 'jane@example.com']) + ->setReplyToMany(['John Doe' => 'john.doe@example.com', 'jane@example.com']) + ->setCCMany(['John Doe' => 'john.doe@example.com', 'jane@example.com']) + ->setBCCMany(['John Doe' => 'john.doe@example.com', 'jane@example.com']); + + $this->assertEquals([['John Doe' => 'john.doe@example.com'], 'jane@example.com'], $message->getTo()); + $this->assertEquals([['John Doe' => 'john.doe@example.com'], 'jane@example.com'], $message->getFrom()); + $this->assertEquals([['John Doe' => 'john.doe@example.com'], 'jane@example.com'], $message->getReplyTo()); + $this->assertEquals([['John Doe' => 'john.doe@example.com'], 'jane@example.com'], $message->getCC()); + $this->assertEquals([['John Doe' => 'john.doe@example.com'], 'jane@example.com'], $message->getBCC()); + } + + public function testSetRecipientsThrowsOnInvalidEmails() + { + $this->setExpectedException('CodeIgniter\Mail\InvalidEmailAddress'); + + $message = new SimpleMessage(); + + $message->setTo('johndoeexample'); + } + + public function testSetHTMLMessageSuccess() + { + $message = new SimpleMessage(); + + $message->setHTMLMessage('

Welcome

'); + + $this->assertEquals('

Welcome

', $message->getHTMLMessage()); + } + + public function testSetTextMessageSuccess() + { + $message = new SimpleMessage(); + + $message->setTextMessage('

Welcome

'); + + $this->assertEquals('Welcome', $message->getTextMessage()); + } + + public function testSetDataSuccess() + { + $message = new SimpleMessage(); + + $message->setData(['foo' => 'bar']); + $message->setData(['bar' => 'baz']); + + $this->assertEquals(['foo' => 'bar', 'bar' => 'baz'], $message->getData()); + } + + public function testSetDataOverwritesItself() + { + $message = new SimpleMessage(); + + $message->setData(['foo' => 'bar']); + $message->setData(['foo' => 'baz']); + + $this->assertEquals(['foo' => 'baz'], $message->getData()); + } + + public function testSetDataOverwritesConstructor() + { + $message = new SimpleMessage([ + 'foo' => 'bar' + ]); + + $message->setData(['foo' => 'baz']); + + $this->assertEquals(['foo' => 'baz'], $message->getData()); + } +} diff --git a/tests/system/Mail/MailHandlerTest.php b/tests/system/Mail/MailHandlerTest.php new file mode 100644 index 000000000000..1357c7fad033 --- /dev/null +++ b/tests/system/Mail/MailHandlerTest.php @@ -0,0 +1,194 @@ +setMultiByteFlags(); + } + + + protected function getMessage(array $options=[]) + { + return new class($options) extends BaseMessage + { + public function build() {} + }; + } + + /** + * A simplified version of what happens in CodeIgniter.php + * just to ensure we have a compatible set of constants + * defined, that's needed for the Q Encoding. + */ + protected function setMultiByteFlags() + { + if (defined('MB_ENABLED')) return; + + $charset = 'UTF-8'; + + if (extension_loaded('mbstring')) + { + define('MB_ENABLED', TRUE); + } + else + { + define('MB_ENABLED', FALSE); + } + + // There's an ICONV_IMPL constant, but the PHP manual says that using + // iconv's predefined constants is "strongly discouraged". + if (extension_loaded('iconv')) + { + define('ICONV_ENABLED', TRUE); + } + else + { + define('ICONV_ENABLED', FALSE); + } + } + + + public function testCorrectlySetsSMTPAuthFlag() + { + $options = [ + 'SMTPUser' => 'fuser', + 'SMTPPass' => 'barword' + ]; + + $handler = new MailHandler($options); + + $this->assertEquals('fuser', $this->getPrivateProperty($handler, 'SMTPUser')); + $this->assertEquals('barword', $this->getPrivateProperty($handler, 'SMTPPass')); + $this->assertTrue($this->getPrivateProperty($handler, 'SMTPAuth')); + } + + public function testInitializeRetrievesAndFormatsToEmails() + { + $handler = new MailHandler(); + $handler->setMessage($this->getMessage([ + 'to' => ['foo' => 'foo@example.com', 'bar' => 'bar@example.com'] + ])); + + $expected = [ + 'foo ', + 'bar ' + ]; + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $this->assertEquals($expected, $this->getPrivateProperty($handler, 'recipients')); + } + + public function testInitializeRetrievesAndFormatsFromEmailsArray() + { + $handler = new MailHandler(); + $handler->setMessage($this->getMessage([ + 'from' => ['foo' => 'foo@example.com', 'bar' => 'bar@example.com'] + ])); + + $expected = [ + '"foo" ', + '"bar" ' + ]; + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $headers = $handler->getHeaders(); + $this->assertEquals(implode(', ', $expected), $headers['From']); + } + + public function testInitializeRetrievesAndFormatsReplyToEmailsArray() + { + $handler = new MailHandler(); + $handler->setMessage($this->getMessage([ + 'replyTo' => ['foo' => 'foo@example.com', 'bar' => 'bar@example.com'] + ])); + + $expected = [ + '"foo" ', + '"bar" ' + ]; + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $headers = $handler->getHeaders(); + $this->assertEquals(implode(', ', $expected), $headers['Reply-To']); + } + + public function testInitializeRetrievesAndFormatsCCEmailsArray() + { + $handler = new MailHandler(['protocol' => 'smtp']); + $handler->setMessage($this->getMessage([ + 'cc' => ['foo' => 'foo@example.com', 'bar' => 'bar@example.com'] + ])); + + $expected = [ + '"foo" ', + '"bar" ' + ]; + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $headers = $handler->getHeaders(); + $this->assertEquals(implode(', ', $expected), $headers['Cc']); + $this->assertEquals($expected, $this->getPrivateProperty($handler, 'CC')); + } + + public function testInitializeRetrievesAndFormatsBCCEmailsArray() + { + $handler = new MailHandler(['protocol' => 'smtp']); + $handler->setMessage($this->getMessage([ + 'bcc' => ['foo' => 'foo@example.com', 'bar' => 'bar@example.com'] + ])); + + $expected = [ + '"foo" ', + '"bar" ' + ]; + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $headers = $handler->getHeaders(); + $this->assertEquals(implode(', ', $expected), $headers['Bcc']); + $this->assertEquals($expected, $this->getPrivateProperty($handler, 'BCC')); + } + + public function testInitializeRetrievesSubjectAndQEncodes() + { + $handler = new MailHandler(['protocol' => 'smtp']); + $handler->setMessage($this->getMessage([ + 'subject' => 'Once more into the breach' + ])); + + $func = $this->getPrivateMethodInvoker($handler, 'initialize'); + $func(); + $headers = $handler->getHeaders(); + $this->assertEquals('=?UTF-8?Q?Once=20more=20into=20the=20b?==?UTF-8?Q?reach?=', $headers['Subject']); + } + + public function testSetHeader() + { + $handler = new MailHandler(); + $handler->setHeader('Foo', 'bar'); + + $headers = $handler->getHeaders(); + $this->assertEquals('bar', $headers['Foo']); + } + + public function testSendFailsWithNoFrom() + { + $handler = new MailHandler([]); + + $result = $handler->send($this->getMessage()); + + $this->assertTrue($result->hasErrors()); + $debug = $handler->getDebugger([]); + $this->assertTrue(strpos($debug, lang('mail.noFrom')) !== false); + } + +} diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index 146671e02d87..4d49a4a691a0 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -13,6 +13,7 @@ Library Reference localization curlrequest incomingrequest + mail message pagination request diff --git a/user_guide_src/source/libraries/mail.rst b/user_guide_src/source/libraries/mail.rst new file mode 100644 index 000000000000..cdb06eb4f48d --- /dev/null +++ b/user_guide_src/source/libraries/mail.rst @@ -0,0 +1,211 @@ +############# +Email Library +############# + +CodeIgniter's robust Email Class supports the following features: + +- Multiple Protocols: Mail, Sendmail, and SMTP +- Support for third-party handlers for local Logging, Amazon SES, Mailtrap, etc. +- TLS and SSL Encryption for SMTP +- Multiple recipients +- CC and BCCs +- HTML or Plaintext email +- Attachments +- Word wrapping +- Priorities +- BCC Batch Mode, enabling large email lists to be broken into small + BCC batches. +- Email Debugging tools + +.. contents:: + :local: + +*********************** +Using the Email Library +*********************** + +In CodeIgniter, messages are each represented by a simple class file. Theses classes are typically stored +within the **app/Mail** directory. + +Building the Message +==================== + +The message class must extend ``CodeIgniter\Mail\BaseMessage``, and should have a ``build()`` method. +This method is where you can prepare your message by loading and parsing view files to construct the +HTML and Text bodies of the message, and more. + +:: + + setSubject(lang('users.welcomeEmailTitle')); + + $this->setHTMLMessage(view('Emails/UserWelcomeHTML', $this->data)); + $this->setTextMessage(view('Emails/UserWelcomeText', $this->data)); + } + } + +This is all that's really needed to setup an email message and get it ready to use. In this example, +we're setting the subject here through a language string. Next we set the HTML and text bodies for the +message. We expect the message to be personalized each time it's sent, so we use views to hold the body templates +themselves. The class has a ``$data`` variable that can be used to store key/value pairs that we use within +the views for dynamic data replacement. + +There are a number of methods available for setting recipients, attachments, and more, and they can all be used +from within the build() method. + +Sending a Message +================= + +You send a message using the ``email()`` helper function. It takes a Message class as it's only parameter, +and returns that message class, already set up with an instantiated handler, and ready to go. You can then +set any additional parameters you need, like who it's going to, who it's from, add attachments, etc.:: + + email(new App\Mail\UserWelcomeMessage()) + ->setTo($user->email, $user->firstName) + ->send(); + +Debugging A Message +=================== + +If you are having problems trying to get your emails to send, you can print out some debugging information +that will display any errors encountered and some basic information about the sending process that might +prove helpful while trying to determine the problem:: + + $mail = email(new App\Mail\UserWelcomeMessage()) + ->setTo($user->email, $user->firstName) + ->send(); + + if ($mail->hasErrors()) + { + die($mail->getDebugger()); + } + + +Setting Email Preferences +========================= + +There are a number of preferences available to tailor how your email messages are sent. These are set +in **application/Config/Mail.php**. + +**default from** + +The default value that will be used when sending any email can be setup in the ``from`` setting:: + + public $from = [ + 'name' => 'CodeIgniter', + 'email' => 'codeigniter@example.com' + ]; + +**userAgent** + +This sets the user agent, or software, that's sending the mails:: + + public $userAgent = 'CodeIgniter'; + +**others** + +The remaining settings can all be set specific to each connection group. Most of them have default values +that will be used if nothing has been set, and some are only required for specific types of connections. + +=================== ====================== ============================ ======================================================================= +Preference Default Value Options Description +=================== ====================== ============================ ======================================================================= +**protocol** mail mail, sendmail, or smtp The mail sending protocol. +**mailpath** /usr/sbin/sendmail None The server path to Sendmail. +**SMTPHost** No Default None SMTP Server Address. +**SMTPUser** No Default None SMTP Username. +**SMTPPass** No Default None SMTP Password. +**SMTPPort** 25 None SMTP Port. +**SMTPTimeout** 5 None SMTP Timeout (in seconds). +**SMTPKeepalive** FALSE TRUE or FALSE (boolean) Enable persistent SMTP connections. +**SMTPCrypto** No Default tls or ssl SMTP Encryption +**wordwrap** TRUE TRUE or FALSE (boolean) Enable word-wrap. +**wrapchars** 76 Character count to wrap at. +**charset** ``$config['charset']`` Character set (utf-8, iso-8859-1, etc.). +**validate** TRUE TRUE or FALSE (boolean) Whether to validate the email address. +**priority** 3 1, 2, 3, 4, 5 Email Priority. 1 = highest. 5 = lowest. 3 = normal. +**crlf** \\n "\\r\\n" or "\\n" or "\\r" Newline character. (Use "\\r\\n" to comply with RFC 822). +**newline** \\n "\\r\\n" or "\\n" or "\\r" Newline character. (Use "\\r\\n" to comply with RFC 822). +**bcc_batch_mode** FALSE TRUE or FALSE (boolean) Enable BCC Batch Mode. +**bcc_batch_size** 200 None Number of emails in each BCC batch. +**DSN** FALSE TRUE or FALSE (boolean) Enable notify message from server +=================== ====================== ============================ ======================================================================= + +Note that mail type is not required to be set. It is determined automatically based on whether the HTML or Text bodies have been set on the message. + +Settings Groups +--------------- + +You can setup multiple groups of settings that can be used at any time. This can be useful if you want to send transactional emails +through a third-party service like Postmark or Mandrill, but need to send non-transactional emails over a local SMTP connection. +Groups are defined in the ``$groups`` setting. For each, you need to supply an alias to refer to the group by, and +any settings that are needed for that connection:: + + public $groups = [ + 'default' => [ + 'handler' => 'default', + 'protocol' => 'mail', + ], + 'mailtrap' => [ + 'handler' => 'default', + 'protocol' => 'smtp', + 'SMTPHost' => 'smtp.mailtrap.io', + 'SMTPUser' => 'xxx', + 'SMTPPass' => 'xxx', + 'SMTPPort' => 2525, + 'SMTPCrypto' => 'tls' + ] + ]; + +In this example we have two groups, **default** and **mailtrap**. We'll use the mailtrap connection on our development +server so that we can test sending mail without bothering anyone with real emails. Both groups use the **default** +mail handler, which can work with *mail*, *sendmail*, or *smtp* to send the message. Each group contains only the +settings it needs that varies from the defaults. + +Choosing Group at Runtime +------------------------- + +You can choose which group is used at runtime by passing the group's alias as the second parameter to the **email()** helper:: + + email(new App\Mail\UserWelcomeMessage(), 'default') + ->setTo($user->email, $user->firstName) + ->send(); + +Provided Handlers +----------------- + +CodeIgniter ships with the following handlers that you can use in your settings groups. + +=========== =========================================================================== +default Supports mail, sendmail, and smtp connections. +logger Simply stores copies of the HTML and Text emails locally, in + **writable/email**. +=========== =========================================================================== + + + +Overriding Word Wrapping +======================== + +If you have word wrapping enabled (recommended to comply with RFC 822) +and you have a very long link in your email it can get wrapped too, +causing it to become un-clickable by the person receiving it. +CodeIgniter lets you manually override word wrapping within part of your +message like this:: + + The text of your email that + gets wrapped normally. + + {unwrap}http://example.com/a_long_link_that_should_not_be_wrapped.html{/unwrap} + + More text that will be + wrapped normally. + + +Place the item you do not want word-wrapped between: {unwrap} {/unwrap} +