* 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.ihtml');
// $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' => null,
'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 the email body.
protected $_template;
// String that contains the email body after replacements.
protected $_template_replaced;
// 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
/**
* 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)
{
// 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,6}))' // FALSE, matches domain name
. '(?(1)' // Comment conditional for if initial < exists
. '(?:\s*>\s*|>\s+\([^,@]+\)\s*)' // TRUE, ensure ending >
. '|'
. '(?:|\s*|\s+\([^,@]+\)\s*))$/i')); // 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;
}
}
/**
* 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.
if (!$this->_template = file_get_contents($template, true)) {
$app->logMsg(sprintf('Email template file does not exist: %s', $template), LOG_ERR, __FILE__, __LINE__);
$this->_template = null;
$this->_template_replaced = 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;
return true;
}
/**
* Loads template from string 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 setString($string)
{
$app =& App::getInstance();
if ('' == trim($string)) {
$app->logMsg(sprintf('Empty string provided.', null), LOG_ERR, __FILE__, __LINE__);
$this->_template_replaced = null;
return false;
} else {
$this->_template = $string;
// This could be a new template, so reset the _template_replaced.
$this->_template_replaced = null;
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, create_function('&$v', '$v = "{" . 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);
}
/*
* 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}/s', "\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();
$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 variables in template: %s', getDump($unreplaced_match)), 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)) {
$app->logMsg(sprintf('Cannot send email: "%s". Template not set.', $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;
}
// 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('Cannot send email. Unreplaced variables in template: %s', getDump($unreplaced_match)), 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'];
// From headers are custom headers.
$headers = array('From' => $this->_params['from']);
// Additional headers.
if (isset($this->_params['headers']) && is_array($this->_params['headers'])) {
$headers = array_merge($this->_params['headers'], $headers);
}
// Process headers.
$final_headers = array();
foreach ($headers as $key => $val) {
// Validate key and values.
if (empty($val)) {
$app->logMsg(sprintf('Empty email header provided: %s', $key), LOG_DEBUG, __FILE__, __LINE__);
continue;
}
if (empty($key) || !is_string($key) || !is_string($val) || preg_match("/[\n\r]/", $key . $val) || preg_match('/[^\w-]/', $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[] = sprintf('%s: %s', $key, $val);
}
$final_headers = join($this->getParam('crlf'), $final_headers);
// 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('/^.*([^\s@\[\]<>()]+\@[A-Za-z0-9.-]{1,}\.[A-Za-z]{2,5})>?$/iU', '$1', $this->_params['from']);
}
if ('' != $envelope_sender_address && $this->validEmail($envelope_sender_address)) {
$additional_parameter = sprintf('-f %s', $envelope_sender_address);
} else {
$additional_parameter = '';
}
// 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;
}
// Send email without 5th parameter if safemode is enabled.
if (ini_get('safe_mode')) {
$ret = mb_send_mail($final_to, $this->_params['subject'], $final_body, $final_headers);
} else {
$ret = mb_send_mail($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', $final_to), 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;
}
}
}
}