From: Michael M Slusarz Date: Wed, 12 May 2010 01:07:32 +0000 (-0600) Subject: Add Horde_Mail package. X-Git-Url: https://git.internetallee.de/?a=commitdiff_plain;h=bbb688cfc7432f14026b9f8f559da657a9443ba1;p=horde.git Add Horde_Mail package. Adds the following features: * Allows a stream to be passed in. * Allows raw headertext to be used in the outgoing messages (required for things like message redirection pursuant to RFC 5322 [3.6.6]). * Native PHP 5 code. * Provides more comprehensive sendmail error messages. * Uses Exceptions instead of PEAR_Errors. --- diff --git a/framework/Mail/lib/Horde/Mail.php b/framework/Mail/lib/Horde/Mail.php new file mode 100644 index 000000000..44d1797de --- /dev/null +++ b/framework/Mail/lib/Horde/Mail.php @@ -0,0 +1,72 @@ + + * @author Michael Slusarz + * @copyright 1997-2010 Chuck Hagenbuch + * @copyright 2010 Michael Slusarz + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * The Mail interface. + * + * @category Horde + * @package Mail + */ +class Horde_Mail +{ + /** + * Returns a Horde_Mail_Driver:: object. + * + * @param string $driver The driver to instantiate. + * @param array $params The parameters to pass to the object. + * + * @return Horde_Mail_Driver The driver instance. + * @throws Horde_Mail_Exception + */ + static public function factory($driver, $params = array()) + { + $class = __CLASS__ . '_' . ucfirst($driver); + + if (class_exists($class)) { + return new $class($params); + } + + throw new Horde_Mail_Exception('Unable to find class for driver ' . $driver); + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Driver.php b/framework/Mail/lib/Horde/Mail/Driver.php new file mode 100644 index 000000000..fb0f08804 --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Driver.php @@ -0,0 +1,214 @@ + + * @author Michael Slusarz + * @copyright 1997-2010 Chuck Hagenbuch + * @copyright 2010 Michael Slusarz + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * Mail driver base class. + * + * @access public + * @version $Revision: 294747 $ + * @package Mail + */ +abstract class Horde_Mail_Driver +{ + /** + * Line terminator used for separating header lines. + * + * @var string + */ + public $sep = "\r\n"; + + /** + * Configuration parameters. + * + * @var array + */ + protected $_params = array(); + + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + abstract public function send($recipients, array $headers, $body); + + /** + * Take an array of mail headers and return a string containing text + * usable in sending a message. + * + * @param array $headers The array of headers to prepare, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array value + * is the header value (ie, 'test'). The header + * produced from those values would be 'Subject: + * test'. + * If the '_raw' key exists, the value of this key + * will be used as the exact text for sending the + * message. + * + * @return mixed Returns false if it encounters a bad address; otherwise + * returns an array containing two elements: Any From: + * address found in the headers, and the plain text version + * of the headers. + * @throws Horde_Mail_Exception + */ + public function prepareHeaders(array $headers) + { + $lines = array(); + $from = null; + + $parser = new Horde_Mail_Rfc822(); + + foreach ($headers as $key => $value) { + if (strcasecmp($key, 'From') === 0) { + $addresses = $parser->parseAddressList($value, array( + 'nest_groups' => false, + )); + $from = $addresses[0]->mailbox . '@' . $addresses[0]->host; + + // Reject envelope From: addresses with spaces. + if (strstr($from, ' ')) { + return false; + } + + $lines[] = $key . ': ' . $value; + } elseif (strcasecmp($key, 'Received') === 0) { + $received = array(); + if (!is_array($value)) { + $value = array($value); + } + + foreach ($value as $line) { + $received[] = $key . ': ' . $line; + } + + // Put Received: headers at the top. Spam detectors often + // flag messages with Received: headers after the Subject: + // as spam. + $lines = array_merge($received, $lines); + } else { + // If $value is an array (i.e., a list of addresses), convert + // it to a comma-delimited string of its elements (addresses). + if (is_array($value)) { + $value = implode(', ', $value); + } + $lines[] = $key . ': ' . $value; + } + } + + return array($from, isset($headers['_raw']) ? $headers['_raw'] : join($this->sep, $lines)); + } + + /** + * Take a set of recipients and parse them, returning an array of bare + * addresses (forward paths) that can be passed to sendmail or an SMTP + * server with the 'RCPT TO:' command. + * + * @param mixed Either a comma-separated list of recipients (RFC822 + * compliant), or an array of recipients, each RFC822 valid. + * + * @return array Forward paths (bare addresses). + * @throws Horde_Mail_Exception + */ + public function parseRecipients($recipients) + { + // if we're passed an array, assume addresses are valid and + // implode them before parsing. + if (is_array($recipients)) { + $recipients = implode(', ', $recipients); + } + + // Parse recipients, leaving out all personal info. This is + // for smtp recipients, etc. All relevant personal information + // should already be in the headers. + $parser = new Horde_Mail_Rfc822(); + $addresses = $parser->parseAddressList($recipients, array( + 'nest_groups' => false + )); + + $recipients = array(); + if (is_array($addresses)) { + foreach ($addresses as $ob) { + $recipients[] = $ob->mailbox . '@' . $ob->host; + } + } + + return $recipients; + } + + /** + * Sanitize an array of mail headers by removing any additional header + * strings present in a legitimate header's value. The goal of this + * filter is to prevent mail injection attacks. + * + * @param array $headers The associative array of headers to sanitize. + * + * @return array The sanitized headers. + */ + protected function _sanitizeHeaders($headers) + { + foreach (array_keys($headers) as $key) { + $headers[$key] = preg_replace('=((||0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $headers[$key]); + } + + return $headers; + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Exception.php b/framework/Mail/lib/Horde/Mail/Exception.php new file mode 100644 index 000000000..eb2c0c52b --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Exception.php @@ -0,0 +1,17 @@ + + * @category Horde + * @package Mail + */ +class Horde_Mail_Exception extends Horde_Exception_Prior +{ +} diff --git a/framework/Mail/lib/Horde/Mail/Mail.php b/framework/Mail/lib/Horde/Mail/Mail.php new file mode 100644 index 000000000..1fdd83c01 --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Mail.php @@ -0,0 +1,149 @@ + + * @author Michael Slusarz + * @copyright 2010 Chuck Hagenbuch + * @copyright 2010 Michael Slusarz + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * Internal PHP-mail() interface. + * + * @category Horde + * @package Mail + */ +class Horde_Mail_Mail extends Horde_Mail_Driver +{ + /** + * Constructor. + * + * @param array $params Additional parameters: + *
+     * 'args' - (string) Extra arguments for the mail() function.
+     * 
+ */ + public function __construct(array $params = array()) + { + $this->_params = array_merge($this->_params, $params); + + /* Because the mail() function may pass headers as command + * line arguments, we can't guarantee the use of the standard + * "\r\n" separator. Instead, we use the system's native line + * separator. */ + $this->sep = defined('PHP_EOL') + ? PHP_EOL + : (strpos(PHP_OS, 'WIN') === false) ? "\n" : "\r\n"; + } + + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + $headers = $this->_sanitizeHeaders($headers); + + // If we're passed an array of recipients, implode it. + if (is_array($recipients)) { + $recipients = array_map('trim', implode(',', $recipients)); + } + + $subject = ''; + + foreach (array_keys($headers) as $hdr) { + if (strcasecmp($hdr, 'Subject') === 0) { + // Get the Subject out of the headers array so that we can + // pass it as a separate argument to mail(). + $subject = $headers[$hdr]; + unset($headers[$hdr]); + } elseif (strcasecmp($hdr, 'To') === 0) { + // Remove the To: header. The mail() function will add its + // own To: header based on the contents of $recipients. + unset($headers[$hdr]); + } + } + + // Flatten the headers out. + list(, $text_headers) = $this->prepareHeaders($headers); + + // mail() requires a string for $body. If resource, need to convert + // to a string. + if (is_resource($body)) { + $body_str = ''; + rewind($body); + while (!feof($body)) { + $body_str .= fread($body, 8192); + } + $body = $body_str; + } + + // We only use mail()'s optional fifth parameter if the additional + // parameters have been provided and we're not running in safe mode. + if (empty($this->_params) || ini_get('safe_mode')) { + $result = mail($recipients, $subject, $body, $text_headers); + } else { + $result = mail($recipients, $subject, $body, $text_headers, isset($this->_params['args']) ? $this->_params['args'] : ''); + } + + // If the mail() function returned failure, we need to create an + // Exception and return it instead of the boolean result. + if ($result === false) { + throw new Horde_Mail_Exception('mail() returned failure.'); + } + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Mock.php b/framework/Mail/lib/Horde/Mail/Mock.php new file mode 100644 index 000000000..6e68c319b --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Mock.php @@ -0,0 +1,138 @@ + + * @copyright 2010 Chuck Hagenbuch + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * Mock implementation, for testing. + * + * @category Horde + * @package Mail + */ +class Horde_Mail_Mock extends Horde_Mail_Driver +{ + /** + * Array of messages that have been sent with the mock. + * + * @var array + */ + public $sentMessages = array(); + + /** + * Callback before sending mail. + * + * @var callback + */ + protected $_preSendCallback; + + /** + * Callback after sending mai. + * + * @var callback + */ + protected $_postSendCallback; + + /** + * Constructor. + * + * @param array Optional parameters: + *
+     * 'preSendCallback' - (callback) Called before an email would be sent.
+     * 'postSendCallback' - (callback) Called after an email would have been
+     *                      sent.
+     * 
+ */ + public function __construct(array $params = array()) + { + if (isset($params['preSendCallback']) && + is_callable($params['preSendCallback'])) { + $this->_preSendCallback = $params['preSendCallback']; + } + + if (isset($params['postSendCallback']) && + is_callable($params['postSendCallback'])) { + $this->_postSendCallback = $params['postSendCallback']; + } + } + + /** + * Send a message. Silently discards all mail. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + if ($this->_preSendCallback) { + call_user_func_array($this->_preSendCallback, array($this, $recipients, $headers, $body)); + } + + $headers = $this->_sanitizeHeaders($headers); + list(, $text_headers) = $this->prepareHeaders($headers); + + $this->sentMessages[] = array( + 'body' => $body, + 'headers' => $headers, + 'header_text' => $text_headers, + 'recipients' => $recipients + ); + + if ($this->_postSendCallback) { + call_user_func_array($this->_postSendCallback, array($this, $recipients, $headers, $body)); + } + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Null.php b/framework/Mail/lib/Horde/Mail/Null.php new file mode 100644 index 000000000..bf03470ea --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Null.php @@ -0,0 +1,77 @@ + + * @copyright 2010 Phil Kernick + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * Null implementation of the mail interface. + * + * @category Horde + * @package Mail + */ +class Horde_Mail_Null extends Horde_Mail_Driver +{ + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Rfc822.php b/framework/Mail/lib/Horde/Mail/Rfc822.php new file mode 100644 index 000000000..2d5b5842e --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Rfc822.php @@ -0,0 +1,921 @@ + + * @author Chuck Hagenbuch + * @author Chuck Hagenbuch + * @category Horde + * @license BSD + * @package Mail + */ +class Horde_Mail_Rfc822 +{ + /** + * The number of groups that have been found in the address list. + * + * @var integer + */ + public $num_groups = 0; + + /** + * The default domain to use for unqualified addresses. + * + * @var string + */ + protected $default_domain = 'localhost'; + + /** + * Should we return a nested array showing groups, or flatten everything? + * + * @var boolean + */ + protected $nestGroups = true; + + /** + * Whether or not to validate atoms for non-ascii characters. + * + * @var boolean + */ + protected $validate = true; + + /** + * The array of raw addresses built up as we parse. + * + * @var array + */ + protected $addresses = array(); + + /** + * The current error message, if any. + * + * @var string + */ + protected $error = null; + + /** + * An internal counter/pointer. + * + * @var integer + */ + protected $index = null; + + /** + * A limit after which processing stops + * + * @var integer + */ + protected $limit = null; + + /** + * Starts the whole process. + * + * @param string $address The address(es) to validate. + * @param array $opts Additional options: + *
+     * 'default_domain' - (string) Default domain/host etc.
+     *                    DEFAULT: localhost
+     * 'limit' - (integer) TODO
+     *           DEFAULT: NONE
+     * 'nest_groups' - (boolean) Whether to return the structure with groups
+     *                 nested for easier viewing.
+     *                 DEFAULT: true
+     * 'validate' - (boolean) Whether to validate atoms.
+     *              DEFAULT: true
+     * 
+ * + * @return array A structured array of addresses. + * @throws Horde_Mail_Exception + */ + public function parseAddressList($address = null, array $opts = array()) + { + if (isset($opts['default_domain'])) { + $this->default_domain = $opts['default_domain']; + } + if (isset($opts['nest_groups'])) { + $this->nestGroups = $opts['nest_groups']; + } + if (isset($opts['validate'])) { + $this->validate = $opts['validate']; + } + if (isset($opts['limit'])) { + $this->limit = $opts['limit']; + } + + $this->addresses = $structure = array(); + $this->error = $this->index = null; + + // Unfold any long lines in $address. + $address = preg_replace(array('/\r?\n/', '/\r\n(\t| )+/'), array("\r\n", ' '), $address); + + while ($address = $this->_splitAddresses($address)); + + if ($address === false || isset($this->error)) { + throw new Horde_Mail_Exception($this->error); + } + + // Validate each address individually. If we encounter an invalid + // address, stop iterating and return an error immediately. + foreach ($this->addresses as $address) { + $valid = $this->_validateAddress($address); + + if ($valid === false || isset($this->error)) { + throw new Horde_Mail_Exception($this->error); + } + + if (!$this->nestGroups) { + $structure = array_merge($structure, $valid); + } else { + $structure[] = $valid; + } + } + + return $structure; + } + + /** + * Splits an address into separate addresses. + * + * @param string $address The addresses to split. + * + * @return boolean Success or failure. + */ + protected function _splitAddresses($address) + { + if (!empty($this->limit) && + (count($this->addresses) == $this->limit)) { + return ''; + } + + if ($this->_isGroup($address) && !isset($this->error)) { + $split_char = ';'; + $is_group = true; + } elseif (!isset($this->error)) { + $split_char = ','; + $is_group = false; + } elseif (isset($this->error)) { + return false; + } + + // Split the string based on the above ten or so lines. + $parts = explode($split_char, $address); + $string = $this->_splitCheck($parts, $split_char); + + if ($is_group) { + // If $string does not contain a colon outside of + // brackets/quotes etc then something's fubar. + + // First check there's a colon at all: + if (strpos($string, ':') === false) { + $this->error = 'Invalid address: ' . $string; + return false; + } + + // Now check it's outside of brackets/quotes: + if (!$this->_splitCheck(explode(':', $string), ':')) { + return false; + } + + // We must have a group at this point, so increase the counter: + ++$this->num_groups; + } + + // $string now contains the first full address/group. + // Add to the addresses array. + $this->addresses[] = array( + 'address' => trim($string), + 'group' => $is_group + ); + + // Remove the now stored address from the initial line, the +1 + // is to account for the explode character. + $address = trim(substr($address, strlen($string) + 1)); + + // If the next char is a comma and this was a group, then + // there are more addresses, otherwise, if there are any more + // chars, then there is another address. + if ($is_group && substr($address, 0, 1) == ','){ + return trim(substr($address, 1)); + } elseif (strlen($address) > 0) { + return $address; + } else { + return ''; + } + + // If you got here then something's off + return false; + } + + /** + * Checks for a group at the start of the string. + * + * @param string $address The address to check. + * + * @return boolean Is there a group at the start of the string? + */ + protected function _isGroup($address) + { + // First comma not in quotes, angles or escaped: + $string = $this->_splitCheck(explode(',', $address), ','); + + // Now we have the first address, we can reliably check for a + // group by searching for a colon that's not escaped or in + // quotes or angle brackets. + if (count($parts = explode(':', $string)) > 1) { + $string2 = $this->_splitCheck($parts, ':'); + return ($string2 !== $string); + } + + return false; + } + + /** + * A common function that will check an exploded string. + * + * @param array $parts The exploded string. + * @param string $char The char that was exploded on. + * + * @return mixed False if the string contains unclosed quotes/brackets, + * or the string on success. + */ + protected function _splitCheck($parts, $char) + { + $string = $parts[0]; + + for ($i = 0; $i < count($parts); ++$i) { + if ($this->_hasUnclosedQuotes($string) || + $this->_hasUnclosedBrackets($string, '<>') || + $this->_hasUnclosedBrackets($string, '[]') || + $this->_hasUnclosedBrackets($string, '()') || + (substr($string, -1) == '\\')) { + if (!isset($parts[$i + 1])) { + $this->error = 'Invalid address spec. Unclosed bracket or quotes'; + return false; + } + + $string = $string . $char . $parts[$i + 1]; + } else { + $this->index = $i; + break; + } + } + + return $string; + } + + /** + * Checks if a string has unclosed quotes or not. + * + * @param string $string The string to check. + * + * @return boolean True if there are unclosed quotes inside the string, + * false otherwise. + */ + protected function _hasUnclosedQuotes($string) + { + $string = trim($string); + $iMax = strlen($string); + $in_quote = false; + $i = $slashes = 0; + + for (; $i < $iMax; ++$i) { + switch ($string[$i]) { + case '\\': + ++$slashes; + break; + + case '"': + if ($slashes % 2 == 0) { + $in_quote = !$in_quote; + } + // Fall through to default action below. + + default: + $slashes = 0; + break; + } + } + + return $in_quote; + } + + /** + * Checks if a string has an unclosed brackets or not. IMPORTANT: + * This function handles both angle brackets and square brackets; + * + * @param string $string The string to check. + * @param string $chars The characters to check for. + * + * @return boolean True if there are unclosed brackets inside the string, + * false otherwise. + */ + protected function _hasUnclosedBrackets($string, $chars) + { + $num_angle_start = substr_count($string, $chars[0]); + $num_angle_end = substr_count($string, $chars[1]); + + $num_angle_start = $this->_hasUnclosedBracketsSub($string, $num_angle_start, $chars[0]); + $num_angle_end = $this->_hasUnclosedBracketsSub($string, $num_angle_end, $chars[1]); + + if ($num_angle_start < $num_angle_end) { + $this->error = 'Invalid address spec. Unmatched quote or bracket (' . $chars . ')'; + return false; + } + + return ($num_angle_start > $num_angle_end); + } + + /** + * Sub function that is used only by hasUnclosedBrackets(). + * + * @param string $string The string to check. + * @param integer $num The number of occurences. + * @param string $char The character to count. + * + * @return integer The number of occurences of $char in $string, adjusted for backslashes. + */ + protected function _hasUnclosedBracketsSub($string, $num, $char) + { + $parts = explode($char, $string); + + for ($i = 0, $p = count($parts); $i < $p; ++$i) { + if ((substr($parts[$i], -1) == '\\') || + $this->_hasUnclosedQuotes($parts[$i])) { + --$num; + } + + if (isset($parts[$i + 1])) { + $parts[$i + 1] = $parts[$i] . $char . $parts[$i + 1]; + } + } + + return $num; + } + + /** + * Function to begin checking the address. + * + * @param string $address The address to validate. + * + * @return mixed False on failure, or a structured array of address + * information on success. + */ + protected function _validateAddress($address) + { + $is_group = false; + $addresses = array(); + + if ($address['group']) { + $is_group = true; + + // Get the group part of the name + $parts = explode(':', $address['address']); + $groupname = $this->_splitCheck($parts, ':'); + $structure = array(); + + // And validate the group part of the name. + if (!$this->_validatePhrase($groupname)){ + $this->error = 'Group name did not validate.'; + return false; + } else { + // Don't include groups if we are not nesting + // them. This avoids returning invalid addresses. + if ($this->nestGroups) { + $structure = new stdClass; + $structure->groupname = $groupname; + } + } + + $address['address'] = ltrim(substr($address['address'], strlen($groupname . ':'))); + } + + // If a group then split on comma and put into an array. + // Otherwise, Just put the whole address in an array. + if ($is_group) { + while (strlen($address['address']) > 0) { + $parts = explode(',', $address['address']); + $addresses[] = $this->_splitCheck($parts, ','); + $address['address'] = trim(substr($address['address'], strlen(end($addresses) . ','))); + } + } else { + $addresses[] = $address['address']; + } + + // Check that $addresses is set, if address like this: + // Groupname:; + // Then errors were appearing. + if (!count($addresses)){ + $this->error = 'Empty group.'; + return false; + } + + // Trim the whitespace from all of the address strings. + array_map('trim', $addresses); + + // Validate each mailbox. + // Format could be one of: name + // geezer@domain.com + // geezer + // ... or any other format valid by RFC 822. + for ($i = 0; $i < count($addresses); $i++) { + if (!$this->validateMailbox($addresses[$i])) { + if (empty($this->error)) { + $this->error = 'Validation failed for: ' . $addresses[$i]; + } + return false; + } + } + + // Nested format + if ($this->nestGroups) { + if ($is_group) { + $structure->addresses = $addresses; + } else { + $structure = $addresses[0]; + } + + // Flat format + } else { + $structure = $is_group + ? array_merge($structure, $addresses) + : $addresses; + } + + return $structure; + } + + /** + * Function to validate a phrase. + * + * @param string $phrase The phrase to check. + * + * @return boolean Success or failure. + */ + protected function _validatePhrase($phrase) + { + // Splits on one or more Tab or space. + $parts = preg_split('/[ \\x09]+/', $phrase, -1, PREG_SPLIT_NO_EMPTY); + + $phrase_parts = array(); + while (count($parts) > 0) { + $phrase_parts[] = $this->_splitCheck($parts, ' '); + for ($i = 0; $i < $this->index + 1; ++$i) { + array_shift($parts); + } + } + + foreach ($phrase_parts as $part) { + // If quoted string: + if (substr($part, 0, 1) == '"') { + if (!$this->_validateQuotedString($part)) { + return false; + } + continue; + } + + // Otherwise it's an atom: + if (!$this->_validateAtom($part)) { + return false; + } + } + + return true; + } + + /** + * Function to validate an atom which from rfc822 is: + * atom = 1* + * + * If validation ($this->validate) has been turned off, then + * validateAtom() doesn't actually check anything. This is so that you + * can split a list of addresses up before encoding personal names + * (umlauts, etc.), for example. + * + * @param string $atom The string to check. + * + * @return boolean Success or failure. + */ + protected function _validateAtom($atom) + { + if (!$this->validate) { + // Validation has been turned off; assume the atom is okay. + return true; + } + + // Check for any char from ASCII 0 - ASCII 127 + if (!preg_match('/^[\\x00-\\x7E]+$/i', $atom, $matches)) { + return false; + } + + // Check for specials: + if (preg_match('/[][()<>@,;\\:". ]/', $atom)) { + return false; + } + + // Check for control characters (ASCII 0-31): + if (preg_match('/[\\x00-\\x1F]+/', $atom)) { + return false; + } + + return true; + } + + /** + * Function to validate quoted string, which is: + * quoted-string = <"> *(qtext/quoted-pair) <"> + * + * @param string $qstring The string to check + * + * @return boolean Success or failure. + */ + protected function _validateQuotedString($qstring) + { + // Leading and trailing " + $qstring = substr($qstring, 1, -1); + + // Perform check, removing quoted characters first. + return !preg_match('/[\x0D\\\\"]/', preg_replace('/\\\\./', '', $qstring)); + } + + /** + * Function to validate a mailbox, which is: + * mailbox = addr-spec ; simple address + * / phrase route-addr ; name and route-addr + * + * @param string &$mailbox The string to check. + * + * @return boolean Success or failure. + */ + public function validateMailbox(&$mailbox) + { + $comment = $phrase = ''; + $comments = array(); + + // Catch any RFC822 comments and store them separately. + $_mailbox = $mailbox; + while (strlen(trim($_mailbox)) > 0) { + $parts = explode('(', $_mailbox); + $before_comment = $this->_splitCheck($parts, '('); + if ($before_comment == $_mailbox) { + break; + } + + // First char should be a (. + $comment = substr(str_replace($before_comment, '', $_mailbox), 1); + $parts = explode(')', $comment); + $comment = $this->_splitCheck($parts, ')'); + $comments[] = $comment; + + // +2 is for the brackets + $_mailbox = substr($_mailbox, strpos($_mailbox, '('.$comment)+strlen($comment)+2); + } + + foreach ($comments as $comment) { + $mailbox = str_replace("($comment)", '', $mailbox); + } + + $mailbox = trim($mailbox); + + // Check for name + route-addr + if ((substr($mailbox, -1) == '>') && + (substr($mailbox, 0, 1) != '<')) { + $parts = explode('<', $mailbox); + $name = $this->_splitCheck($parts, '<'); + + $phrase = trim($name); + $route_addr = trim(substr($mailbox, strlen($name.'<'), -1)); + + if (($this->_validatePhrase($phrase) === false) || + (($route_addr = $this->_validateRouteAddr($route_addr)) === false)) { + return false; + } + + // Only got addr-spec + } else { + // First snip angle brackets if present. + $addr_spec = ((substr($mailbox, 0, 1) == '<') && (substr($mailbox, -1) == '>')) + ? substr($mailbox, 1, -1) + : $mailbox; + + if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) { + return false; + } + } + + // Construct the object that will be returned. + $mbox = new stdClass(); + + // Add the phrase (even if empty) and comments + $mbox->personal = $phrase; + $mbox->comment = isset($comments) + ? $comments + : array(); + + if (isset($route_addr)) { + $mbox->mailbox = $route_addr['local_part']; + $mbox->host = $route_addr['domain']; + $route_addr['adl'] !== '' ? $mbox->adl = $route_addr['adl'] : ''; + } else { + $mbox->mailbox = $addr_spec['local_part']; + $mbox->host = $addr_spec['domain']; + } + + $mailbox = $mbox; + + return true; + } + + /** + * This function validates a route-addr which is: + * route-addr = "<" [route] addr-spec ">" + * + * Angle brackets have already been removed at the point of entering + * this function. + * + * @param string $route_addr The string to check. + * + * @return mixed False on failure, or an array containing validated + * address/route information on success. + */ + protected function _validateRouteAddr($route_addr) + { + // Check for colon. + if (strpos($route_addr, ':') !== false) { + $parts = explode(':', $route_addr); + $route = $this->_splitCheck($parts, ':'); + } else { + $route = $route_addr; + } + + // If $route is same as $route_addr then the colon was in + // quotes or brackets or, of course, non existent. + if ($route === $route_addr){ + unset($route); + $addr_spec = $route_addr; + if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) { + return false; + } + } else { + // Validate route part. + if (($route = $this->_validateRoute($route)) === false) { + return false; + } + + $addr_spec = substr($route_addr, strlen($route . ':')); + + // Validate addr-spec part. + if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) { + return false; + } + } + + $return['adl'] = isset($route) + ? $route + : ''; + + return array_merge($return, $addr_spec); + } + + /** + * Function to validate a route, which is: + * route = 1#("@" domain) ":" + * + * @param string $route The string to check. + * + * @return mixed False on failure, or the validated $route on success. + */ + protected function _validateRoute($route) + { + // Split on comma. + $domains = explode(',', trim($route)); + + foreach ($domains as $domain) { + $domain = str_replace('@', '', trim($domain)); + if (!$this->_validateDomain($domain)) { + return false; + } + } + + return $route; + } + + /** + * Function to validate a domain, though this is not quite what + * you expect of a strict internet domain. + * + * domain = sub-domain *("." sub-domain) + * + * @param string $domain The string to check. + * + * @return mixed False on failure, or the validated domain on success. + */ + protected function _validateDomain($domain) + { + // Note the different use of $subdomains and $sub_domains + $subdomains = explode('.', $domain); + + while (count($subdomains) > 0) { + $sub_domains[] = $this->_splitCheck($subdomains, '.'); + for ($i = 0; $i < $this->index + 1; ++$i) { + array_shift($subdomains); + } + } + + foreach ($sub_domains as $sub_domain) { + if (!$this->_validateSubdomain(trim($sub_domain))) { + return false; + } + } + + // Managed to get here, so return input. + return $domain; + } + + /** + * Function to validate a subdomain: + * subdomain = domain-ref / domain-literal + * + * @param string $subdomain The string to check. + * + * @return boolean Success or failure. + */ + protected function _validateSubdomain($subdomain) + { + return !((preg_match('|^\[(.*)]$|', $subdomain, $arr) && + !$this->_validateDliteral($arr[1])) || + !$this->_validateAtom($subdomain)); + } + + /** + * Function to validate a domain literal: + * domain-literal = "[" *(dtext / quoted-pair) "]" + * + * @param string $dliteral The string to check. + * + * @return boolean Success or failure. + */ + protected function _validateDliteral($dliteral) + { + return !preg_match('/(.)[][\x0D\\\\]/', $dliteral, $matches) && + ($matches[1] != '\\'); + } + + /** + * Function to validate an addr-spec. + * + * addr-spec = local-part "@" domain + * + * @param string $addr_spec The string to check. + * + * @return mixed False on failure, or the validated addr-spec on success. + */ + protected function _validateAddrSpec($addr_spec) + { + $addr_spec = trim($addr_spec); + + // Split on @ sign if there is one. + if (strpos($addr_spec, '@') !== false) { + $parts = explode('@', $addr_spec); + $local_part = $this->_splitCheck($parts, '@'); + $domain = substr($addr_spec, strlen($local_part . '@')); + + // No @ sign so assume the default domain. + } else { + $local_part = $addr_spec; + $domain = $this->default_domain; + } + + if ((($local_part = $this->_validateLocalPart($local_part)) === false) || + (($domain = $this->_validateDomain($domain)) === false)) { + return false; + } + + // Got here so return successful. + return array( + 'domain' => $domain, + 'local_part' => $local_part + ); + } + + /** + * Function to validate the local part of an address: + * local-part = word *("." word) + * + * @param string $local_part TODO + * + * @return mixed False on failure, or the validated local part on + * success. + */ + protected function _validateLocalPart($local_part) + { + $parts = explode('.', $local_part); + $words = array(); + + // Split the local_part into words. + while (count($parts) > 0){ + $words[] = $this->_splitCheck($parts, '.'); + for ($i = 0; $i < $this->index + 1; ++$i) { + array_shift($parts); + } + } + + // Validate each word. + foreach ($words as $word) { + // If this word contains an unquoted space, it is invalid. (6.2.4) + if ((strpos($word, ' ') && ($word[0] !== '"')) || + ($this->_validatePhrase(trim($word)) === false)) { + return false; + } + } + + // Managed to get here, so return the input. + return $local_part; + } + + /** + * Returns an approximate count of how many addresses are in the + * given string. This is APPROXIMATE as it only splits based on a + * comma which has no preceding backslash. Could be useful as + * large amounts of addresses will end up producing *large* + * structures when used with parseAddressList(). + * + * @param string $data Addresses to count. + * + * @return integer Approximate count. + */ + public function approximateCount($data) + { + return count(preg_split('/(?@. This can be sufficient for most + * people. Optional stricter mode can be utilised which restricts + * mailbox characters allowed to alphanumeric, full stop, hyphen + * and underscore. + * + * @param string $data Address to check. + * @param boolean $strict Optional stricter mode. + * + * @return mixed False if it fails, an indexed array username/domain if + * it matches. + */ + public function isValidInetAddress($data, $strict = false) + { + $regex = $strict ? '/^([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})$/i' : '/^([*+!.&#$|\'\\%\/0-9a-z^_`{}=?~:-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})$/i'; + + return preg_match($regex, trim($data), $matches) + ? array($matches[1], $matches[2]) + : false; + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Sendmail.php b/framework/Mail/lib/Horde/Mail/Sendmail.php new file mode 100644 index 000000000..08c01a39b --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Sendmail.php @@ -0,0 +1,247 @@ + + * @author Michael Slusarz + * @copyright 2010 Chuck Hagenbuch + * @copyright 2010 Michael Slusarz + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * Sendmail interface. + * + * @category Horde + * @package Mail + */ +class Horde_Mail_Sendmail extends Horde_Mail_Driver +{ + /** + * Any extra command-line parameters to pass to the sendmail or + * sendmail wrapper binary. + * + * @var string + */ + protected $_sendmailArgs = '-i'; + + /** + * The location of the sendmail or sendmail wrapper binary on the + * filesystem. + * + * @var string + */ + protected $_sendmailPath = '/usr/sbin/sendmail'; + + /** + * Constructor. + * + * @param array $params Additional parameters: + *
+     * 'sendmail_args' - (string) Any extra parameters to pass to the sendmail
+     *                   or sendmail wrapper binary.
+     *                   DEFAULT: -i
+     * 'sendmail_path' - (string) The location of the sendmail binary on the
+     *                   filesystem.
+     *                   DEFAULT: /usr/sbin/sendmail
+     * 
+ */ + public function __construct(array $params = array()) + { + if (isset($params['sendmail_args'])) { + $this->_sendmailArgs = $params['sendmail_args']; + } + + if (isset($params['sendmail_path'])) { + $this->_sendmailPath = $params['sendmail_path']; + } + + /* Because we need to pass message headers to the sendmail program on + * the commandline, we can't guarantee the use of the standard "\r\n" + * separator. Instead, we use the system's native line separator. */ + $this->sep = defined('PHP_EOL') + ? PHP_EOL + : (strpos(PHP_OS, 'WIN') === false) ? "\n" : "\r\n"; + } + + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + $recipients = implode(' ', array_map('escapeshellarg', $this->parseRecipients($recipients))); + + $headers = $this->_sanitizeHeaders($headers); + list($from, $text_headers) = $this->prepareHeaders($headers); + + /* Since few MTAs are going to allow this header to be forged + * unless it's in the MAIL FROM: exchange, we'll use Return-Path + * instead of From: if it's set. */ + foreach (array_keys($headers) as $hdr) { + if (strcasecmp($hdr, 'Return-Path') === 0) { + $from = $headers[$hdr]; + break; + } + } + + if (!strlen($from)) { + throw new Horde_Mail_Exception('No From address given.'); + } elseif ((strpos($from, ' ') !== false) || + (strpos($from, ';') !== false) || + (strpos($from, '&') !== false) || + (strpos($from, '`') !== false)) { + throw new Horde_Mail_Exception('From address specified with dangerous characters.'); + } + + $mail = @popen($this->_sendmailPath . (empty($this->_sendmailArgs) ? '' : ' ' . $this->_sendmailargs) . ' -f' . escapeshellarg($from) . ' -- ' . $recipients, 'w'); + if (!$mail) { + throw new Horde_Mail_Exception('Failed to open sendmail [' . $this->_sendmailPath . '] for execution.'); + } + + // Write the headers following by two newlines: one to end the headers + // section and a second to separate the headers block from the body. + fputs($mail, $text_headers . $this->sep . $this->sep); + + if (is_resource($body)) { + rewind($body); + while (!feof($body)) { + fputs($mail, fread($body, 8192)); + } + } else { + fputs($mail, $body); + } + $result = pclose($mail); + + if (!$result) { + return; + } + + switch ($result) { + case 64: // EX_USAGE + $msg = 'command line usage error'; + break; + + case 65: // EX_DATAERR + $msg = 'data format error'; + break; + + case 66: // EX_NOINPUT + $msg = 'cannot open input'; + break; + + case 67: // EX_NOUSER + $msg = 'addressee unknown'; + break; + + case 68: // EX_NOHOST + $msg = 'host name unknown'; + break; + + case 69: // EX_UNAVAILABLE + $msg = 'service unavailable'; + break; + + case 70: // EX_SOFTWARE + $msg = 'internal software error'; + break; + + case 71: // EX_OSERR + $msg = 'system error'; + break; + + case 72: // EX_OSFILE + $msg = 'critical system file missing'; + break; + + case 73: // EX_CANTCREAT + $msg = 'cannot create output file'; + break; + + case 74: // EX_IOERR + $msg = 'input/output error'; + + case 75: // EX_TEMPFAIL + $msg = 'temporary failure'; + break; + + case 76: // EX_PROTOCOL + $msg = 'remote error in protocol'; + break; + + case 77: // EX_NOPERM + $msg = 'permission denied'; + break; + + case 77: // EX_NOPERM + $msg = 'permission denied'; + break; + + case 78: // EX_CONFIG + $msg = 'configuration error'; + break; + + case 79: // EX_NOTFOUND + $msg = 'entry not found'; + break; + + default: + $msg = 'unknown error'; + break; + } + + throw new Horde_Mail_Exception('sendmail: ' . $msg . ' (' . $result . ')', $result); + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Smtp.php b/framework/Mail/lib/Horde/Mail/Smtp.php new file mode 100644 index 000000000..58b0d833c --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Smtp.php @@ -0,0 +1,356 @@ + + * @author Chuck Hagenbuch + * @copyright 2010 Chuck Hagenbuch + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * SMTP implementation. + * + * @category Horde + * @package Mail + */ +class Horde_Mail_Smtp extends Horde_Mail_Driver +{ + /* Error: Failed to create a Net_SMTP object */ + const ERROR_CREATE = 10000; + + /* Error: Failed to connect to SMTP server */ + const ERROR_CONNECT = 10001; + + /* Error: SMTP authentication failure */ + const ERROR_AUTH = 10002; + + /* Error: No From: address has been provided */ + const ERROR_FROM = 10003; + + /* Error: Failed to set sender */ + const ERROR_SENDER = 10004; + + /* Error: Failed to add recipient */ + const ERROR_RECIPIENT = 10005; + + /* Error: Failed to send data */ + const ERROR_DATA = 10006; + + /** + * The SMTP greeting. + * + * @var string + */ + public $greeting = null; + + /** + * The SMTP queued response. + * + * @var string + */ + public $queuedAs = null; + + /** + * SMTP connection object. + * + * @var Net_SMTP + */ + protected $_smtp = null; + + /** + * The list of service extension parameters to pass to the Net_SMTP + * mailFrom() command. + * + * @var array + */ + protected $_extparams = array(); + + /** + * Constructor. + * + * @param array $params Additional parameters: + *
+     * 'auth' - (mixed) SMTP authentication.
+     *          This value may be set to true, false or the name of a specific
+     *          authentication method.
+     *          If the value is set to true, the Net_SMTP package will attempt
+     *          to use the best authentication method advertised by the remote
+     *          SMTP server.
+     *          DEFAULT: false.
+     * 'debug' - (boolean) Activate SMTP debug mode?
+     *           DEFAULT: false
+     * 'host' - (string) The server to connect to.
+     *          DEFAULT: localhost
+     * 'localhost' - (string) Hostname or domain that will be sent to the
+     *               remote SMTP server in the HELO / EHLO message.
+     *               DEFAULT: localhost
+     * 'password' - (string) The password to use for SMTP auth.
+     *              DEFAULT: NONE
+     * 'persist' - (boolean) Should the SMTP connection persist?
+     *             DEFAULT: false
+     * 'pipelining' - (boolean) Use SMTP command pipelining.
+     *                Use SMTP command pipelining (specified in RFC 2920) if
+     *                the SMTP server supports it. This speeds up delivery
+     *                over high-latency connections.
+     *                DEFAULT: false (use default value from Net_SMTP)
+     * 'port' - (integer) The port to connect to.
+     *          DEFAULT: 25
+     * 'timeout' - (integer) The SMTP connection timeout.
+     *             DEFAULT: NONE
+     * 'username' - (string) The username to use for SMTP auth.
+     *              DEFAULT: NONE
+     * 
+ */ + public function __construct(array $params = array()) + { + $this->_params = array_merge(array( + 'auth' => false, + 'debug' => false, + 'host' => 'localhost', + 'localhost' => 'localhost', + 'password' => '', + 'persist' => false, + 'pipelining' => false, + 'port' => 25, + 'timeout' => null, + 'username' => '' + ), $params); + + /* Destructor implementation to ensure that we disconnect from any + * potentially-alive persistent SMTP connections. */ + register_shutdown_function(array($this, 'disconnect')); + } + + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + /* If we don't already have an SMTP object, create one. */ + $this->getSMTPObject(); + + $headers = $this->_sanitizeHeaders($headers); + + try { + list($from, $textHeaders) = $this->prepareHeaders($headers); + } catch (Horde_Mail_Exception $e) { + $this->_smtp->rset(); + throw $e; + } + + /* Since few MTAs are going to allow this header to be forged unless + * it's in the MAIL FROM: exchange, we'll use Return-Path instead of + * From: if it's set. */ + foreach (array_keys($headers) as $hdr) { + if (strcasecmp($hdr, 'Return-Path') === 0) { + $from = $headers[$hdr]; + break; + } + } + + if (!strlen($from)) { + $this->_smtp->rset(); + throw new Horde_Mail_Exception('No From: address has been provided', self::ERROR_FROM); + } + + $params = ''; + foreach ($this->_extparams as $key => $val) { + $params .= ' ' . $key . (is_null($val) ? '' : '=' . $val); + } + + $res = $this->_smtp->mailFrom($from, ltrim($params)); + if ($res instanceof PEAR_Error) { + $this->_smtp->rset(); + $this->_error("Failed to set sender: $from", $res, self::ERROR_SENDER); + } + + try { + $recipients = $this->parseRecipients($recipients); + } catch (Horde_Mail_Exception $e) { + $this->_smtp->rset(); + throw $e; + } + + foreach ($recipients as $recipient) { + $res = $this->_smtp->rcptTo($recipient); + if ($res instanceof PEAR_Error) { + $this->_smtp->rset(); + $this->_error("Failed to add recipient: $recipient", $res, self::ERROR_RECIPIENT); + } + } + + /* Send the message's headers and the body as SMTP data. */ + $res = $this->_smtp->data($body, $textHeaders); + list(,$args) = $this->_smtp->getResponse(); + + if (preg_match("/Ok: queued as (.*)/", $args, $queued)) { + $this->queuedAs = $queued[1]; + } + + /* We need the greeting; from it we can extract the authorative name + * of the mail server we've really connected to. Ideal if we're + * connecting to a round-robin of relay servers and need to track + * which exact one took the email */ + $this->greeting = $this->_smtp->getGreeting(); + + if ($res instanceof PEAR_Error) { + $this->_smtp->rset(); + $this->_error('Failed to send data', $res, self::ERROR_DATA); + } + + /* If persistent connections are disabled, destroy our SMTP object. */ + if ($this->_params['persist']) { + $this->disconnect(); + } + } + + /** + * Connect to the SMTP server by instantiating a Net_SMTP object. + * + * @return Net_SMTP The SMTP object. + * @throws Horde_Mail_Exception + */ + public function getSMTPObject() + { + if ($this->_smtp) { + return $this->_smtp; + } + + $this->_smtp = new Net_SMTP( + $this->_params['host'], + $this->_params['port'], + $this->_params['localhost'] + ); + + /* If we still don't have an SMTP object at this point, fail. */ + if (!($this->_smtp instanceof Net_SMTP)) { + throw new Horde_Mail_Exception('Failed to create a Net_SMTP object', self::ERROR_CREATE); + } + + /* Configure the SMTP connection. */ + if ($this->_params['debug']) { + $this->_smtp->setDebug(true); + } + + /* Attempt to connect to the configured SMTP server. */ + $res = $this->_smtp->connect($this->_params['timeout']); + if ($res instanceof PEAR_Error) { + $this->_error('Failed to connect to ' . $this->_params['host'] . ':' . $this->_params['port'], $res, self::ERROR_CONNECT); + } + + /* Attempt to authenticate if authentication has been enabled. */ + if ($this->_params['auth']) { + $method = is_string($this->_params['auth']) + ? $this->_params['auth'] + : ''; + + $res = $this->_smtp->auth($this->_params['username'], $this->_params['password'], $method); + if ($res instanceof PEAR_Error) { + $this->_smtp->rset(); + $this->_error("$method authentication failure", $res, self::ERROR_AUTH); + } + } + + return $this->_smtp; + } + + /** + * Add parameter associated with a SMTP service extension. + * + * @param string $keyword Extension keyword. + * @param string $value Any value the keyword needs. + */ + public function addServiceExtensionParameter($keyword, $value = null) + { + $this->_extparams[$keyword] = $value; + } + + /** + * Disconnect and destroy the current SMTP connection. + * + * @return boolean True if the SMTP connection no longer exists. + */ + public function disconnect() + { + /* If we have an SMTP object, disconnect and destroy it. */ + if (is_object($this->_smtp) && $this->_smtp->disconnect()) { + $this->_smtp = null; + } + + /* We are disconnected if we no longer have an SMTP object. */ + return ($this->_smtp === null); + } + + /** + * Build a standardized string describing the current SMTP error. + * + * @param string $text Custom string describing the error context. + * @param PEAR_Error $error PEAR_Error object. + * @param integer $e_code Error code. + * + * @throws Horde_Mail_Exception + */ + protected function _error($text, $error, $e_code) + { + /* Split the SMTP response into a code and a response string. */ + list($code, $response) = $this->_smtp->getResponse(); + + /* Build our standardized error string. */ + throw new Horde_Mail_Exception($text . ' [SMTP: ' . $error->getMessage() . " (code: $code, response: $response)]", $e_code); + } + +} diff --git a/framework/Mail/lib/Horde/Mail/Smtpmx.php b/framework/Mail/lib/Horde/Mail/Smtpmx.php new file mode 100644 index 000000000..45966a9fc --- /dev/null +++ b/framework/Mail/lib/Horde/Mail/Smtpmx.php @@ -0,0 +1,389 @@ + + * @copyright 2010 gERD Schaufelberger + * @license http://opensource.org/licenses/bsd-license.php New BSD License + */ + +/** + * SMTP MX implementation. + * + * @author gERD Schaufelberger + * @category Horde + * @package Mail + */ +class Horde_Mail_Smtpmx extends Horde_Mail_Driver +{ + /** + * SMTP connection object. + * + * @var Net_SMTP + */ + protected $_smtp = null; + + /** + * Net_DNS_Resolver object. + * + * @var Net_DNS_Resolver + */ + protected $_resolver; + + /** + * Internal error codes. + * Translate internal error identifier to human readable messages. + * + * @var array + */ + protected $_errorCode = array( + 'not_connected' => array( + 'code' => 1, + 'msg' => 'Could not connect to any mail server ({HOST}) at port {PORT} to send mail to {RCPT}.' + ), + 'failed_vrfy_rcpt' => array( + 'code' => 2, + 'msg' => 'Recipient "{RCPT}" could not be veryfied.' + ), + 'failed_set_from' => array( + 'code' => 3, + 'msg' => 'Failed to set sender: {FROM}.' + ), + 'failed_set_rcpt' => array( + 'code' => 4, + 'msg' => 'Failed to set recipient: {RCPT}.' + ), + 'failed_send_data' => array( + 'code' => 5, + 'msg' => 'Failed to send mail to: {RCPT}.' + ), + 'no_from' => array( + 'code' => 5, + 'msg' => 'No from address has be provided.' + ), + 'send_data' => array( + 'code' => 7, + 'msg' => 'Failed to create Net_SMTP object.' + ), + 'no_mx' => array( + 'code' => 8, + 'msg' => 'No MX-record for {RCPT} found.' + ), + 'no_resolver' => array( + 'code' => 9, + 'msg' => 'Could not start resolver! Install PEAR:Net_DNS or switch off "netdns"' + ), + 'failed_rset' => array( + 'code' => 10, + 'msg' => 'RSET command failed, SMTP-connection corrupt.' + ) + ); + + /** + * Constructor. + * + * @param array $params Additional options: + *
+     * 'debug' - (boolean) Activate SMTP and Net_DNS debug mode?
+     *           DEFAULT: false
+     * 'mailname' - (string) The name of the local mail system (a valid
+     *              hostname which matches the reverse lookup)
+     *              DEFAULT: Auto-determined
+     * 'netdns' - (boolean) Use PEAR:Net_DNS (true) or the PHP builtin
+     *            getmxrr().
+     *            DEFAULT: true
+     * 'port' - (integer) Port.
+     *          DEFAULT: Auto-determined
+     * 'test' - (boolean) Activate test mode?
+     *          DEFAULT: false
+     * 'timeout' - (integer) The SMTP connection timeout (in seconds).
+     *             DEFAULT: 10
+     * 'verp' - (boolean) Whether to use VERP.
+     *          If not a boolean, the string value will be used as the VERP
+     *          separators.
+     *          DEFAULT: false
+     * 'vrfy' - (boolean) Whether to use VRFY.
+     *          DEFAULT: false
+     * 
+ */ + public function __construct(array $params = array()) + { + /* Try to find a valid mailname. */ + if (!isset($params['mailname']) && function_exists('posix_uname')) { + $uname = posix_uname(); + $params['mailname'] = $uname['nodename']; + } + + if (!isset($params['port'])) { + $params['port'] = getservbyname('smtp', 'tcp'); + } + + $this->_params = array_merge(array( + 'debug' => false, + 'mailname' => 'localhost', + 'netdns' => true, + 'port' => 25, + 'test' => false, + 'timeout' => 10, + 'verp' => false, + 'vrfy' => false + ), $params); + } + + /** + * Destructor implementation to ensure that we disconnect from any + * potentially-alive persistent SMTP connections. + */ + public function __destruct() + { + if (is_object($this->_smtp)) { + $this->_smtp->disconnect(); + $this->_smtp = null; + } + } + + /** + * Send a message. + * + * @param mixed $recipients Either a comma-seperated list of recipients + * (RFC822 compliant), or an array of + * recipients, each RFC822 valid. This may + * contain recipients not specified in the + * headers, for Bcc:, resending messages, etc. + * @param array $headers The headers to send with the mail, in an + * associative array, where the array key is the + * header name (ie, 'Subject'), and the array + * value is the header value (ie, 'test'). The + * header produced from those values would be + * 'Subject: test'. + * If the '_raw' key exists, the value of this + * key will be used as the exact text for + * sending the message. + * @param mixed $body The full text of the message body, including + * any Mime parts, etc. Either a string or a + * stream resource. + * + * @throws Horde_Mail_Exception + */ + public function send($recipients, array $headers, $body) + { + $headers = $this->_sanitizeHeaders($headers); + + // Prepare headers + list($from, $textHeaders) = $this->prepareHeaders($headers); + + // Use 'Return-Path' if possible + foreach (array_keys($headers) as $hdr) { + if (strcasecmp($hdr, 'Return-Path') === 0) { + $from = $headers['Return-Path']; + break; + } + } + + if (!strlen($from)) { + $this->_error('no_from'); + } + + // Prepare recipients + foreach ($this->parseRecipients($recipients) as $rcpt) { + list($user, $host) = explode('@', $rcpt); + + $mx = $this->_getMx($host); + if (!$mx) { + $this->_error('no_mx', array('rcpt' => $rcpt)); + } + + $connected = false; + foreach ($mx as $mserver => $mpriority) { + $this->_smtp = new Net_SMTP($mserver, $this->_params['port'], $this->_params['mailname']); + + // configure the SMTP connection. + if ($this->_params['debug']) { + $this->_smtp->setDebug(true); + } + + // attempt to connect to the configured SMTP server. + $res = $this->_smtp->connect($this->_params['timeout']); + if ($res instanceof PEAR_Error) { + $this->_smtp = null; + continue; + } + + // connection established + if ($res) { + $connected = true; + break; + } + } + + if (!$connected) { + $this->_error('not_connected', array( + 'host' => implode(', ', array_keys($mx)), + 'port' => $this->_params['port'], + 'rcpt' => $rcpt + )); + } + + // Verify recipient + if ($this->_params['vrfy']) { + $res = $this->_smtp->vrfy($rcpt); + if ($res instanceof PEAR_Error) { + $this->_error('failed_vrfy_rcpt', array('rcpt' => $rcpt)); + } + } + + // mail from: + $args['verp'] = $this->_params['verp']; + $res = $this->_smtp->mailFrom($from, $args); + if ($res instanceof PEAR_Error) { + $this->_error('failed_set_from', array('from' => $from)); + } + + // rcpt to: + $res = $this->_smtp->rcptTo($rcpt); + if ($res instanceof PEAR_Error) { + $this->_error('failed_set_rcpt', array('rcpt' => $rcpt)); + } + + // Don't send anything in test mode + if ($this->_params['test']) { + $res = $this->_smtp->rset(); + if ($res instanceof PEAR_Error) { + $this->_error('failed_rset'); + } + + $this->_smtp->disconnect(); + $this->_smtp = null; + return; + } + + // Send data + $res = $this->_smtp->data($body, $textHeaders); + if ($res instanceof PEAR_Error) { + $this->_error('failed_send_data', array('rcpt' => $rcpt)); + } + + $this->_smtp->disconnect(); + $this->_smtp = null; + } + } + + /** + * Recieve MX records for a host. + * + * @param string $host Mail host. + * + * @return mixed Sorted MX list or false on error. + */ + protected function _getMx($host) + { + $mx = array(); + + if ($this->params['netdns']) { + $this->_loadNetDns(); + + $response = $this->_resolver->query($host, 'MX'); + if (!$response) { + return false; + } + + foreach ($response->answer as $rr) { + if ($rr->type == 'MX') { + $mx[$rr->exchange] = $rr->preference; + } + } + } else { + $mxHost = $mxWeight = array(); + + if (!getmxrr($host, $mxHost, $mxWeight)) { + return false; + } + + for ($i = 0; $i < count($mxHost); ++$i) { + $mx[$mxHost[$i]] = $mxWeight[$i]; + } + } + + asort($mx); + + return $mx; + } + + /** + * Initialize Net_DNS_Resolver. + */ + protected function _loadNetDns() + { + if (!$this->_resolver) { + if (!class_exists('Net_DNS_Resolver')) { + $this->_error('no_resolver'); + } + + $this->_resolver = new Net_DNS_Resolver(); + if ($this->_params['debug']) { + $this->_resolver->test = 1; + } + } + } + + /** + * Format error message. + * + * @param string $id Maps error ids to codes and message. + * @param array $info Optional information in associative array. + * + * @throws Horde_Mail_Exception + */ + protected function _error($id, $info = array()) + { + $msg = $this->_errorCode[$id]['msg']; + + // include info to messages + if (!empty($info)) { + $replace = $search = array(); + + foreach ($info as $key => $value) { + $search[] = '{' . strtoupper($key) . '}'; + $replace[] = $value; + } + + $msg = str_replace($search, $replace, $msg); + } + + throw new Horde_Mail_Exception($msg, $this->_errorCode[$id]['code']); + } + +} diff --git a/framework/Mail/package.xml b/framework/Mail/package.xml new file mode 100644 index 000000000..835cd21c8 --- /dev/null +++ b/framework/Mail/package.xml @@ -0,0 +1,101 @@ + + + Mail + pear.horde.org + Horde Mail Library + The Horde_Mail:: library is a fork of the PEAR Mail library that provides additional functionality, including (but not limited to): + * Allows a stream to be passed in. + * Allows raw headertext to be used in the outgoing messages (required for + things like message redirection pursuant to RFC 5322 [3.6.6]). + * Native PHP 5 code. + * PHPUnit test suite. + * Provides more comprehensive sendmail error messages. + * Uses Exceptions instead of PEAR_Errors. + + + Michael Slusarz + slusarz + slusarz@horde.org + yes + + 2010-05-11 + + 0.1.0 + 0.1.0 + + + beta + beta + + BSD + * Initial Horde Release. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5.2.0 + + + 1.7.0 + + + Exception + pear.horde.org + + + + + Net_SMTP + pear.php.net + 1.4.0 + + + + + + + + + + + + + + + + + + + + diff --git a/framework/Mail/test/Horde/Mail/AllTests.php b/framework/Mail/test/Horde/Mail/AllTests.php new file mode 100644 index 000000000..4b1be36f4 --- /dev/null +++ b/framework/Mail/test/Horde/Mail/AllTests.php @@ -0,0 +1,36 @@ + + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Mail + * @subpackage UnitTests + */ + +/** + * Define the main method + */ +if (!defined('PHPUnit_MAIN_METHOD')) { + define('PHPUnit_MAIN_METHOD', 'Horde_Mail_AllTests::main'); +} + +/** + * Prepare the test setup. + */ +require_once 'Horde/Test/AllTests.php'; + +/** + * @package Mail + * @subpackage UnitTests + */ +class Horde_Mail_AllTests extends Horde_Test_AllTests +{ +} + +Horde_Mail_AllTests::init('Horde_Mail', __FILE__); + +if (PHPUnit_MAIN_METHOD == 'Horde_Mail_AllTests::main') { + Horde_Mail_AllTests::main(); +} diff --git a/framework/Mail/test/Horde/Mail/ParseTest.php b/framework/Mail/test/Horde/Mail/ParseTest.php new file mode 100644 index 000000000..f095c1c26 --- /dev/null +++ b/framework/Mail/test/Horde/Mail/ParseTest.php @@ -0,0 +1,192 @@ + + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Mail + * @subpackage UnitTests + */ + +class Horde_Mail_ParseTest extends PHPUnit_Framework_TestCase +{ + /* Test case for PEAR Mail:: bug #13659 */ + public function testParseBug13659() + { + $address = '"Test Student" (test)'; + + $parser = new Horde_Mail_Rfc822(); + $result = $parser->parseAddressList($address, array( + 'default_domain' => 'anydomain.com' + )); + + $this->assertTrue(is_array($result) && + is_object($result[0]) && + ($result[0]->personal == '"Test Student"') && + ($result[0]->mailbox == "test") && + ($result[0]->host == "mydomain.com") && + is_array($result[0]->comment) && + ($result[0]->comment[0] == 'test')); + } + + /* Test case for PEAR Mail:: bug #9137 */ + public function testParseBug9137() + { + $addresses = array( + array('name' => 'John Doe', 'email' => 'test@example.com'), + array('name' => 'John Doe\\', 'email' => 'test@example.com'), + array('name' => 'John "Doe', 'email' => 'test@example.com'), + array('name' => 'John "Doe\\', 'email' => 'test@example.com'), + ); + + $parser = new Horde_Mail_Rfc822(); + + foreach ($addresses as $val) { + $address = + '"' . addslashes($val['name']) . '" <' . $val['email'] . '>'; + + /* Throws Exception on error. */ + $parser->parseAddressList($address); + } + } + + /* Test case for PEAR Mail:: bug #9137, take 2 */ + public function testParseBug9137Take2() + { + $addresses = array( + array( + 'raw' => '"John Doe" ' + ), + array( + 'raw' => '"John Doe' . chr(92) . '" ', + 'fail' => true + ), + array( + 'raw' => '"John Doe' . chr(92) . chr(92) . '" ' + ), + array( + 'raw' => '"John Doe' . chr(92) . chr(92) . chr(92) . '" ', + 'fail' => true + ), + array( + 'raw' => '"John Doe' . chr(92) . chr(92) . chr(92) . chr(92) . '" ' + ), + array( + 'raw' => '"John Doe ', + 'fail' => true + ) + ); + + $parser = new Horde_Mail_Rfc822(); + + foreach ($addresses as $val) { + try { + $parser->parseAddressList($val['raw']); + if (!empty($val['fail'])) { + $this->fail('An expected exception was not raised.'); + } + } catch (Horde_Mail_Exception $e) { + if (empty($val['fail'])) { + $this->fail('An unexpected exception was raised.'); + } + } + } + } + + public function testGeneralParsing() + { + $parser = new Horde_Mail_Rfc822(); + + /* A simple, bare address. */ + $address = 'user@example.com'; + $result = $parser->parseAddressList($address, array( + 'default_domain' => null + )); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]); + $this->assertEquals($result[0]->personal, ''); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->comment); + $this->assertEquals($result[0]->comment, array()); + $this->assertEquals($result[0]->mailbox, 'user'); + $this->assertEquals($result[0]->host, 'example.com'); + + /* Address groups. */ + $address = 'My Group: "Richard" (A comment), ted@example.com (Ted Bloggs), Barney;'; + $result = $parser->parseAddressList($address, array( + 'default_domain' => null + )); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]); + $this->assertEquals($result[0]->groupname, 'My Group'); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->addresses); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]->addresses[0]); + $this->assertEquals($result[0]->addresses[0]->personal, '"Richard"'); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->addresses[0]->comment); + $this->assertEquals($result[0]->addresses[0]->comment[0], 'A comment'); + $this->assertEquals($result[0]->addresses[0]->mailbox, 'richard'); + $this->assertEquals($result[0]->addresses[0]->host, 'localhost'); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]->addresses[1]); + $this->assertEquals($result[0]->addresses[1]->personal, ''); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->addresses[1]->comment); + $this->assertEquals($result[0]->addresses[1]->comment[0], 'Ted Bloggs'); + $this->assertEquals($result[0]->addresses[1]->mailbox, 'ted'); + $this->assertEquals($result[0]->addresses[1]->host, 'example.com'); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]->addresses[2]); + $this->assertEquals($result[0]->addresses[2]->personal, ''); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->addresses[2]->comment); + $this->assertEquals($result[0]->addresses[2]->comment, array()); + $this->assertEquals($result[0]->addresses[2]->mailbox, 'Barney'); + $this->assertEquals($result[0]->addresses[2]->host, 'localhost'); + + /* A valid address with spaces in the local part. */ + $address = '<"Jon Parise"@php.net>'; + $result = $parser->parseAddressList($address, array( + 'default_domain' => null + )); + + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_OBJECT, $result[0]); + $this->assertEquals($result[0]->personal, ''); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $result[0]->comment); + $this->assertEquals($result[0]->comment, array()); + $this->assertEquals($result[0]->mailbox, '"Jon Parise"'); + $this->assertEquals($result[0]->host, 'php.net'); + + /* An invalid address with spaces in the local part. */ + $address = ''; + try { + $parser->parseAddressList($address, array( + 'default_domain' => null + )); + $this->fail('An expected exception was not raised.'); + } catch (Horde_Mail_Exception $e) {} + + /* A valid address with an uncommon TLD. */ + $address = 'jon@host.longtld'; + try { + $parser->parseAddressList($address, array( + 'default_domain' => null + )); + } catch (Horde_Mail_Exception $e) { + $this->fail('An unexpected exception was raised.'); + } + } + + public function testValidateQuotedString() + { + $address_string = '"Joe Doe \(from Somewhere\)" , postmaster@example.com, root'; + + $parser = new Horde_Mail_Rfc822(); + + $res = $parser->parseAddressList($address_string, array( + 'default_domain' => 'example.com' + )); + $this->assertType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY, $res); + $this->assertEquals(count($res), 3); + } + +}