* Copyright 2001-2012 Strangecode, LLC * * This file is part of The Strangecode Codebase. * * The Strangecode Codebase is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as published by the * Free Software Foundation, either version 3 of the License, or (at your option) * any later version. * * The Strangecode Codebase is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * The Strangecode Codebase. If not, see . */ /* * Email.inc.php * * Easy email template usage. * * @author Quinn Comendant * @version 1.0 * * Example of use: --------------------------------------------------------------------- // Setup email object. $email = new Email(array( 'to' => array($frm['email'], 'q@lovemachine.local'), 'from' => sprintf('"%s" <%s>', addcslashes($app->getParam('site_name'), '"'), $app->getParam('site_email')), 'subject' => 'Your account has been activated', )); $email->setTemplate('email_registration_confirm.eml'); // $email->setString('Or you can pass your message body as a string, also with {VARIABLES}.'); $email->replace(array( 'site_name' => $app->getParam('site_name'), 'site_url' => $app->getParam('site_url'), 'username' => $frm['username'], 'password' => $frm['password1'], )); if ($email->send()) { $app->raiseMsg(sprintf(_("A confirmation email has been sent to %s."), $frm['email']), MSG_SUCCESS, __FILE__, __LINE__); } else { $app->logMsg(sprintf('Error sending confirmation email to address %s', $frm['email']), LOG_NOTICE, __FILE__, __LINE__); } --------------------------------------------------------------------- */ class Email { // Default parameters, to be overwritten by setParam() and read with getParam() protected $_params = array( 'to' => null, 'from' => null, 'subject' => null, 'headers' => [], 'envelope_sender_address' => null, // AKA the bounce-to address. Will default to 'from' if left null. 'regex' => null, // A single carriage return (\n) should terminate lines for locally injected mail. // A carriage return + line-feed (\r\n) should be used if sending mail directly with SMTP. 'crlf' => "\n", // RFC 2822 says line length MUST be no more than 998 characters, and SHOULD be no more than 78 characters, excluding the CRLF. // http://mailformat.dan.info/body/linelength.html 'wrap' => true, 'line_length' => 75, 'sandbox_mode' => null, 'sandbox_to_addr' => null, ); // String that contains filename of the template used (for logging). protected $_template_filename; // String that contains the email body. protected $_template; // String that contains the email body after replacements. protected $_template_replaced; // String that contains the final email body. // Use only when email is manually composed and encoded outside of this Email class, and need to bypass replacements, detection of mail header injection attacks, and the encoding provided by mb_send_mail(). protected $_raw_body; // Email debug modes. const SANDBOX_MODE_REDIRECT = 1; // Send all mail to 'sandbox_to_addr' const SANDBOX_MODE_STDERR = 2; // Log all mail to stderr const SANDBOX_MODE_LOG = 3; // Log all mail using $app->logMsg(…) /** * Constructor. * * @access public * @param array $params Array of object parameters. * @author Quinn Comendant * @since 28 Nov 2005 12:59:41 */ public function __construct($params=null) { $app =& App::getInstance(); // The regex used in validEmail(). Set here instead of in the default _params above so we can use the concatenation . dot. // This matches a (valid) email address as complex as: // "Jane & Bob Smith" (Sales department) // ...and something as simple as: // x@x.com $this->setParam(array('regex' => '/^(?:(?:"[^"]*?"\s*|[^,@]*)(<\s*)|(?:"[^"]*?"|[^,@]*)\s+|)' // Display name . '((?:[^.<>\s@",\[\]]+[^<>\s@",\[\]])*[^.<>\s@",\[\]]+)' // Local-part . '@' // @ . '((?:(\[)|[a-z0-9]?)' // Domain, first char . '(?(4)' // Domain conditional for if first domain char is [ . '(?:[0-9]{1,3}\.){3}[0-9]{1,3}\]' // TRUE, matches IP address . '|' . '[.-]?(?:[a-z0-9]+[-.])*(?:[a-z0-9]+\.)+[a-z]{2,19}))' // FALSE, matches domain name . '(?(1)' // Comment conditional for if initial < exists . '(?:\s*>\s*|>\s+\([^,@]+\)\s*)' // TRUE, ensure ending > . '|' . '(?:|\s*|\s+\([^,@]+\)\s*))$/i' . $app->getParam('preg_u'))); // FALSE ensure there is no ending > if (isset($params)) { $this->setParam($params); } } /** * Set (or overwrite existing) parameters by passing an array of new parameters. * * @access public * @param array $params Array of parameters (key => val pairs). */ public function setParam($params) { $app =& App::getInstance(); if (isset($params) && is_array($params)) { // Enforce valid email addresses. if (isset($params['to']) && !$this->validEmail($params['to'])) { $params['to'] = null; } if (isset($params['from']) && !$this->validEmail($params['from'])) { $params['from'] = null; } if (isset($params['envelope_sender_address']) && !$this->validEmail($params['envelope_sender_address'])) { $params['envelope_sender_address'] = null; } // Merge new parameters with old overriding only those passed. $this->_params = array_merge($this->_params, $params); } else { $app->logMsg(sprintf('Parameters are not an array: %s', $params), LOG_ERR, __FILE__, __LINE__); } } /** * Return the value of a parameter, if it exists. * * @access public * @param string $param Which parameter to return. * @return mixed Configured parameter value. */ public function getParam($param) { $app =& App::getInstance(); if (array_key_exists($param, $this->_params)) { return $this->_params[$param]; } else { $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__); return null; } } /* * Append headers to $this->_params['headers'] * * @access public * @param array $headers Array of key=>val pairs that will be appended. * @return void * @author Quinn Comendant * @since 23 Jul 2022 13:36:21 */ public function appendHeaders($headers) { $app =& App::getInstance(); if (!isset($headers) || !is_array($headers)) { $app->logMsg(sprintf('%s requires an array of header values', __METHOD__), LOG_NOTICE, __FILE__, __LINE__); } $this->_params['headers'] = array_merge($this->_params['headers'], $headers); } /** * Loads template from file to generate email body. * * @access public * @param string $template Filename of email template. * @author Quinn Comendant * @since 28 Nov 2005 12:56:23 */ public function setTemplate($template) { $app =& App::getInstance(); // Load file, using include_path. $this->_template = file_get_contents($template, true); if (!$this->_template || '' == trim($this->_template)) { $app->logMsg(sprintf('Email template file does not exist or is empty: %s', $template), LOG_ERR, __FILE__, __LINE__); $this->_template = null; $this->_template_replaced = null; $this->_raw_body = null; return false; } // Ensure template is UTF-8. $detected_encoding = mb_detect_encoding($this->_template, array('UTF-8', 'ISO-8859-1', 'WINDOWS-1252'), true); if ('UTF-8' != strtoupper($detected_encoding)) { $this->_template = mb_convert_encoding($this->_template, 'UTF-8', $detected_encoding); } // This could be a new template, so reset the _template_replaced. $this->_template_replaced = null; $this->_raw_body = null; $this->_template_filename = $template; return true; } /** * Loads template from string to generate email body. * * @access public * @param string $string Content of email template. * @author Quinn Comendant * @since 28 Nov 2005 12:56:23 */ public function setString($string) { $app =& App::getInstance(); if ('' == trim($string)) { $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__); $this->_template_replaced = null; $this->_raw_body = null; return false; } else { $this->_template = $string; // This could be a new template, so reset the _template_replaced. $this->_template_replaced = null; $this->_raw_body = null; $this->_template_filename = '(using Email::setString)'; return true; } } /** * Replace variables in template with argument data. * * @access public * @param array $replacements Array keys are the values to search for, array vales are the replacement values. * @author Quinn Comendant * @since 28 Nov 2005 13:08:51 */ public function replace($replacements) { $app =& App::getInstance(); // Ensure template exists. if (!isset($this->_template)) { $app->logMsg(sprintf('Cannot replace variables, no template defined.', null), LOG_ERR, __FILE__, __LINE__); return false; } // Ensure replacements argument is an array. if (!is_array($replacements)) { $app->logMsg(sprintf('Cannot replace variables, invalid replacements.', null), LOG_ERR, __FILE__, __LINE__); return false; } // Apply regex pattern to search elements. $search = array_keys($replacements); array_walk($search, function (&$v) { $v = sprintf('{%s}', mb_strtoupper($v)); }); // Replacement values. $replace = array_values($replacements); // Search and replace all values at once. $this->_template_replaced = str_replace($search, $replace, $this->_template); } /** * Set the final email body to send. Using this disables any further post-processing, including encoding and scanning for mail header injection attacks. * * @access public * @param string $body Final email body. * @author Quinn Comendant * @since 23 Jul 2022 12:01:52 */ public function setRawBody($string) { $app =& App::getInstance(); if ('' == trim($string)) { $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__); $this->_template = null; $this->_template_replaced = null; $this->_raw_body = null; return false; } else { $this->_template = null; $this->_template_replaced = null; $this->_raw_body = $string; $this->_template_filename = '(using Email::setRawBody)'; return true; } } /* * Returns the body of the current email. This can be used to store the message that is being sent. * It will use the original template, or the replaced template if it has been processed. * You can also use this function to do post-processing on the email body before sending it, * like removing extraneous lines: * $email->setString(preg_replace('/(?:(?:\r\n|\r|\n)\s*){2}/su', "\n\n", $email->getBody())); * * @access public * @return string Message body. * @author Quinn Comendant * @version 1.0 * @since 18 Nov 2014 21:15:19 */ public function getBody() { $app =& App::getInstance(); if (isset($this->_raw_body)) { return $this->_raw_body; } $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template; // Ensure all placeholders have been replaced. Find anything with {...} characters. if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) { unset($unreplaced_match[0]); $app->logMsg(sprintf('Cannot get email body. Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__); return false; } return $final_body; } /** * Send email using PHP's mail() function. * * @access public * @param string $to * @param string $from * @param string $subject * @author Quinn Comendant * @since 28 Nov 2005 12:56:09 */ public function send($to=null, $from=null, $subject=null, $headers=null) { $app =& App::getInstance(); // Use arguments if provided. if (isset($to)) { $this->setParam(array('to' => $to)); } if (isset($from)) { $this->setParam(array('from' => $from)); } if (isset($subject)) { $this->setParam(array('subject' => $subject)); } if (isset($headers)) { $this->setParam(array('headers' => $headers)); } // Ensure required values exist. if (!isset($this->_params['subject'])) { $app->logMsg('Cannot send email. SUBJECT not defined.', LOG_ERR, __FILE__, __LINE__); return false; } else if (!isset($this->_template) && !isset($this->_raw_body)) { $app->logMsg(sprintf('Cannot send email: "%s". Need a template or raw body.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__); return false; } else if (!isset($this->_params['to'])) { $app->logMsg(sprintf('Cannot send email: "%s". TO not defined.', $this->_params['subject']), LOG_NOTICE, __FILE__, __LINE__); return false; } else if (!isset($this->_params['from'])) { $app->logMsg(sprintf('Cannot send email: "%s". FROM not defined.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__); return false; } // Final “to” header can have multiple addresses if in an array. $final_to = is_array($this->_params['to']) ? join(', ', $this->_params['to']) : $this->_params['to']; if (!preg_match('/(?:>$|>,)/', $final_to)) { $app->logMsg(sprintf('Email addresses should be enclosed in : %s', $final_to), LOG_NOTICE, __FILE__, __LINE__); } // “From” headers are custom headers. $this->appendHeaders(['From' => $this->_params['from']]); // Process headers. $final_headers_arr = array(); $final_headers = ''; foreach ($this->_params['headers'] as $key => $val) { // Validate key and values. if (!isset($val) || !strlen($val)) { $app->logMsg(sprintf('Empty email header provided: %s', $key), LOG_NOTICE, __FILE__, __LINE__); continue; } // Ensure headers meet RFC requirements. // https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1 // https://datatracker.ietf.org/doc/html/rfc5322#section-2.2 if (!strlen($key) || strlen($key . $val) > 998 || preg_match('/[^\x21-\x39\x3B-\x7E]/', $key)) { $app->logMsg(sprintf('Broken email header provided: %s=%s', $key, $val), LOG_WARNING, __FILE__, __LINE__); continue; } // If the envelope_sender_address was given as a header, move it to the correct place. if ('envelope_sender_address' == strtolower($key)) { $this->_params['envelope_sender_address'] = isset($this->_params['envelope_sender_address']) ? $this->_params['envelope_sender_address'] : $val; continue; } // If we're sending in sandbox mode, remove any headers with recipient addresses. if ($this->getParam('sandbox_mode') == self::SANDBOX_MODE_REDIRECT && in_array(strtolower($key), array('to', 'cc', 'bcc')) && mb_strpos($val, '@') !== false) { // Don't carry this into the $final_headers. $app->logMsg(sprintf('Skipping header in sandbox mode: %s=%s', $key, $val), LOG_DEBUG, __FILE__, __LINE__); continue; } $final_headers_arr[] = sprintf('%s: %s', $key, $val); } $final_headers = join($this->getParam('crlf'), $final_headers_arr); // This is the address where delivery problems are sent to. We must strip off everything except the local@domain part. if (isset($this->_params['envelope_sender_address'])) { $envelope_sender_address = sprintf('<%s>', trim($this->_params['envelope_sender_address'], '<>')); } else { $envelope_sender_address = preg_replace('/^.*()]+\@[A-Za-z0-9.-]{1,}\.[A-Za-z]{2,19})>?$/iU' . $app->getParam('preg_u'), '$1', $this->_params['from']); } if ('' != $envelope_sender_address && $this->validEmail($envelope_sender_address)) { $additional_parameter = sprintf('-f %s', $envelope_sender_address); } else { $additional_parameter = ''; } if (isset($this->_raw_body)) { $mail_function = 'mail'; $final_body = $this->_raw_body; } else { $mail_function = 'mb_send_mail'; // Wrap email text body, using _template_replaced if replacements have been used, or just a fresh _template if not. $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template; if (false !== $this->getParam('wrap')) { $final_body = wordwrap($final_body, $this->getParam('line_length'), $this->getParam('crlf')); } // Ensure all placeholders have been replaced. Find anything with {...} characters. if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) { unset($unreplaced_match[0]); $app->logMsg(sprintf('Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__); return false; } // Check for mail header injection attacks. $full_mail_content = join($this->getParam('crlf'), array($final_to, $this->_params['subject'], $final_body)); if (preg_match("/(^|[\n\r])(Content-Type|MIME-Version|Content-Transfer-Encoding|Bcc|Cc)\s*:/i", $full_mail_content)) { $app->logMsg(sprintf('Mail header injection attack in content: %s', $full_mail_content), LOG_WARNING, __FILE__, __LINE__); return false; } } // Enter sandbox mode, if specified. switch ($this->getParam('sandbox_mode')) { case self::SANDBOX_MODE_REDIRECT: if (!$this->getParam('sandbox_to_addr')) { $app->logMsg(sprintf('Email sandbox_mode is SANDBOX_MODE_REDIRECT but sandbox_to_addr is not set.', null), LOG_ERR, __FILE__, __LINE__); break; } $final_to = $this->getParam('sandbox_to_addr'); break; case self::SANDBOX_MODE_STDERR: file_put_contents('php://stderr', sprintf("Subject: %s\nTo: %s\n%s\n\n%s", $this->getParam('subject'), $final_to, str_replace($this->getParam('crlf'), "\n", $final_headers), $final_body), FILE_APPEND); return true; case self::SANDBOX_MODE_LOG: // Temporarily modify log settings to allow full multi-line emails to appear in logs. $log_serialize = $app->getParam('log_serialize'); $log_message_max_length = $app->getParam('log_message_max_length'); $app->setParam(array('log_serialize' => false, 'log_message_max_length' => 65536)); $app->logMsg(sprintf("\nSubject: %s\nTo: %s\n%s\n\n%s", $this->getParam('subject'), $final_to, trim(str_replace($this->getParam('crlf'), "\n", $final_headers)), trim($final_body)), LOG_DEBUG, __FILE__, __LINE__); $app->setParam(array('log_serialize' => $log_serialize, 'log_message_max_length' => $log_message_max_length)); return true; } // Send email without 5th parameter if safemode is enabled. if (ini_get('safe_mode')) { $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers); } else { $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers, $additional_parameter); } // Ensure message was successfully accepted for delivery. if ($ret) { $app->logMsg(sprintf('Email successfully sent to %s: %s', $final_to, $this->_params['subject']), LOG_INFO, __FILE__, __LINE__); return true; } else { $app->logMsg(sprintf('Email failure: %s, %s, %s, %s', $final_to, $this->_params['subject'], str_replace("\r\n", '\r\n', $final_headers), $additional_parameter), LOG_WARNING, __FILE__, __LINE__); return false; } } /** * Validates an email address based on the recommendations in RFC 3696. * Is more loose than restrictive, to allow the many valid variants of * email addresses while catching the most common mistakes. Checks an array too. * http://www.faqs.org/rfcs/rfc822.html * http://www.faqs.org/rfcs/rfc2822.html * http://www.faqs.org/rfcs/rfc3696.html * http://www.faqs.org/rfcs/rfc1035.html * * @access public * @param mixed $email Address to check, string or array. * @return bool Validity of address. * @author Quinn Comendant * @since 30 Nov 2005 22:00:50 */ public function validEmail($email) { $app =& App::getInstance(); // If an array, check values recursively. if (is_array($email)) { foreach ($email as $e) { if (!$this->validEmail($e)) { return false; } } return true; } else { // To be valid email address must match regex and fit within the length constraints. if (preg_match($this->getParam('regex'), $email, $e_parts) && mb_strlen($e_parts[2]) < 64 && mb_strlen($e_parts[3]) < 255) { return true; } else { $app->logMsg(sprintf('Invalid email address: %s', $email), LOG_INFO, __FILE__, __LINE__); return false; } } } }