diff options
Diffstat (limited to 'program/lib/Roundcube')
41 files changed, 29640 insertions, 0 deletions
diff --git a/program/lib/Roundcube/bootstrap.php b/program/lib/Roundcube/bootstrap.php new file mode 100644 index 000000000..eed7db8c1 --- /dev/null +++ b/program/lib/Roundcube/bootstrap.php @@ -0,0 +1,493 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/bootstrap.php | + | | + | This file is part of the Roundcube PHP suite | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | CONTENTS: | + | Roundcube Framework Initialization | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Roundcube Framework Initialization + * + * @package Framework + * @subpackage Core + */ + +$config = array( + 'error_reporting' => E_ALL &~ (E_NOTICE | E_STRICT), + // Some users are not using Installer, so we'll check some + // critical PHP settings here. Only these, which doesn't provide + // an error/warning in the logs later. See (#1486307). + 'mbstring.func_overload' => 0, + 'suhosin.session.encrypt' => 0, + 'session.auto_start' => 0, + 'file_uploads' => 1, + 'magic_quotes_runtime' => 0, + 'magic_quotes_sybase' => 0, // #1488506 +); +foreach ($config as $optname => $optval) { + if ($optval != ini_get($optname) && @ini_set($optname, $optval) === false) { + die("ERROR: Wrong '$optname' option value and it wasn't possible to set it to required value ($optval).\n" + ."Check your PHP configuration (including php_admin_flag)."); + } +} + +// framework constants +define('RCUBE_VERSION', '0.9-git'); +define('RCUBE_CHARSET', 'UTF-8'); + +if (!defined('RCUBE_LIB_DIR')) { + define('RCUBE_LIB_DIR', dirname(__FILE__).'/'); +} + +if (!defined('RCUBE_INSTALL_PATH')) { + define('RCUBE_INSTALL_PATH', RCUBE_LIB_DIR); +} + +if (!defined('RCUBE_CONFIG_DIR')) { + define('RCUBE_CONFIG_DIR', RCUBE_INSTALL_PATH . 'config/'); +} + +if (!defined('RCUBE_PLUGINS_DIR')) { + define('RCUBE_PLUGINS_DIR', RCUBE_INSTALL_PATH . 'plugins/'); +} + +if (!defined('RCUBE_LOCALIZATION_DIR')) { + define('RCUBE_LOCALIZATION_DIR', RCUBE_INSTALL_PATH . 'localization/'); +} + +// set internal encoding for mbstring extension +if (extension_loaded('mbstring')) { + mb_internal_encoding(RCUBE_CHARSET); + @mb_regex_encoding(RCUBE_CHARSET); +} + +// Register autoloader +spl_autoload_register('rcube_autoload'); + +// set PEAR error handling (will also load the PEAR main class) +PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, 'rcube_pear_error'); + + + +/** + * Similar function as in_array() but case-insensitive + * + * @param string $needle Needle value + * @param array $heystack Array to search in + * + * @return boolean True if found, False if not + */ +function in_array_nocase($needle, $haystack) +{ + $needle = mb_strtolower($needle); + foreach ((array)$haystack as $value) { + if ($needle === mb_strtolower($value)) { + return true; + } + } + + return false; +} + + +/** + * Parse a human readable string for a number of bytes. + * + * @param string $str Input string + * + * @return float Number of bytes + */ +function parse_bytes($str) +{ + if (is_numeric($str)) { + return floatval($str); + } + + if (preg_match('/([0-9\.]+)\s*([a-z]*)/i', $str, $regs)) { + $bytes = floatval($regs[1]); + switch (strtolower($regs[2])) { + case 'g': + case 'gb': + $bytes *= 1073741824; + break; + case 'm': + case 'mb': + $bytes *= 1048576; + break; + case 'k': + case 'kb': + $bytes *= 1024; + break; + } + } + + return floatval($bytes); +} + + +/** + * Make sure the string ends with a slash + */ +function slashify($str) +{ + return unslashify($str).'/'; +} + + +/** + * Remove slashes at the end of the string + */ +function unslashify($str) +{ + return preg_replace('/\/+$/', '', $str); +} + + +/** + * Returns number of seconds for a specified offset string. + * + * @param string $str String representation of the offset (e.g. 20min, 5h, 2days, 1week) + * + * @return int Number of seconds + */ +function get_offset_sec($str) +{ + if (preg_match('/^([0-9]+)\s*([smhdw])/i', $str, $regs)) { + $amount = (int) $regs[1]; + $unit = strtolower($regs[2]); + } + else { + $amount = (int) $str; + $unit = 's'; + } + + switch ($unit) { + case 'w': + $amount *= 7; + case 'd': + $amount *= 24; + case 'h': + $amount *= 60; + case 'm': + $amount *= 60; + } + + return $amount; +} + + +/** + * Create a unix timestamp with a specified offset from now. + * + * @param string $offset_str String representation of the offset (e.g. 20min, 5h, 2days) + * @param int $factor Factor to multiply with the offset + * + * @return int Unix timestamp + */ +function get_offset_time($offset_str, $factor=1) +{ + return time() + get_offset_sec($offset_str) * $factor; +} + + +/** + * Truncate string if it is longer than the allowed length. + * Replace the middle or the ending part of a string with a placeholder. + * + * @param string $str Input string + * @param int $maxlength Max. length + * @param string $placeholder Replace removed chars with this + * @param bool $ending Set to True if string should be truncated from the end + * + * @return string Abbreviated string + */ +function abbreviate_string($str, $maxlength, $placeholder='...', $ending=false) +{ + $length = mb_strlen($str); + + if ($length > $maxlength) { + if ($ending) { + return mb_substr($str, 0, $maxlength) . $placeholder; + } + + $placeholder_length = mb_strlen($placeholder); + $first_part_length = floor(($maxlength - $placeholder_length)/2); + $second_starting_location = $length - $maxlength + $first_part_length + $placeholder_length; + + $str = mb_substr($str, 0, $first_part_length) . $placeholder . mb_substr($str, $second_starting_location); + } + + return $str; +} + + +/** + * Get all keys from array (recursive). + * + * @param array $array Input array + * + * @return array List of array keys + */ +function array_keys_recursive($array) +{ + $keys = array(); + + if (!empty($array) && is_array($array)) { + foreach ($array as $key => $child) { + $keys[] = $key; + foreach (array_keys_recursive($child) as $val) { + $keys[] = $val; + } + } + } + + return $keys; +} + + +/** + * Remove all non-ascii and non-word chars except ., -, _ + */ +function asciiwords($str, $css_id = false, $replace_with = '') +{ + $allowed = 'a-z0-9\_\-' . (!$css_id ? '\.' : ''); + return preg_replace("/[^$allowed]/i", $replace_with, $str); +} + + +/** + * Check if a string contains only ascii characters + * + * @param string $str String to check + * @param bool $control_chars Includes control characters + * + * @return bool + */ +function is_ascii($str, $control_chars = true) +{ + $regexp = $control_chars ? '/[^\x00-\x7F]/' : '/[^\x20-\x7E]/'; + return preg_match($regexp, $str) ? false : true; +} + + +/** + * Remove single and double quotes from a given string + * + * @param string Input value + * + * @return string Dequoted string + */ +function strip_quotes($str) +{ + return str_replace(array("'", '"'), '', $str); +} + + +/** + * Remove new lines characters from given string + * + * @param string $str Input value + * + * @return string Stripped string + */ +function strip_newlines($str) +{ + return preg_replace('/[\r\n]/', '', $str); +} + + +/** + * Compose a valid representation of name and e-mail address + * + * @param string $email E-mail address + * @param string $name Person name + * + * @return string Formatted string + */ +function format_email_recipient($email, $name = '') +{ + $email = trim($email); + + if ($name && $name != $email) { + // Special chars as defined by RFC 822 need to in quoted string (or escaped). + if (preg_match('/[\(\)\<\>\\\.\[\]@,;:"]/', $name)) { + $name = '"'.addcslashes($name, '"').'"'; + } + + return "$name <$email>"; + } + + return $email; +} + + +/** + * Format e-mail address + * + * @param string $email E-mail address + * + * @return string Formatted e-mail address + */ +function format_email($email) +{ + $email = trim($email); + $parts = explode('@', $email); + $count = count($parts); + + if ($count > 1) { + $parts[$count-1] = mb_strtolower($parts[$count-1]); + + $email = implode('@', $parts); + } + + return $email; +} + + +/** + * mbstring replacement functions + */ +if (!extension_loaded('mbstring')) +{ + function mb_strlen($str) + { + return strlen($str); + } + + function mb_strtolower($str) + { + return strtolower($str); + } + + function mb_strtoupper($str) + { + return strtoupper($str); + } + + function mb_substr($str, $start, $len=null) + { + return substr($str, $start, $len); + } + + function mb_strpos($haystack, $needle, $offset=0) + { + return strpos($haystack, $needle, $offset); + } + + function mb_strrpos($haystack, $needle, $offset=0) + { + return strrpos($haystack, $needle, $offset); + } +} + +/** + * intl replacement functions + */ + +if (!function_exists('idn_to_utf8')) +{ + function idn_to_utf8($domain, $flags=null) + { + static $idn, $loaded; + + if (!$loaded) { + $idn = new Net_IDNA2(); + $loaded = true; + } + + if ($idn && $domain && preg_match('/(^|\.)xn--/i', $domain)) { + try { + $domain = $idn->decode($domain); + } + catch (Exception $e) { + } + } + return $domain; + } +} + +if (!function_exists('idn_to_ascii')) +{ + function idn_to_ascii($domain, $flags=null) + { + static $idn, $loaded; + + if (!$loaded) { + $idn = new Net_IDNA2(); + $loaded = true; + } + + if ($idn && $domain && preg_match('/[^\x20-\x7E]/', $domain)) { + try { + $domain = $idn->encode($domain); + } + catch (Exception $e) { + } + } + return $domain; + } +} + +/** + * Use PHP5 autoload for dynamic class loading + * + * @todo Make Zend, PEAR etc play with this + * @todo Make our classes conform to a more straight forward CS. + */ +function rcube_autoload($classname) +{ + $filename = preg_replace( + array( + '/Mail_(.+)/', + '/Net_(.+)/', + '/Auth_(.+)/', + '/^html_.+/', + '/^rcube(.*)/', + '/^utf8$/', + ), + array( + 'Mail/\\1', + 'Net/\\1', + 'Auth/\\1', + 'Roundcube/html', + 'Roundcube/rcube\\1', + 'utf8.class', + ), + $classname + ); + + if ($fp = @fopen("$filename.php", 'r', true)) { + fclose($fp); + include_once "$filename.php"; + return true; + } + + return false; +} + +/** + * Local callback function for PEAR errors + */ +function rcube_pear_error($err) +{ + error_log(sprintf("%s (%s): %s", + $err->getMessage(), + $err->getCode(), + $err->getUserinfo()), 0); +} diff --git a/program/lib/Roundcube/html.php b/program/lib/Roundcube/html.php new file mode 100644 index 000000000..5fb574b97 --- /dev/null +++ b/program/lib/Roundcube/html.php @@ -0,0 +1,849 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/html.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2011, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Helper class to create valid XHTML code | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class for HTML code creation + * + * @package Framework + * @subpackage HTML + */ +class html +{ + protected $tagname; + protected $attrib = array(); + protected $allowed = array(); + protected $content; + + public static $doctype = 'xhtml'; + public static $lc_tags = true; + public static $common_attrib = array('id','class','style','title','align'); + public static $containers = array('iframe','div','span','p','h1','h2','h3','form','textarea','table','thead','tbody','tr','th','td','style','script'); + + /** + * Constructor + * + * @param array $attrib Hash array with tag attributes + */ + public function __construct($attrib = array()) + { + if (is_array($attrib)) { + $this->attrib = $attrib; + } + } + + /** + * Return the tag code + * + * @return string The finally composed HTML tag + */ + public function show() + { + return self::tag($this->tagname, $this->attrib, $this->content, array_merge(self::$common_attrib, $this->allowed)); + } + + /****** STATIC METHODS *******/ + + /** + * Generic method to create a HTML tag + * + * @param string $tagname Tag name + * @param array $attrib Tag attributes as key/value pairs + * @param string $content Optinal Tag content (creates a container tag) + * @param array $allowed_attrib List with allowed attributes, omit to allow all + * @return string The XHTML tag + */ + public static function tag($tagname, $attrib = array(), $content = null, $allowed_attrib = null) + { + if (is_string($attrib)) + $attrib = array('class' => $attrib); + + $inline_tags = array('a','span','img'); + $suffix = $attrib['nl'] || ($content && $attrib['nl'] !== false && !in_array($tagname, $inline_tags)) ? "\n" : ''; + + $tagname = self::$lc_tags ? strtolower($tagname) : $tagname; + if (isset($content) || in_array($tagname, self::$containers)) { + $suffix = $attrib['noclose'] ? $suffix : '</' . $tagname . '>' . $suffix; + unset($attrib['noclose'], $attrib['nl']); + return '<' . $tagname . self::attrib_string($attrib, $allowed_attrib) . '>' . $content . $suffix; + } + else { + return '<' . $tagname . self::attrib_string($attrib, $allowed_attrib) . '>' . $suffix; + } + } + + /** + * + */ + public static function doctype($type) + { + $doctypes = array( + 'html5' => '<!DOCTYPE html>', + 'xhtml' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">', + 'xhtml-trans' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">', + 'xhtml-strict' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">', + ); + + if ($doctypes[$type]) { + self::$doctype = preg_replace('/-\w+$/', '', $type); + return $doctypes[$type]; + } + + return ''; + } + + /** + * Derrived method for <div> containers + * + * @param mixed $attr Hash array with tag attributes or string with class name + * @param string $cont Div content + * @return string HTML code + * @see html::tag() + */ + public static function div($attr = null, $cont = null) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + return self::tag('div', $attr, $cont, array_merge(self::$common_attrib, array('onclick'))); + } + + /** + * Derrived method for <p> blocks + * + * @param mixed $attr Hash array with tag attributes or string with class name + * @param string $cont Paragraph content + * @return string HTML code + * @see html::tag() + */ + public static function p($attr = null, $cont = null) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + return self::tag('p', $attr, $cont, self::$common_attrib); + } + + /** + * Derrived method to create <img /> + * + * @param mixed $attr Hash array with tag attributes or string with image source (src) + * @return string HTML code + * @see html::tag() + */ + public static function img($attr = null) + { + if (is_string($attr)) { + $attr = array('src' => $attr); + } + return self::tag('img', $attr + array('alt' => ''), null, array_merge(self::$common_attrib, + array('src','alt','width','height','border','usemap','onclick'))); + } + + /** + * Derrived method for link tags + * + * @param mixed $attr Hash array with tag attributes or string with link location (href) + * @param string $cont Link content + * @return string HTML code + * @see html::tag() + */ + public static function a($attr, $cont) + { + if (is_string($attr)) { + $attr = array('href' => $attr); + } + return self::tag('a', $attr, $cont, array_merge(self::$common_attrib, + array('href','target','name','rel','onclick','onmouseover','onmouseout','onmousedown','onmouseup'))); + } + + /** + * Derrived method for inline span tags + * + * @param mixed $attr Hash array with tag attributes or string with class name + * @param string $cont Tag content + * @return string HTML code + * @see html::tag() + */ + public static function span($attr, $cont) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + return self::tag('span', $attr, $cont, self::$common_attrib); + } + + /** + * Derrived method for form element labels + * + * @param mixed $attr Hash array with tag attributes or string with 'for' attrib + * @param string $cont Tag content + * @return string HTML code + * @see html::tag() + */ + public static function label($attr, $cont) + { + if (is_string($attr)) { + $attr = array('for' => $attr); + } + return self::tag('label', $attr, $cont, array_merge(self::$common_attrib, array('for'))); + } + + /** + * Derrived method to create <iframe></iframe> + * + * @param mixed $attr Hash array with tag attributes or string with frame source (src) + * @return string HTML code + * @see html::tag() + */ + public static function iframe($attr = null, $cont = null) + { + if (is_string($attr)) { + $attr = array('src' => $attr); + } + return self::tag('iframe', $attr, $cont, array_merge(self::$common_attrib, + array('src','name','width','height','border','frameborder'))); + } + + /** + * Derrived method to create <script> tags + * + * @param mixed $attr Hash array with tag attributes or string with script source (src) + * @param string $cont Javascript code to be placed as tag content + * @return string HTML code + * @see html::tag() + */ + public static function script($attr, $cont = null) + { + if (is_string($attr)) { + $attr = array('src' => $attr); + } + if ($cont) { + if (self::$doctype == 'xhtml') + $cont = "\n/* <![CDATA[ */\n" . $cont . "\n/* ]]> */\n"; + else + $cont = "\n" . $cont . "\n"; + } + + return self::tag('script', $attr + array('type' => 'text/javascript', 'nl' => true), + $cont, array_merge(self::$common_attrib, array('src','type','charset'))); + } + + /** + * Derrived method for line breaks + * + * @return string HTML code + * @see html::tag() + */ + public static function br($attrib = array()) + { + return self::tag('br', $attrib); + } + + /** + * Create string with attributes + * + * @param array $attrib Associative arry with tag attributes + * @param array $allowed List of allowed attributes + * @return string Valid attribute string + */ + public static function attrib_string($attrib = array(), $allowed = null) + { + if (empty($attrib)) { + return ''; + } + + $allowed_f = array_flip((array)$allowed); + $attrib_arr = array(); + foreach ($attrib as $key => $value) { + // skip size if not numeric + if ($key == 'size' && !is_numeric($value)) { + continue; + } + + // ignore "internal" or not allowed attributes + if ($key == 'nl' || ($allowed && !isset($allowed_f[$key])) || $value === null) { + continue; + } + + // skip empty eventhandlers + if (preg_match('/^on[a-z]+/', $key) && !$value) { + continue; + } + + // attributes with no value + if (in_array($key, array('checked', 'multiple', 'disabled', 'selected'))) { + if ($value) { + $attrib_arr[] = $key . '="' . $key . '"'; + } + } + else { + $attrib_arr[] = $key . '="' . self::quote($value) . '"'; + } + } + + return count($attrib_arr) ? ' '.implode(' ', $attrib_arr) : ''; + } + + /** + * Convert a HTML attribute string attributes to an associative array (name => value) + * + * @param string Input string + * @return array Key-value pairs of parsed attributes + */ + public static function parse_attrib_string($str) + { + $attrib = array(); + $regexp = '/\s*([-_a-z]+)=(["\'])??(?(2)([^\2]*)\2|(\S+?))/Ui'; + + preg_match_all($regexp, stripslashes($str), $regs, PREG_SET_ORDER); + + // convert attributes to an associative array (name => value) + if ($regs) { + foreach ($regs as $attr) { + $attrib[strtolower($attr[1])] = html_entity_decode($attr[3] . $attr[4]); + } + } + + return $attrib; + } + + /** + * Replacing specials characters in html attribute value + * + * @param string $str Input string + * + * @return string The quoted string + */ + public static function quote($str) + { + return @htmlspecialchars($str, ENT_COMPAT, RCUBE_CHARSET); + } +} + + +/** + * Class to create an HTML input field + * + * @package HTML + */ +class html_inputfield extends html +{ + protected $tagname = 'input'; + protected $type = 'text'; + protected $allowed = array( + 'type','name','value','size','tabindex','autocapitalize', + 'autocomplete','checked','onchange','onclick','disabled','readonly', + 'spellcheck','results','maxlength','src','multiple','placeholder', + ); + + /** + * Object constructor + * + * @param array $attrib Associative array with tag attributes + */ + public function __construct($attrib = array()) + { + if (is_array($attrib)) { + $this->attrib = $attrib; + } + + if ($attrib['type']) { + $this->type = $attrib['type']; + } + } + + /** + * Compose input tag + * + * @param string $value Field value + * @param array $attrib Additional attributes to override + * @return string HTML output + */ + public function show($value = null, $attrib = null) + { + // overwrite object attributes + if (is_array($attrib)) { + $this->attrib = array_merge($this->attrib, $attrib); + } + + // set value attribute + if ($value !== null) { + $this->attrib['value'] = $value; + } + // set type + $this->attrib['type'] = $this->type; + return parent::show(); + } +} + +/** + * Class to create an HTML password field + * + * @package HTML + */ +class html_passwordfield extends html_inputfield +{ + protected $type = 'password'; +} + +/** + * Class to create an hidden HTML input field + * + * @package HTML + */ + +class html_hiddenfield extends html +{ + protected $tagname = 'input'; + protected $type = 'hidden'; + protected $fields_arr = array(); + protected $allowed = array('type','name','value','onchange','disabled','readonly'); + + /** + * Constructor + * + * @param array $attrib Named tag attributes + */ + public function __construct($attrib = null) + { + if (is_array($attrib)) { + $this->add($attrib); + } + } + + /** + * Add a hidden field to this instance + * + * @param array $attrib Named tag attributes + */ + public function add($attrib) + { + $this->fields_arr[] = $attrib; + } + + /** + * Create HTML code for the hidden fields + * + * @return string Final HTML code + */ + public function show() + { + $out = ''; + foreach ($this->fields_arr as $attrib) { + $out .= self::tag($this->tagname, array('type' => $this->type) + $attrib); + } + return $out; + } +} + +/** + * Class to create HTML radio buttons + * + * @package HTML + */ +class html_radiobutton extends html_inputfield +{ + protected $type = 'radio'; + + /** + * Get HTML code for this object + * + * @param string $value Value of the checked field + * @param array $attrib Additional attributes to override + * @return string HTML output + */ + public function show($value = '', $attrib = null) + { + // overwrite object attributes + if (is_array($attrib)) { + $this->attrib = array_merge($this->attrib, $attrib); + } + + // set value attribute + $this->attrib['checked'] = ((string)$value == (string)$this->attrib['value']); + + return parent::show(); + } +} + +/** + * Class to create HTML checkboxes + * + * @package HTML + */ +class html_checkbox extends html_inputfield +{ + protected $type = 'checkbox'; + + /** + * Get HTML code for this object + * + * @param string $value Value of the checked field + * @param array $attrib Additional attributes to override + * @return string HTML output + */ + public function show($value = '', $attrib = null) + { + // overwrite object attributes + if (is_array($attrib)) { + $this->attrib = array_merge($this->attrib, $attrib); + } + + // set value attribute + $this->attrib['checked'] = ((string)$value == (string)$this->attrib['value']); + + return parent::show(); + } +} + +/** + * Class to create an HTML textarea + * + * @package HTML + */ +class html_textarea extends html +{ + protected $tagname = 'textarea'; + protected $allowed = array('name','rows','cols','wrap','tabindex', + 'onchange','disabled','readonly','spellcheck'); + + /** + * Get HTML code for this object + * + * @param string $value Textbox value + * @param array $attrib Additional attributes to override + * @return string HTML output + */ + public function show($value = '', $attrib = null) + { + // overwrite object attributes + if (is_array($attrib)) { + $this->attrib = array_merge($this->attrib, $attrib); + } + + // take value attribute as content + if (empty($value) && !empty($this->attrib['value'])) { + $value = $this->attrib['value']; + } + + // make shure we don't print the value attribute + if (isset($this->attrib['value'])) { + unset($this->attrib['value']); + } + + if (!empty($value) && empty($this->attrib['is_escaped'])) { + $value = self::quote($value); + } + + return self::tag($this->tagname, $this->attrib, $value, + array_merge(self::$common_attrib, $this->allowed)); + } +} + +/** + * Builder for HTML drop-down menus + * Syntax:<pre> + * // create instance. arguments are used to set attributes of select-tag + * $select = new html_select(array('name' => 'fieldname')); + * + * // add one option + * $select->add('Switzerland', 'CH'); + * + * // add multiple options + * $select->add(array('Switzerland','Germany'), array('CH','DE')); + * + * // generate pulldown with selection 'Switzerland' and return html-code + * // as second argument the same attributes available to instanciate can be used + * print $select->show('CH'); + * </pre> + * + * @package HTML + */ +class html_select extends html +{ + protected $tagname = 'select'; + protected $options = array(); + protected $allowed = array('name','size','tabindex','autocomplete', + 'multiple','onchange','disabled','rel'); + + /** + * Add a new option to this drop-down + * + * @param mixed $names Option name or array with option names + * @param mixed $values Option value or array with option values + */ + public function add($names, $values = null) + { + if (is_array($names)) { + foreach ($names as $i => $text) { + $this->options[] = array('text' => $text, 'value' => $values[$i]); + } + } + else { + $this->options[] = array('text' => $names, 'value' => $values); + } + } + + /** + * Get HTML code for this object + * + * @param string $select Value of the selection option + * @param array $attrib Additional attributes to override + * @return string HTML output + */ + public function show($select = array(), $attrib = null) + { + // overwrite object attributes + if (is_array($attrib)) { + $this->attrib = array_merge($this->attrib, $attrib); + } + + $this->content = "\n"; + $select = (array)$select; + foreach ($this->options as $option) { + $attr = array( + 'value' => $option['value'], + 'selected' => (in_array($option['value'], $select, true) || + in_array($option['text'], $select, true)) ? 1 : null); + + $option_content = $option['text']; + if (empty($this->attrib['is_escaped'])) { + $option_content = self::quote($option_content); + } + + $this->content .= self::tag('option', $attr, $option_content); + } + + return parent::show(); + } +} + + +/** + * Class to build an HTML table + * + * @package HTML + */ +class html_table extends html +{ + protected $tagname = 'table'; + protected $allowed = array('id','class','style','width','summary', + 'cellpadding','cellspacing','border'); + + private $header = array(); + private $rows = array(); + private $rowindex = 0; + private $colindex = 0; + + /** + * Constructor + * + * @param array $attrib Named tag attributes + */ + public function __construct($attrib = array()) + { + $default_attrib = self::$doctype == 'xhtml' ? array('summary' => '', 'border' => 0) : array(); + $this->attrib = array_merge($attrib, $default_attrib); + } + + /** + * Add a table cell + * + * @param array $attr Cell attributes + * @param string $cont Cell content + */ + public function add($attr, $cont) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + + $cell = new stdClass; + $cell->attrib = $attr; + $cell->content = $cont; + + $this->rows[$this->rowindex]->cells[$this->colindex] = $cell; + $this->colindex += max(1, intval($attr['colspan'])); + + if ($this->attrib['cols'] && $this->colindex >= $this->attrib['cols']) { + $this->add_row(); + } + } + + /** + * Add a table header cell + * + * @param array $attr Cell attributes + * @param string $cont Cell content + */ + public function add_header($attr, $cont) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + + $cell = new stdClass; + $cell->attrib = $attr; + $cell->content = $cont; + $this->header[] = $cell; + } + + /** + * Remove a column from a table + * Useful for plugins making alterations + * + * @param string $class + */ + public function remove_column($class) + { + // Remove the header + foreach ($this->header as $index=>$header){ + if ($header->attrib['class'] == $class){ + unset($this->header[$index]); + break; + } + } + + // Remove cells from rows + foreach ($this->rows as $i=>$row){ + foreach ($row->cells as $j=>$cell){ + if ($cell->attrib['class'] == $class){ + unset($this->rows[$i]->cells[$j]); + break; + } + } + } + } + + /** + * Jump to next row + * + * @param array $attr Row attributes + */ + public function add_row($attr = array()) + { + $this->rowindex++; + $this->colindex = 0; + $this->rows[$this->rowindex] = new stdClass; + $this->rows[$this->rowindex]->attrib = $attr; + $this->rows[$this->rowindex]->cells = array(); + } + + /** + * Set row attributes + * + * @param array $attr Row attributes + * @param int $index Optional row index (default current row index) + */ + public function set_row_attribs($attr = array(), $index = null) + { + if (is_string($attr)) { + $attr = array('class' => $attr); + } + + if ($index === null) { + $index = $this->rowindex; + } + + $this->rows[$index]->attrib = $attr; + } + + /** + * Get row attributes + * + * @param int $index Row index + * + * @return array Row attributes + */ + public function get_row_attribs($index = null) + { + if ($index === null) { + $index = $this->rowindex; + } + + return $this->rows[$index] ? $this->rows[$index]->attrib : null; + } + + /** + * Build HTML output of the table data + * + * @param array $attrib Table attributes + * @return string The final table HTML code + */ + public function show($attrib = null) + { + if (is_array($attrib)) + $this->attrib = array_merge($this->attrib, $attrib); + + $thead = $tbody = ""; + + // include <thead> + if (!empty($this->header)) { + $rowcontent = ''; + foreach ($this->header as $c => $col) { + $rowcontent .= self::tag('td', $col->attrib, $col->content); + } + $thead = self::tag('thead', null, self::tag('tr', null, $rowcontent, parent::$common_attrib)); + } + + foreach ($this->rows as $r => $row) { + $rowcontent = ''; + foreach ($row->cells as $c => $col) { + $rowcontent .= self::tag('td', $col->attrib, $col->content); + } + + if ($r < $this->rowindex || count($row->cells)) { + $tbody .= self::tag('tr', $row->attrib, $rowcontent, parent::$common_attrib); + } + } + + if ($this->attrib['rowsonly']) { + return $tbody; + } + + // add <tbody> + $this->content = $thead . self::tag('tbody', null, $tbody); + + unset($this->attrib['cols'], $this->attrib['rowsonly']); + return parent::show(); + } + + /** + * Count number of rows + * + * @return The number of rows + */ + public function size() + { + return count($this->rows); + } + + /** + * Remove table body (all rows) + */ + public function remove_body() + { + $this->rows = array(); + $this->rowindex = 0; + } + +} diff --git a/program/lib/Roundcube/rcube.php b/program/lib/Roundcube/rcube.php new file mode 100644 index 000000000..c3aa8ffa5 --- /dev/null +++ b/program/lib/Roundcube/rcube.php @@ -0,0 +1,1281 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Framework base class providing core functions and holding | + | instances of all 'global' objects like db- and storage-connections | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Base class of the Roundcube Framework + * implemented as singleton + * + * @package Framework + * @subpackage Core + */ +class rcube +{ + const INIT_WITH_DB = 1; + const INIT_WITH_PLUGINS = 2; + + /** + * Singleton instace of rcube + * + * @var rcmail + */ + static protected $instance; + + /** + * Stores instance of rcube_config. + * + * @var rcube_config + */ + public $config; + + /** + * Instace of database class. + * + * @var rcube_db + */ + public $db; + + /** + * Instace of Memcache class. + * + * @var Memcache + */ + public $memcache; + + /** + * Instace of rcube_session class. + * + * @var rcube_session + */ + public $session; + + /** + * Instance of rcube_smtp class. + * + * @var rcube_smtp + */ + public $smtp; + + /** + * Instance of rcube_storage class. + * + * @var rcube_storage + */ + public $storage; + + /** + * Instance of rcube_output class. + * + * @var rcube_output + */ + public $output; + + /** + * Instance of rcube_plugin_api. + * + * @var rcube_plugin_api + */ + public $plugins; + + + /* private/protected vars */ + protected $texts; + protected $caches = array(); + protected $shutdown_functions = array(); + protected $expunge_cache = false; + + + /** + * This implements the 'singleton' design pattern + * + * @param integer Options to initialize with this instance. See rcube::INIT_WITH_* constants + * + * @return rcube The one and only instance + */ + static function get_instance($mode = 0) + { + if (!self::$instance) { + self::$instance = new rcube(); + self::$instance->init($mode); + } + + return self::$instance; + } + + + /** + * Private constructor + */ + protected function __construct() + { + // load configuration + $this->config = new rcube_config; + $this->plugins = new rcube_dummy_plugin_api; + + register_shutdown_function(array($this, 'shutdown')); + } + + + /** + * Initial startup function + */ + protected function init($mode = 0) + { + // initialize syslog + if ($this->config->get('log_driver') == 'syslog') { + $syslog_id = $this->config->get('syslog_id', 'roundcube'); + $syslog_facility = $this->config->get('syslog_facility', LOG_USER); + openlog($syslog_id, LOG_ODELAY, $syslog_facility); + } + + // connect to database + if ($mode & self::INIT_WITH_DB) { + $this->get_dbh(); + } + + // create plugin API and load plugins + if ($mode & self::INIT_WITH_PLUGINS) { + $this->plugins = rcube_plugin_api::get_instance(); + } + } + + + /** + * Get the current database connection + * + * @return rcube_db Database object + */ + public function get_dbh() + { + if (!$this->db) { + $config_all = $this->config->all(); + $this->db = rcube_db::factory($config_all['db_dsnw'], $config_all['db_dsnr'], $config_all['db_persistent']); + $this->db->set_debug((bool)$config_all['sql_debug']); + } + + return $this->db; + } + + + /** + * Get global handle for memcache access + * + * @return object Memcache + */ + public function get_memcache() + { + if (!isset($this->memcache)) { + // no memcache support in PHP + if (!class_exists('Memcache')) { + $this->memcache = false; + return false; + } + + $this->memcache = new Memcache; + $this->mc_available = 0; + + // add all configured hosts to pool + $pconnect = $this->config->get('memcache_pconnect', true); + foreach ($this->config->get('memcache_hosts', array()) as $host) { + if (substr($host, 0, 7) != 'unix://') { + list($host, $port) = explode(':', $host); + if (!$port) $port = 11211; + } + else { + $port = 0; + } + + $this->mc_available += intval($this->memcache->addServer( + $host, $port, $pconnect, 1, 1, 15, false, array($this, 'memcache_failure'))); + } + + // test connection and failover (will result in $this->mc_available == 0 on complete failure) + $this->memcache->increment('__CONNECTIONTEST__', 1); // NOP if key doesn't exist + + if (!$this->mc_available) { + $this->memcache = false; + } + } + + return $this->memcache; + } + + + /** + * Callback for memcache failure + */ + public function memcache_failure($host, $port) + { + static $seen = array(); + + // only report once + if (!$seen["$host:$port"]++) { + $this->mc_available--; + self::raise_error(array( + 'code' => 604, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => "Memcache failure on host $host:$port"), + true, false); + } + } + + + /** + * Initialize and get cache object + * + * @param string $name Cache identifier + * @param string $type Cache type ('db', 'apc' or 'memcache') + * @param string $ttl Expiration time for cache items + * @param bool $packed Enables/disables data serialization + * + * @return rcube_cache Cache object + */ + public function get_cache($name, $type='db', $ttl=0, $packed=true) + { + if (!isset($this->caches[$name]) && ($userid = $this->get_user_id())) { + $this->caches[$name] = new rcube_cache($type, $userid, $name, $ttl, $packed); + } + + return $this->caches[$name]; + } + + + /** + * Create SMTP object and connect to server + * + * @param boolean True if connection should be established + */ + public function smtp_init($connect = false) + { + $this->smtp = new rcube_smtp(); + + if ($connect) { + $this->smtp->connect(); + } + } + + + /** + * Initialize and get storage object + * + * @return rcube_storage Storage object + */ + public function get_storage() + { + // already initialized + if (!is_object($this->storage)) { + $this->storage_init(); + } + + return $this->storage; + } + + + /** + * Initialize storage object + */ + public function storage_init() + { + // already initialized + if (is_object($this->storage)) { + return; + } + + $driver = $this->config->get('storage_driver', 'imap'); + $driver_class = "rcube_{$driver}"; + + if (!class_exists($driver_class)) { + self::raise_error(array( + 'code' => 700, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Storage driver class ($driver) not found!"), + true, true); + } + + // Initialize storage object + $this->storage = new $driver_class; + + // for backward compat. (deprecated, will be removed) + $this->imap = $this->storage; + + // enable caching of mail data + $storage_cache = $this->config->get("{$driver}_cache"); + $messages_cache = $this->config->get('messages_cache'); + // for backward compatybility + if ($storage_cache === null && $messages_cache === null && $this->config->get('enable_caching')) { + $storage_cache = 'db'; + $messages_cache = true; + } + + if ($storage_cache) { + $this->storage->set_caching($storage_cache); + } + if ($messages_cache) { + $this->storage->set_messages_caching(true); + } + + // set pagesize from config + $pagesize = $this->config->get('mail_pagesize'); + if (!$pagesize) { + $pagesize = $this->config->get('pagesize', 50); + } + $this->storage->set_pagesize($pagesize); + + // set class options + $options = array( + 'auth_type' => $this->config->get("{$driver}_auth_type", 'check'), + 'auth_cid' => $this->config->get("{$driver}_auth_cid"), + 'auth_pw' => $this->config->get("{$driver}_auth_pw"), + 'debug' => (bool) $this->config->get("{$driver}_debug"), + 'force_caps' => (bool) $this->config->get("{$driver}_force_caps"), + 'timeout' => (int) $this->config->get("{$driver}_timeout"), + 'skip_deleted' => (bool) $this->config->get('skip_deleted'), + 'driver' => $driver, + ); + + if (!empty($_SESSION['storage_host'])) { + $options['host'] = $_SESSION['storage_host']; + $options['user'] = $_SESSION['username']; + $options['port'] = $_SESSION['storage_port']; + $options['ssl'] = $_SESSION['storage_ssl']; + $options['password'] = $this->decrypt($_SESSION['password']); + $_SESSION[$driver.'_host'] = $_SESSION['storage_host']; + } + + $options = $this->plugins->exec_hook("storage_init", $options); + + // for backward compat. (deprecated, to be removed) + $options = $this->plugins->exec_hook("imap_init", $options); + + $this->storage->set_options($options); + $this->set_storage_prop(); + } + + + /** + * Set storage parameters. + * This must be done AFTER connecting to the server! + */ + protected function set_storage_prop() + { + $storage = $this->get_storage(); + + $storage->set_charset($this->config->get('default_charset', RCUBE_CHARSET)); + + if ($default_folders = $this->config->get('default_folders')) { + $storage->set_default_folders($default_folders); + } + if (isset($_SESSION['mbox'])) { + $storage->set_folder($_SESSION['mbox']); + } + if (isset($_SESSION['page'])) { + $storage->set_page($_SESSION['page']); + } + } + + + /** + * Create session object and start the session. + */ + public function session_init() + { + // session started (Installer?) + if (session_id()) { + return; + } + + $sess_name = $this->config->get('session_name'); + $sess_domain = $this->config->get('session_domain'); + $sess_path = $this->config->get('session_path'); + $lifetime = $this->config->get('session_lifetime', 0) * 60; + + // set session domain + if ($sess_domain) { + ini_set('session.cookie_domain', $sess_domain); + } + // set session path + if ($sess_path) { + ini_set('session.cookie_path', $sess_path); + } + // set session garbage collecting time according to session_lifetime + if ($lifetime) { + ini_set('session.gc_maxlifetime', $lifetime * 2); + } + + ini_set('session.cookie_secure', rcube_utils::https_check()); + ini_set('session.name', $sess_name ? $sess_name : 'roundcube_sessid'); + ini_set('session.use_cookies', 1); + ini_set('session.use_only_cookies', 1); + ini_set('session.serialize_handler', 'php'); + ini_set('session.cookie_httponly', 1); + + // use database for storing session data + $this->session = new rcube_session($this->get_dbh(), $this->config); + + $this->session->register_gc_handler(array($this, 'temp_gc')); + $this->session->register_gc_handler(array($this, 'cache_gc')); + + $this->session->set_secret($this->config->get('des_key') . dirname($_SERVER['SCRIPT_NAME'])); + $this->session->set_ip_check($this->config->get('ip_check')); + + // start PHP session (if not in CLI mode) + if ($_SERVER['REMOTE_ADDR']) { + session_start(); + } + } + + + /** + * Garbage collector function for temp files. + * Remove temp files older than two days + */ + public function temp_gc() + { + $tmp = unslashify($this->config->get('temp_dir')); + $expire = time() - 172800; // expire in 48 hours + + if ($tmp && ($dir = opendir($tmp))) { + while (($fname = readdir($dir)) !== false) { + if ($fname{0} == '.') { + continue; + } + + if (filemtime($tmp.'/'.$fname) < $expire) { + @unlink($tmp.'/'.$fname); + } + } + + closedir($dir); + } + } + + + /** + * Garbage collector for cache entries. + * Set flag to expunge caches on shutdown + */ + public function cache_gc() + { + // because this gc function is called before storage is initialized, + // we just set a flag to expunge storage cache on shutdown. + $this->expunge_cache = true; + } + + + /** + * Get localized text in the desired language + * + * @param mixed $attrib Named parameters array or label name + * @param string $domain Label domain (plugin) name + * + * @return string Localized text + */ + public function gettext($attrib, $domain=null) + { + // load localization files if not done yet + if (empty($this->texts)) { + $this->load_language(); + } + + // extract attributes + if (is_string($attrib)) { + $attrib = array('name' => $attrib); + } + + $name = $attrib['name'] ? $attrib['name'] : ''; + + // attrib contain text values: use them from now + if (($setval = $attrib[strtolower($_SESSION['language'])]) || ($setval = $attrib['en_us'])) { + $this->texts[$name] = $setval; + } + + // check for text with domain + if ($domain && ($text = $this->texts[$domain.'.'.$name])) { + } + // text does not exist + else if (!($text = $this->texts[$name])) { + return "[$name]"; + } + + // replace vars in text + if (is_array($attrib['vars'])) { + foreach ($attrib['vars'] as $var_key => $var_value) { + $text = str_replace($var_key[0]!='$' ? '$'.$var_key : $var_key, $var_value, $text); + } + } + + // format output + if (($attrib['uppercase'] && strtolower($attrib['uppercase'] == 'first')) || $attrib['ucfirst']) { + return ucfirst($text); + } + else if ($attrib['uppercase']) { + return mb_strtoupper($text); + } + else if ($attrib['lowercase']) { + return mb_strtolower($text); + } + + return strtr($text, array('\n' => "\n")); + } + + + /** + * Check if the given text label exists + * + * @param string $name Label name + * @param string $domain Label domain (plugin) name or '*' for all domains + * @param string $ref_domain Sets domain name if label is found + * + * @return boolean True if text exists (either in the current language or in en_US) + */ + public function text_exists($name, $domain = null, &$ref_domain = null) + { + // load localization files if not done yet + if (empty($this->texts)) { + $this->load_language(); + } + + if (isset($this->texts[$name])) { + $ref_domain = ''; + return true; + } + + // any of loaded domains (plugins) + if ($domain == '*') { + foreach ($this->plugins->loaded_plugins() as $domain) { + if (isset($this->texts[$domain.'.'.$name])) { + $ref_domain = $domain; + return true; + } + } + } + // specified domain + else if ($domain) { + $ref_domain = $domain; + return isset($this->texts[$domain.'.'.$name]); + } + + return false; + } + + + /** + * Load a localization package + * + * @param string Language ID + * @param array Additional text labels/messages + */ + public function load_language($lang = null, $add = array()) + { + $lang = $this->language_prop(($lang ? $lang : $_SESSION['language'])); + + // load localized texts + if (empty($this->texts) || $lang != $_SESSION['language']) { + $this->texts = array(); + + // handle empty lines after closing PHP tag in localization files + ob_start(); + + // get english labels (these should be complete) + @include(RCUBE_LOCALIZATION_DIR . 'en_US/labels.inc'); + @include(RCUBE_LOCALIZATION_DIR . 'en_US/messages.inc'); + + if (is_array($labels)) + $this->texts = $labels; + if (is_array($messages)) + $this->texts = array_merge($this->texts, $messages); + + // include user language files + if ($lang != 'en' && $lang != 'en_US' && is_dir(RCUBE_LOCALIZATION_DIR . $lang)) { + include_once(RCUBE_LOCALIZATION_DIR . $lang . '/labels.inc'); + include_once(RCUBE_LOCALIZATION_DIR . $lang . '/messages.inc'); + + if (is_array($labels)) + $this->texts = array_merge($this->texts, $labels); + if (is_array($messages)) + $this->texts = array_merge($this->texts, $messages); + } + + ob_end_clean(); + + $_SESSION['language'] = $lang; + } + + // append additional texts (from plugin) + if (is_array($add) && !empty($add)) { + $this->texts += $add; + } + } + + + /** + * Check the given string and return a valid language code + * + * @param string Language code + * + * @return string Valid language code + */ + protected function language_prop($lang) + { + static $rcube_languages, $rcube_language_aliases; + + // user HTTP_ACCEPT_LANGUAGE if no language is specified + if (empty($lang) || $lang == 'auto') { + $accept_langs = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']); + $lang = str_replace('-', '_', $accept_langs[0]); + } + + if (empty($rcube_languages)) { + @include(RCUBE_LOCALIZATION_DIR . 'index.inc'); + } + + // check if we have an alias for that language + if (!isset($rcube_languages[$lang]) && isset($rcube_language_aliases[$lang])) { + $lang = $rcube_language_aliases[$lang]; + } + // try the first two chars + else if (!isset($rcube_languages[$lang])) { + $short = substr($lang, 0, 2); + + // check if we have an alias for the short language code + if (!isset($rcube_languages[$short]) && isset($rcube_language_aliases[$short])) { + $lang = $rcube_language_aliases[$short]; + } + // expand 'nn' to 'nn_NN' + else if (!isset($rcube_languages[$short])) { + $lang = $short.'_'.strtoupper($short); + } + } + + if (!isset($rcube_languages[$lang]) || !is_dir(RCUBE_LOCALIZATION_DIR . $lang)) { + $lang = 'en_US'; + } + + return $lang; + } + + + /** + * Read directory program/localization and return a list of available languages + * + * @return array List of available localizations + */ + public function list_languages() + { + static $sa_languages = array(); + + if (!sizeof($sa_languages)) { + @include(RCUBE_LOCALIZATION_DIR . 'index.inc'); + + if ($dh = @opendir(RCUBE_LOCALIZATION_DIR)) { + while (($name = readdir($dh)) !== false) { + if ($name[0] == '.' || !is_dir(RCUBE_LOCALIZATION_DIR . $name)) { + continue; + } + + if ($label = $rcube_languages[$name]) { + $sa_languages[$name] = $label; + } + } + closedir($dh); + } + } + + return $sa_languages; + } + + + /** + * Encrypt using 3DES + * + * @param string $clear clear text input + * @param string $key encryption key to retrieve from the configuration, defaults to 'des_key' + * @param boolean $base64 whether or not to base64_encode() the result before returning + * + * @return string encrypted text + */ + public function encrypt($clear, $key = 'des_key', $base64 = true) + { + if (!$clear) { + return ''; + } + + /*- + * Add a single canary byte to the end of the clear text, which + * will help find out how much of padding will need to be removed + * upon decryption; see http://php.net/mcrypt_generic#68082 + */ + $clear = pack("a*H2", $clear, "80"); + + if (function_exists('mcrypt_module_open') && + ($td = mcrypt_module_open(MCRYPT_TripleDES, "", MCRYPT_MODE_CBC, "")) + ) { + $iv = $this->create_iv(mcrypt_enc_get_iv_size($td)); + mcrypt_generic_init($td, $this->config->get_crypto_key($key), $iv); + $cipher = $iv . mcrypt_generic($td, $clear); + mcrypt_generic_deinit($td); + mcrypt_module_close($td); + } + else { + @include_once 'des.inc'; + + if (function_exists('des')) { + $des_iv_size = 8; + $iv = $this->create_iv($des_iv_size); + $cipher = $iv . des($this->config->get_crypto_key($key), $clear, 1, 1, $iv); + } + else { + self::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Could not perform encryption; make sure Mcrypt is installed or lib/des.inc is available" + ), true, true); + } + } + + return $base64 ? base64_encode($cipher) : $cipher; + } + + + /** + * Decrypt 3DES-encrypted string + * + * @param string $cipher encrypted text + * @param string $key encryption key to retrieve from the configuration, defaults to 'des_key' + * @param boolean $base64 whether or not input is base64-encoded + * + * @return string decrypted text + */ + public function decrypt($cipher, $key = 'des_key', $base64 = true) + { + if (!$cipher) { + return ''; + } + + $cipher = $base64 ? base64_decode($cipher) : $cipher; + + if (function_exists('mcrypt_module_open') && + ($td = mcrypt_module_open(MCRYPT_TripleDES, "", MCRYPT_MODE_CBC, "")) + ) { + $iv_size = mcrypt_enc_get_iv_size($td); + $iv = substr($cipher, 0, $iv_size); + + // session corruption? (#1485970) + if (strlen($iv) < $iv_size) { + return ''; + } + + $cipher = substr($cipher, $iv_size); + mcrypt_generic_init($td, $this->config->get_crypto_key($key), $iv); + $clear = mdecrypt_generic($td, $cipher); + mcrypt_generic_deinit($td); + mcrypt_module_close($td); + } + else { + @include_once 'des.inc'; + + if (function_exists('des')) { + $des_iv_size = 8; + $iv = substr($cipher, 0, $des_iv_size); + $cipher = substr($cipher, $des_iv_size); + $clear = des($this->config->get_crypto_key($key), $cipher, 0, 1, $iv); + } + else { + self::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Could not perform decryption; make sure Mcrypt is installed or lib/des.inc is available" + ), true, true); + } + } + + /*- + * Trim PHP's padding and the canary byte; see note in + * rcube::encrypt() and http://php.net/mcrypt_generic#68082 + */ + $clear = substr(rtrim($clear, "\0"), 0, -1); + + return $clear; + } + + + /** + * Generates encryption initialization vector (IV) + * + * @param int Vector size + * + * @return string Vector string + */ + private function create_iv($size) + { + // mcrypt_create_iv() can be slow when system lacks entrophy + // we'll generate IV vector manually + $iv = ''; + for ($i = 0; $i < $size; $i++) { + $iv .= chr(mt_rand(0, 255)); + } + + return $iv; + } + + + /** + * Build a valid URL to this instance of Roundcube + * + * @param mixed Either a string with the action or url parameters as key-value pairs + * @return string Valid application URL + */ + public function url($p) + { + // STUB: should be overloaded by the application + return ''; + } + + + /** + * Function to be executed in script shutdown + * Registered with register_shutdown_function() + */ + public function shutdown() + { + foreach ($this->shutdown_functions as $function) { + call_user_func($function); + } + + if (is_object($this->smtp)) { + $this->smtp->disconnect(); + } + + foreach ($this->caches as $cache) { + if (is_object($cache)) { + $cache->close(); + } + } + + if (is_object($this->storage)) { + if ($this->expunge_cache) { + $this->storage->expunge_cache(); + } + $this->storage->close(); + } + } + + + /** + * Registers shutdown function to be executed on shutdown. + * The functions will be executed before destroying any + * objects like smtp, imap, session, etc. + * + * @param callback Function callback + */ + public function add_shutdown_function($function) + { + $this->shutdown_functions[] = $function; + } + + + /** + * Quote a given string. + * Shortcut function for rcube_utils::rep_specialchars_output() + * + * @return string HTML-quoted string + */ + public static function Q($str, $mode = 'strict', $newlines = true) + { + return rcube_utils::rep_specialchars_output($str, 'html', $mode, $newlines); + } + + + /** + * Quote a given string for javascript output. + * Shortcut function for rcube_utils::rep_specialchars_output() + * + * @return string JS-quoted string + */ + public static function JQ($str) + { + return rcube_utils::rep_specialchars_output($str, 'js'); + } + + + /** + * Construct shell command, execute it and return output as string. + * Keywords {keyword} are replaced with arguments + * + * @param $cmd Format string with {keywords} to be replaced + * @param $values (zero, one or more arrays can be passed) + * + * @return output of command. shell errors not detectable + */ + public static function exec(/* $cmd, $values1 = array(), ... */) + { + $args = func_get_args(); + $cmd = array_shift($args); + $values = $replacements = array(); + + // merge values into one array + foreach ($args as $arg) { + $values += (array)$arg; + } + + preg_match_all('/({(-?)([a-z]\w*)})/', $cmd, $matches, PREG_SET_ORDER); + foreach ($matches as $tags) { + list(, $tag, $option, $key) = $tags; + $parts = array(); + + if ($option) { + foreach ((array)$values["-$key"] as $key => $value) { + if ($value === true || $value === false || $value === null) { + $parts[] = $value ? $key : ""; + } + else { + foreach ((array)$value as $val) { + $parts[] = "$key " . escapeshellarg($val); + } + } + } + } + else { + foreach ((array)$values[$key] as $value) { + $parts[] = escapeshellarg($value); + } + } + + $replacements[$tag] = join(" ", $parts); + } + + // use strtr behaviour of going through source string once + $cmd = strtr($cmd, $replacements); + + return (string)shell_exec($cmd); + } + + + /** + * Print or write debug messages + * + * @param mixed Debug message or data + */ + public static function console() + { + $args = func_get_args(); + + if (class_exists('rcube', false)) { + $rcube = self::get_instance(); + $plugin = $rcube->plugins->exec_hook('console', array('args' => $args)); + if ($plugin['abort']) { + return; + } + $args = $plugin['args']; + } + + $msg = array(); + foreach ($args as $arg) { + $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; + } + + self::write_log('console', join(";\n", $msg)); + } + + + /** + * Append a line to a logfile in the logs directory. + * Date will be added automatically to the line. + * + * @param $name name of log file + * @param line Line to append + */ + public static function write_log($name, $line) + { + if (!is_string($line)) { + $line = var_export($line, true); + } + + $date_format = self::$instance ? self::$instance->config->get('log_date_format') : null; + $log_driver = self::$instance ? self::$instance->config->get('log_driver') : null; + + if (empty($date_format)) { + $date_format = 'd-M-Y H:i:s O'; + } + + $date = date($date_format); + + // trigger logging hook + if (is_object(self::$instance) && is_object(self::$instance->plugins)) { + $log = self::$instance->plugins->exec_hook('write_log', array('name' => $name, 'date' => $date, 'line' => $line)); + $name = $log['name']; + $line = $log['line']; + $date = $log['date']; + if ($log['abort']) + return true; + } + + if ($log_driver == 'syslog') { + $prio = $name == 'errors' ? LOG_ERR : LOG_INFO; + syslog($prio, $line); + return true; + } + + // log_driver == 'file' is assumed here + + $line = sprintf("[%s]: %s\n", $date, $line); + $log_dir = self::$instance ? self::$instance->config->get('log_dir') : null; + + if (empty($log_dir)) { + $log_dir = RCUBE_INSTALL_PATH . 'logs'; + } + + // try to open specific log file for writing + $logfile = $log_dir.'/'.$name; + + if ($fp = @fopen($logfile, 'a')) { + fwrite($fp, $line); + fflush($fp); + fclose($fp); + return true; + } + + trigger_error("Error writing to log file $logfile; Please check permissions", E_USER_WARNING); + return false; + } + + + /** + * Throw system error (and show error page). + * + * @param array Named parameters + * - code: Error code (required) + * - type: Error type [php|db|imap|javascript] (required) + * - message: Error message + * - file: File where error occured + * - line: Line where error occured + * @param boolean True to log the error + * @param boolean Terminate script execution + */ + public static function raise_error($arg = array(), $log = false, $terminate = false) + { + // handle PHP exceptions + if (is_object($arg) && is_a($arg, 'Exception')) { + $err = array( + 'type' => 'php', + 'code' => $arg->getCode(), + 'line' => $arg->getLine(), + 'file' => $arg->getFile(), + 'message' => $arg->getMessage(), + ); + $arg = $err; + } + + // installer + if (class_exists('rcube_install', false)) { + $rci = rcube_install::get_instance(); + $rci->raise_error($arg); + return; + } + + if (($log || $terminate) && $arg['type'] && $arg['message']) { + $arg['fatal'] = $terminate; + self::log_bug($arg); + } + + // display error page and terminate script + if ($terminate && is_object(self::$instance->output)) { + self::$instance->output->raise_error($arg['code'], $arg['message']); + } + } + + + /** + * Report error according to configured debug_level + * + * @param array Named parameters + * @see self::raise_error() + */ + public static function log_bug($arg_arr) + { + $program = strtoupper($arg_arr['type']); + $level = self::get_instance()->config->get('debug_level'); + + // disable errors for ajax requests, write to log instead (#1487831) + if (($level & 4) && !empty($_REQUEST['_remote'])) { + $level = ($level ^ 4) | 1; + } + + // write error to local log file + if (($level & 1) || !empty($arg_arr['fatal'])) { + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $post_query = '?_task='.urlencode($_POST['_task']).'&_action='.urlencode($_POST['_action']); + } + else { + $post_query = ''; + } + + $log_entry = sprintf("%s Error: %s%s (%s %s)", + $program, + $arg_arr['message'], + $arg_arr['file'] ? sprintf(' in %s on line %d', $arg_arr['file'], $arg_arr['line']) : '', + $_SERVER['REQUEST_METHOD'], + $_SERVER['REQUEST_URI'] . $post_query); + + if (!self::write_log('errors', $log_entry)) { + // send error to PHPs error handler if write_log didn't succeed + trigger_error($arg_arr['message']); + } + } + + // report the bug to the global bug reporting system + if ($level & 2) { + // TODO: Send error via HTTP + } + + // show error if debug_mode is on + if ($level & 4) { + print "<b>$program Error"; + + if (!empty($arg_arr['file']) && !empty($arg_arr['line'])) { + print " in $arg_arr[file] ($arg_arr[line])"; + } + + print ':</b> '; + print nl2br($arg_arr['message']); + print '<br />'; + flush(); + } + } + + + /** + * Returns current time (with microseconds). + * + * @return float Current time in seconds since the Unix + */ + public static function timer() + { + return microtime(true); + } + + + /** + * Logs time difference according to provided timer + * + * @param float $timer Timer (self::timer() result) + * @param string $label Log line prefix + * @param string $dest Log file name + * + * @see self::timer() + */ + public static function print_timer($timer, $label = 'Timer', $dest = 'console') + { + static $print_count = 0; + + $print_count++; + $now = self::timer(); + $diff = $now - $timer; + + if (empty($label)) { + $label = 'Timer '.$print_count; + } + + self::write_log($dest, sprintf("%s: %0.4f sec", $label, $diff)); + } + + + /** + * Getter for logged user ID. + * + * @return mixed User identifier + */ + public function get_user_id() + { + if (is_object($this->user)) { + return $this->user->ID; + } + else if (isset($_SESSION['user_id'])) { + return $_SESSION['user_id']; + } + + return null; + } + + + /** + * Getter for logged user name. + * + * @return string User name + */ + public function get_user_name() + { + if (is_object($this->user)) { + return $this->user->get_username(); + } + else if (isset($_SESSION['username'])) { + return $_SESSION['username']; + } + } + + + /** + * Getter for logged user email (derived from user name not identity). + * + * @return string User email address + */ + public function get_user_email() + { + if (is_object($this->user)) { + return $this->user->get_username('mail'); + } + } + + + /** + * Getter for logged user password. + * + * @return string User password + */ + public function get_user_password() + { + if ($this->password) { + return $this->password; + } + else if ($_SESSION['password']) { + return $this->decrypt($_SESSION['password']); + } + } +} + + +/** + * Lightweight plugin API class serving as a dummy if plugins are not enabled + * + * @package Core + */ +class rcube_dummy_plugin_api +{ + /** + * Triggers a plugin hook. + * @see rcube_plugin_api::exec_hook() + */ + public function exec_hook($hook, $args = array()) + { + return $args; + } +} diff --git a/program/lib/Roundcube/rcube_addressbook.php b/program/lib/Roundcube/rcube_addressbook.php new file mode 100644 index 000000000..d14fc587a --- /dev/null +++ b/program/lib/Roundcube/rcube_addressbook.php @@ -0,0 +1,533 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_addressbook.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2006-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Interface to the local address book database | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Abstract skeleton of an address book/repository + * + * @package Framework + * @subpackage Addressbook + */ +abstract class rcube_addressbook +{ + /** constants for error reporting **/ + const ERROR_READ_ONLY = 1; + const ERROR_NO_CONNECTION = 2; + const ERROR_VALIDATE = 3; + const ERROR_SAVING = 4; + const ERROR_SEARCH = 5; + + /** public properties (mandatory) */ + public $primary_key; + public $groups = false; + public $readonly = true; + public $searchonly = false; + public $undelete = false; + public $ready = false; + public $group_id = null; + public $list_page = 1; + public $page_size = 10; + public $sort_col = 'name'; + public $sort_order = 'ASC'; + public $coltypes = array('name' => array('limit'=>1), 'firstname' => array('limit'=>1), 'surname' => array('limit'=>1), 'email' => array('limit'=>1)); + + protected $error; + + /** + * Returns addressbook name (e.g. for addressbooks listing) + */ + abstract function get_name(); + + /** + * Save a search string for future listings + * + * @param mixed Search params to use in listing method, obtained by get_search_set() + */ + abstract function set_search_set($filter); + + /** + * Getter for saved search properties + * + * @return mixed Search properties used by this class + */ + abstract function get_search_set(); + + /** + * Reset saved results and search parameters + */ + abstract function reset(); + + /** + * Refresh saved search set after data has changed + * + * @return mixed New search set + */ + function refresh_search() + { + return $this->get_search_set(); + } + + /** + * List the current set of contact records + * + * @param array List of cols to show + * @param int Only return this number of records, use negative values for tail + * @return array Indexed list of contact records, each a hash array + */ + abstract function list_records($cols=null, $subset=0); + + /** + * Search records + * + * @param array List of fields to search in + * @param string Search value + * @param int Matching mode: + * 0 - partial (*abc*), + * 1 - strict (=), + * 2 - prefix (abc*) + * @param boolean True if results are requested, False if count only + * @param boolean True to skip the count query (select only) + * @param array List of fields that cannot be empty + * @return object rcube_result_set List of contact records and 'count' value + */ + abstract function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()); + + /** + * Count number of available contacts in database + * + * @return rcube_result_set Result set with values for 'count' and 'first' + */ + abstract function count(); + + /** + * Return the last result set + * + * @return rcube_result_set Current result set or NULL if nothing selected yet + */ + abstract function get_result(); + + /** + * Get a specific contact record + * + * @param mixed record identifier(s) + * @param boolean True to return record as associative array, otherwise a result set is returned + * + * @return mixed Result object with all record fields or False if not found + */ + abstract function get_record($id, $assoc=false); + + /** + * Returns the last error occured (e.g. when updating/inserting failed) + * + * @return array Hash array with the following fields: type, message + */ + function get_error() + { + return $this->error; + } + + /** + * Setter for errors for internal use + * + * @param int Error type (one of this class' error constants) + * @param string Error message (name of a text label) + */ + protected function set_error($type, $message) + { + $this->error = array('type' => $type, 'message' => $message); + } + + /** + * Close connection to source + * Called on script shutdown + */ + function close() { } + + /** + * Set internal list page + * + * @param number Page number to list + * @access public + */ + function set_page($page) + { + $this->list_page = (int)$page; + } + + /** + * Set internal page size + * + * @param number Number of messages to display on one page + * @access public + */ + function set_pagesize($size) + { + $this->page_size = (int)$size; + } + + /** + * Set internal sort settings + * + * @param string $sort_col Sort column + * @param string $sort_order Sort order + */ + function set_sort_order($sort_col, $sort_order = null) + { + if ($sort_col != null && ($this->coltypes[$sort_col] || in_array($sort_col, $this->coltypes))) { + $this->sort_col = $sort_col; + } + if ($sort_order != null) { + $this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC'; + } + } + + /** + * Check the given data before saving. + * If input isn't valid, the message to display can be fetched using get_error() + * + * @param array Assoziative array with data to save + * @param boolean Attempt to fix/complete record automatically + * @return boolean True if input is valid, False if not. + */ + public function validate(&$save_data, $autofix = false) + { + $rcmail = rcube::get_instance(); + + // check validity of email addresses + foreach ($this->get_col_values('email', $save_data, true) as $email) { + if (strlen($email)) { + if (!rcube_utils::check_email(rcube_utils::idn_to_ascii($email))) { + $error = $rcmail->gettext(array('name' => 'emailformaterror', 'vars' => array('email' => $email))); + $this->set_error(self::ERROR_VALIDATE, $error); + return false; + } + } + } + + return true; + } + + + /** + * Create a new contact record + * + * @param array Assoziative array with save data + * Keys: Field name with optional section in the form FIELD:SECTION + * Values: Field value. Can be either a string or an array of strings for multiple values + * @param boolean True to check for duplicates first + * @return mixed The created record ID on success, False on error + */ + function insert($save_data, $check=false) + { + /* empty for read-only address books */ + } + + /** + * Create new contact records for every item in the record set + * + * @param object rcube_result_set Recordset to insert + * @param boolean True to check for duplicates first + * @return array List of created record IDs + */ + function insertMultiple($recset, $check=false) + { + $ids = array(); + if (is_object($recset) && is_a($recset, rcube_result_set)) { + while ($row = $recset->next()) { + if ($insert = $this->insert($row, $check)) + $ids[] = $insert; + } + } + return $ids; + } + + /** + * Update a specific contact record + * + * @param mixed Record identifier + * @param array Assoziative array with save data + * Keys: Field name with optional section in the form FIELD:SECTION + * Values: Field value. Can be either a string or an array of strings for multiple values + * @return boolean True on success, False on error + */ + function update($id, $save_cols) + { + /* empty for read-only address books */ + } + + /** + * Mark one or more contact records as deleted + * + * @param array Record identifiers + * @param bool Remove records irreversible (see self::undelete) + */ + function delete($ids, $force=true) + { + /* empty for read-only address books */ + } + + /** + * Unmark delete flag on contact record(s) + * + * @param array Record identifiers + */ + function undelete($ids) + { + /* empty for read-only address books */ + } + + /** + * Mark all records in database as deleted + */ + function delete_all() + { + /* empty for read-only address books */ + } + + /** + * Setter for the current group + * (empty, has to be re-implemented by extending class) + */ + function set_group($gid) { } + + /** + * List all active contact groups of this source + * + * @param string Optional search string to match group name + * @return array Indexed list of contact groups, each a hash array + */ + function list_groups($search = null) + { + /* empty for address books don't supporting groups */ + return array(); + } + + /** + * Get group properties such as name and email address(es) + * + * @param string Group identifier + * @return array Group properties as hash array + */ + function get_group($group_id) + { + /* empty for address books don't supporting groups */ + return null; + } + + /** + * Create a contact group with the given name + * + * @param string The group name + * @return mixed False on error, array with record props in success + */ + function create_group($name) + { + /* empty for address books don't supporting groups */ + return false; + } + + /** + * Delete the given group and all linked group members + * + * @param string Group identifier + * @return boolean True on success, false if no data was changed + */ + function delete_group($gid) + { + /* empty for address books don't supporting groups */ + return false; + } + + /** + * Rename a specific contact group + * + * @param string Group identifier + * @param string New name to set for this group + * @param string New group identifier (if changed, otherwise don't set) + * @return boolean New name on success, false if no data was changed + */ + function rename_group($gid, $newname, &$newid) + { + /* empty for address books don't supporting groups */ + return false; + } + + /** + * Add the given contact records the a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be added + * @return int Number of contacts added + */ + function add_to_group($group_id, $ids) + { + /* empty for address books don't supporting groups */ + return 0; + } + + /** + * Remove the given contact records from a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be removed + * @return int Number of deleted group members + */ + function remove_from_group($group_id, $ids) + { + /* empty for address books don't supporting groups */ + return 0; + } + + /** + * Get group assignments of a specific contact record + * + * @param mixed Record identifier + * + * @return array List of assigned groups as ID=>Name pairs + * @since 0.5-beta + */ + function get_record_groups($id) + { + /* empty for address books don't supporting groups */ + return array(); + } + + + /** + * Utility function to return all values of a certain data column + * either as flat list or grouped by subtype + * + * @param string Col name + * @param array Record data array as used for saving + * @param boolean True to return one array with all values, False for hash array with values grouped by type + * @return array List of column values + */ + function get_col_values($col, $data, $flat = false) + { + $out = array(); + foreach ((array)$data as $c => $values) { + if ($c === $col || strpos($c, $col.':') === 0) { + if ($flat) { + $out = array_merge($out, (array)$values); + } + else { + list($f, $type) = explode(':', $c); + $out[$type] = array_merge((array)$out[$type], (array)$values); + } + } + } + + // remove duplicates + if ($flat && !empty($out)) { + $out = array_unique($out); + } + + return $out; + } + + + /** + * Normalize the given string for fulltext search. + * Currently only optimized for Latin-1 characters; to be extended + * + * @param string Input string (UTF-8) + * @return string Normalized string + * @deprecated since 0.9-beta + */ + protected static function normalize_string($str) + { + return rcube_utils::normalize_string($str); + } + + /** + * Compose a valid display name from the given structured contact data + * + * @param array Hash array with contact data as key-value pairs + * @param bool Don't attempt to extract components from the email address + * + * @return string Display name + */ + public static function compose_display_name($contact, $full_email = false) + { + $contact = rcube::get_instance()->plugins->exec_hook('contact_displayname', $contact); + $fn = $contact['name']; + + if (!$fn) // default display name composition according to vcard standard + $fn = trim(join(' ', array_filter(array($contact['prefix'], $contact['firstname'], $contact['middlename'], $contact['surname'], $contact['suffix'])))); + + // use email address part for name + $email = is_array($contact['email']) ? $contact['email'][0] : $contact['email']; + + if ($email && (empty($fn) || $fn == $email)) { + // return full email + if ($full_email) + return $email; + + list($emailname) = explode('@', $email); + if (preg_match('/(.*)[\.\-\_](.*)/', $emailname, $match)) + $fn = trim(ucfirst($match[1]).' '.ucfirst($match[2])); + else + $fn = ucfirst($emailname); + } + + return $fn; + } + + + /** + * Compose the name to display in the contacts list for the given contact record. + * This respects the settings parameter how to list conacts. + * + * @param array Hash array with contact data as key-value pairs + * @return string List name + */ + public static function compose_list_name($contact) + { + static $compose_mode; + + if (!isset($compose_mode)) // cache this + $compose_mode = rcube::get_instance()->config->get('addressbook_name_listing', 0); + + if ($compose_mode == 3) + $fn = join(' ', array($contact['surname'] . ',', $contact['firstname'], $contact['middlename'])); + else if ($compose_mode == 2) + $fn = join(' ', array($contact['surname'], $contact['firstname'], $contact['middlename'])); + else if ($compose_mode == 1) + $fn = join(' ', array($contact['firstname'], $contact['middlename'], $contact['surname'])); + else + $fn = !empty($contact['name']) ? $contact['name'] : join(' ', array($contact['prefix'], $contact['firstname'], $contact['middlename'], $contact['surname'], $contact['suffix'])); + + $fn = trim($fn, ', '); + + // fallback to display name + if (empty($fn) && $contact['name']) + $fn = $contact['name']; + + // fallback to email address + $email = is_array($contact['email']) ? $contact['email'][0] : $contact['email']; + if (empty($fn) && $email) + return $email; + + return $fn; + } + +} + diff --git a/program/lib/Roundcube/rcube_base_replacer.php b/program/lib/Roundcube/rcube_base_replacer.php new file mode 100644 index 000000000..b2a0fc13c --- /dev/null +++ b/program/lib/Roundcube/rcube_base_replacer.php @@ -0,0 +1,108 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_base_replacer.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Provide basic functions for base URL replacement | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Helper class to turn relative urls into absolute ones + * using a predefined base + * + * @package Framework + * @subpackage Core + * @author Thomas Bruederli <roundcube@gmail.com> + */ +class rcube_base_replacer +{ + private $base_url; + + + public function __construct($base) + { + $this->base_url = $base; + } + + + public function callback($matches) + { + return $matches[1] . '="' . self::absolute_url($matches[3], $this->base_url) . '"'; + } + + + public function replace($body) + { + return preg_replace_callback(array( + '/(src|background|href)=(["\']?)([^"\'\s]+)(\2|\s|>)/Ui', + '/(url\s*\()(["\']?)([^"\'\)\s]+)(\2)\)/Ui', + ), + array($this, 'callback'), $body); + } + + + /** + * Convert paths like ../xxx to an absolute path using a base url + * + * @param string $path Relative path + * @param string $base_url Base URL + * + * @return string Absolute URL + */ + public static function absolute_url($path, $base_url) + { + $host_url = $base_url; + $abs_path = $path; + + // check if path is an absolute URL + if (preg_match('/^[fhtps]+:\/\//', $path)) { + return $path; + } + + // check if path is a content-id scheme + if (strpos($path, 'cid:') === 0) { + return $path; + } + + // cut base_url to the last directory + if (strrpos($base_url, '/') > 7) { + $host_url = substr($base_url, 0, strpos($base_url, '/', 7)); + $base_url = substr($base_url, 0, strrpos($base_url, '/')); + } + + // $path is absolute + if ($path[0] == '/') { + $abs_path = $host_url.$path; + } + else { + // strip './' because its the same as '' + $path = preg_replace('/^\.\//', '', $path); + + if (preg_match_all('/\.\.\//', $path, $matches, PREG_SET_ORDER)) { + foreach ($matches as $a_match) { + if (strrpos($base_url, '/')) { + $base_url = substr($base_url, 0, strrpos($base_url, '/')); + } + $path = substr($path, 3); + } + } + + $abs_path = $base_url.'/'.$path; + } + + return $abs_path; + } +} diff --git a/program/lib/Roundcube/rcube_browser.php b/program/lib/Roundcube/rcube_browser.php new file mode 100644 index 000000000..154e7ef4e --- /dev/null +++ b/program/lib/Roundcube/rcube_browser.php @@ -0,0 +1,73 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_browser.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2007-2009, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Class representing the client browser's properties | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Provide details about the client's browser based on the User-Agent header + * + * @package Framework + * @subpackage Core + */ +class rcube_browser +{ + function __construct() + { + $HTTP_USER_AGENT = strtolower($_SERVER['HTTP_USER_AGENT']); + + $this->ver = 0; + $this->win = strpos($HTTP_USER_AGENT, 'win') != false; + $this->mac = strpos($HTTP_USER_AGENT, 'mac') != false; + $this->linux = strpos($HTTP_USER_AGENT, 'linux') != false; + $this->unix = strpos($HTTP_USER_AGENT, 'unix') != false; + + $this->opera = strpos($HTTP_USER_AGENT, 'opera') !== false; + $this->ns4 = strpos($HTTP_USER_AGENT, 'mozilla/4') !== false && strpos($HTTP_USER_AGENT, 'msie') === false; + $this->ns = ($this->ns4 || strpos($HTTP_USER_AGENT, 'netscape') !== false); + $this->ie = !$this->opera && strpos($HTTP_USER_AGENT, 'compatible; msie') !== false; + $this->khtml = strpos($HTTP_USER_AGENT, 'khtml') !== false; + $this->mz = !$this->ie && !$this->khtml && strpos($HTTP_USER_AGENT, 'mozilla/5') !== false; + $this->chrome = strpos($HTTP_USER_AGENT, 'chrome') !== false; + $this->safari = !$this->chrome && ($this->khtml || strpos($HTTP_USER_AGENT, 'safari') !== false); + + if ($this->ns || $this->chrome) { + $test = preg_match('/(mozilla|chrome)\/([0-9.]+)/', $HTTP_USER_AGENT, $regs); + $this->ver = $test ? (float)$regs[2] : 0; + } + else if ($this->mz) { + $test = preg_match('/rv:([0-9.]+)/', $HTTP_USER_AGENT, $regs); + $this->ver = $test ? (float)$regs[1] : 0; + } + else if ($this->ie || $this->opera) { + $test = preg_match('/(msie|opera) ([0-9.]+)/', $HTTP_USER_AGENT, $regs); + $this->ver = $test ? (float)$regs[2] : 0; + } + + if (preg_match('/ ([a-z]{2})-([a-z]{2})/', $HTTP_USER_AGENT, $regs)) + $this->lang = $regs[1]; + else + $this->lang = 'en'; + + $this->dom = ($this->mz || $this->safari || ($this->ie && $this->ver>=5) || ($this->opera && $this->ver>=7)); + $this->pngalpha = $this->mz || $this->safari || ($this->ie && $this->ver>=5.5) || + ($this->ie && $this->ver>=5 && $this->mac) || ($this->opera && $this->ver>=7) ? true : false; + $this->imgdata = !$this->ie; + } +} + diff --git a/program/lib/Roundcube/rcube_cache.php b/program/lib/Roundcube/rcube_cache.php new file mode 100644 index 000000000..3e1ce4fc8 --- /dev/null +++ b/program/lib/Roundcube/rcube_cache.php @@ -0,0 +1,559 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_cache.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2011, The Roundcube Dev Team | + | Copyright (C) 2011, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Caching engine | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Interface class for accessing Roundcube cache + * + * @package Framework + * @subpackage Cache + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_cache +{ + /** + * Instance of database handler + * + * @var rcube_db|Memcache|bool + */ + private $db; + private $type; + private $userid; + private $prefix; + private $ttl; + private $packed; + private $index; + private $cache = array(); + private $cache_changes = array(); + private $cache_sums = array(); + + + /** + * Object constructor. + * + * @param string $type Engine type ('db' or 'memcache' or 'apc') + * @param int $userid User identifier + * @param string $prefix Key name prefix + * @param string $ttl Expiration time of memcache/apc items + * @param bool $packed Enables/disabled data serialization. + * It's possible to disable data serialization if you're sure + * stored data will be always a safe string + */ + function __construct($type, $userid, $prefix='', $ttl=0, $packed=true) + { + $rcube = rcube::get_instance(); + $type = strtolower($type); + + if ($type == 'memcache') { + $this->type = 'memcache'; + $this->db = $rcube->get_memcache(); + } + else if ($type == 'apc') { + $this->type = 'apc'; + $this->db = function_exists('apc_exists'); // APC 3.1.4 required + } + else { + $this->type = 'db'; + $this->db = $rcube->get_dbh(); + } + + // convert ttl string to seconds + $ttl = get_offset_sec($ttl); + if ($ttl > 2592000) $ttl = 2592000; + + $this->userid = (int) $userid; + $this->ttl = $ttl; + $this->packed = $packed; + $this->prefix = $prefix; + } + + + /** + * Returns cached value. + * + * @param string $key Cache key name + * + * @return mixed Cached value + */ + function get($key) + { + if (!array_key_exists($key, $this->cache)) { + return $this->read_record($key); + } + + return $this->cache[$key]; + } + + + /** + * Sets (add/update) value in cache. + * + * @param string $key Cache key name + * @param mixed $data Cache data + */ + function set($key, $data) + { + $this->cache[$key] = $data; + $this->cache_changed = true; + $this->cache_changes[$key] = true; + } + + + /** + * Returns cached value without storing it in internal memory. + * + * @param string $key Cache key name + * + * @return mixed Cached value + */ + function read($key) + { + if (array_key_exists($key, $this->cache)) { + return $this->cache[$key]; + } + + return $this->read_record($key, true); + } + + + /** + * Sets (add/update) value in cache and immediately saves + * it in the backend, no internal memory will be used. + * + * @param string $key Cache key name + * @param mixed $data Cache data + * + * @param boolean True on success, False on failure + */ + function write($key, $data) + { + return $this->write_record($key, $this->packed ? serialize($data) : $data); + } + + + /** + * Clears the cache. + * + * @param string $key Cache key name or pattern + * @param boolean $prefix_mode Enable it to clear all keys starting + * with prefix specified in $key + */ + function remove($key=null, $prefix_mode=false) + { + // Remove all keys + if ($key === null) { + $this->cache = array(); + $this->cache_changed = false; + $this->cache_changes = array(); + $this->cache_sums = array(); + } + // Remove keys by name prefix + else if ($prefix_mode) { + foreach (array_keys($this->cache) as $k) { + if (strpos($k, $key) === 0) { + $this->cache[$k] = null; + $this->cache_changes[$k] = false; + unset($this->cache_sums[$k]); + } + } + } + // Remove one key by name + else { + $this->cache[$key] = null; + $this->cache_changes[$key] = false; + unset($this->cache_sums[$key]); + } + + // Remove record(s) from the backend + $this->remove_record($key, $prefix_mode); + } + + + /** + * Remove cache records older than ttl + */ + function expunge() + { + if ($this->type == 'db' && $this->db) { + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache'). + " WHERE user_id = ?". + " AND cache_key LIKE ?". + " AND " . $this->db->unixtimestamp('created')." < ?", + $this->userid, + $this->prefix.'.%', + time() - $this->ttl); + } + } + + + /** + * Writes the cache back to the DB. + */ + function close() + { + if (!$this->cache_changed) { + return; + } + + foreach ($this->cache as $key => $data) { + // The key has been used + if ($this->cache_changes[$key]) { + // Make sure we're not going to write unchanged data + // by comparing current md5 sum with the sum calculated on DB read + $data = $this->packed ? serialize($data) : $data; + + if (!$this->cache_sums[$key] || $this->cache_sums[$key] != md5($data)) { + $this->write_record($key, $data); + } + } + } + + $this->write_index(); + } + + + /** + * Reads cache entry. + * + * @param string $key Cache key name + * @param boolean $nostore Enable to skip in-memory store + * + * @return mixed Cached value + */ + private function read_record($key, $nostore=false) + { + if (!$this->db) { + return null; + } + + if ($this->type != 'db') { + if ($this->type == 'memcache') { + $data = $this->db->get($this->ckey($key)); + } + else if ($this->type == 'apc') { + $data = apc_fetch($this->ckey($key)); + } + + if ($data) { + $md5sum = md5($data); + $data = $this->packed ? unserialize($data) : $data; + + if ($nostore) { + return $data; + } + + $this->cache_sums[$key] = $md5sum; + $this->cache[$key] = $data; + } + else { + $this->cache[$key] = null; + } + } + else { + $sql_result = $this->db->limitquery( + "SELECT data, cache_key". + " FROM ".$this->db->table_name('cache'). + " WHERE user_id = ?". + " AND cache_key = ?". + // for better performance we allow more records for one key + // get the newer one + " ORDER BY created DESC", + 0, 1, $this->userid, $this->prefix.'.'.$key); + + if ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $key = substr($sql_arr['cache_key'], strlen($this->prefix)+1); + $md5sum = $sql_arr['data'] ? md5($sql_arr['data']) : null; + if ($sql_arr['data']) { + $data = $this->packed ? unserialize($sql_arr['data']) : $sql_arr['data']; + } + + if ($nostore) { + return $data; + } + + $this->cache[$key] = $data; + $this->cache_sums[$key] = $md5sum; + } + else { + $this->cache[$key] = null; + } + } + + return $this->cache[$key]; + } + + + /** + * Writes single cache record into DB. + * + * @param string $key Cache key name + * @param mxied $data Serialized cache data + * + * @param boolean True on success, False on failure + */ + private function write_record($key, $data) + { + if (!$this->db) { + return false; + } + + if ($this->type == 'memcache' || $this->type == 'apc') { + return $this->add_record($this->ckey($key), $data); + } + + $key_exists = array_key_exists($key, $this->cache_sums); + $key = $this->prefix . '.' . $key; + + // Remove NULL rows (here we don't need to check if the record exist) + if ($data == 'N;') { + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache'). + " WHERE user_id = ?". + " AND cache_key = ?", + $this->userid, $key); + + return true; + } + + // update existing cache record + if ($key_exists) { + $result = $this->db->query( + "UPDATE ".$this->db->table_name('cache'). + " SET created = ". $this->db->now().", data = ?". + " WHERE user_id = ?". + " AND cache_key = ?", + $data, $this->userid, $key); + } + // add new cache record + else { + // for better performance we allow more records for one key + // so, no need to check if record exist (see rcube_cache::read_record()) + $result = $this->db->query( + "INSERT INTO ".$this->db->table_name('cache'). + " (created, user_id, cache_key, data)". + " VALUES (".$this->db->now().", ?, ?, ?)", + $this->userid, $key, $data); + } + + return $this->db->affected_rows($result); + } + + + /** + * Deletes the cache record(s). + * + * @param string $key Cache key name or pattern + * @param boolean $prefix_mode Enable it to clear all keys starting + * with prefix specified in $key + * + */ + private function remove_record($key=null, $prefix_mode=false) + { + if (!$this->db) { + return; + } + + if ($this->type != 'db') { + $this->load_index(); + + // Remove all keys + if ($key === null) { + foreach ($this->index as $key) { + $this->delete_record($key, false); + } + $this->index = array(); + } + // Remove keys by name prefix + else if ($prefix_mode) { + foreach ($this->index as $k) { + if (strpos($k, $key) === 0) { + $this->delete_record($k); + } + } + } + // Remove one key by name + else { + $this->delete_record($key); + } + + return; + } + + // Remove all keys (in specified cache) + if ($key === null) { + $where = " AND cache_key LIKE " . $this->db->quote($this->prefix.'.%'); + } + // Remove keys by name prefix + else if ($prefix_mode) { + $where = " AND cache_key LIKE " . $this->db->quote($this->prefix.'.'.$key.'%'); + } + // Remove one key by name + else { + $where = " AND cache_key = " . $this->db->quote($this->prefix.'.'.$key); + } + + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache'). + " WHERE user_id = ?" . $where, + $this->userid); + } + + + /** + * Adds entry into memcache/apc DB. + * + * @param string $key Cache key name + * @param mxied $data Serialized cache data + * @param bollean $index Enables immediate index update + * + * @param boolean True on success, False on failure + */ + private function add_record($key, $data, $index=false) + { + if ($this->type == 'memcache') { + $result = $this->db->replace($key, $data, MEMCACHE_COMPRESSED, $this->ttl); + if (!$result) + $result = $this->db->set($key, $data, MEMCACHE_COMPRESSED, $this->ttl); + } + else if ($this->type == 'apc') { + if (apc_exists($key)) + apc_delete($key); + $result = apc_store($key, $data, $this->ttl); + } + + // Update index + if ($index && $result) { + $this->load_index(); + + if (array_search($key, $this->index) === false) { + $this->index[] = $key; + $data = serialize($this->index); + $this->add_record($this->ikey(), $data); + } + } + + return $result; + } + + + /** + * Deletes entry from memcache/apc DB. + */ + private function delete_record($key, $index=true) + { + if ($this->type == 'memcache') { + // #1488592: use 2nd argument + $this->db->delete($this->ckey($key), 0); + } + else { + apc_delete($this->ckey($key)); + } + + if ($index) { + if (($idx = array_search($key, $this->index)) !== false) { + unset($this->index[$idx]); + } + } + } + + + /** + * Writes the index entry into memcache/apc DB. + */ + private function write_index() + { + if (!$this->db) { + return; + } + + if ($this->type == 'db') { + return; + } + + $this->load_index(); + + // Make sure index contains new keys + foreach ($this->cache as $key => $value) { + if ($value !== null) { + if (array_search($key, $this->index) === false) { + $this->index[] = $key; + } + } + } + + $data = serialize($this->index); + $this->add_record($this->ikey(), $data); + } + + + /** + * Gets the index entry from memcache/apc DB. + */ + private function load_index() + { + if (!$this->db) { + return; + } + + if ($this->index !== null) { + return; + } + + $index_key = $this->ikey(); + if ($this->type == 'memcache') { + $data = $this->db->get($index_key); + } + else if ($this->type == 'apc') { + $data = apc_fetch($index_key); + } + + $this->index = $data ? unserialize($data) : array(); + } + + + /** + * Creates per-user cache key name (for memcache and apc) + * + * @param string $key Cache key name + * + * @return string Cache key + */ + private function ckey($key) + { + return sprintf('%d:%s:%s', $this->userid, $this->prefix, $key); + } + + + /** + * Creates per-user index cache key name (for memcache and apc) + * + * @return string Cache key + */ + private function ikey() + { + // This way each cache will have its own index + return sprintf('%d:%s%s', $this->userid, $this->prefix, 'INDEX'); + } +} diff --git a/program/lib/Roundcube/rcube_charset.php b/program/lib/Roundcube/rcube_charset.php new file mode 100644 index 000000000..6135a5711 --- /dev/null +++ b/program/lib/Roundcube/rcube_charset.php @@ -0,0 +1,791 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_charset.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | Copyright (C) 2000 Edmund Grimley Evans <edmundo@rano.org> | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Provide charset conversion functionality | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Character sets conversion functionality + * + * @package Framework + * @subpackage Core + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + * @author Edmund Grimley Evans <edmundo@rano.org> + */ +class rcube_charset +{ + // Aliases: some of them from HTML5 spec. + static public $aliases = array( + 'USASCII' => 'WINDOWS-1252', + 'ANSIX31101983' => 'WINDOWS-1252', + 'ANSIX341968' => 'WINDOWS-1252', + 'UNKNOWN8BIT' => 'ISO-8859-15', + 'UNKNOWN' => 'ISO-8859-15', + 'USERDEFINED' => 'ISO-8859-15', + 'KSC56011987' => 'EUC-KR', + 'GB2312' => 'GBK', + 'GB231280' => 'GBK', + 'UNICODE' => 'UTF-8', + 'UTF7IMAP' => 'UTF7-IMAP', + 'TIS620' => 'WINDOWS-874', + 'ISO88599' => 'WINDOWS-1254', + 'ISO885911' => 'WINDOWS-874', + 'MACROMAN' => 'MACINTOSH', + '77' => 'MAC', + '128' => 'SHIFT-JIS', + '129' => 'CP949', + '130' => 'CP1361', + '134' => 'GBK', + '136' => 'BIG5', + '161' => 'WINDOWS-1253', + '162' => 'WINDOWS-1254', + '163' => 'WINDOWS-1258', + '177' => 'WINDOWS-1255', + '178' => 'WINDOWS-1256', + '186' => 'WINDOWS-1257', + '204' => 'WINDOWS-1251', + '222' => 'WINDOWS-874', + '238' => 'WINDOWS-1250', + 'MS950' => 'CP950', + 'WINDOWS949' => 'UHC', + ); + + + /** + * Catch an error and throw an exception. + * + * @param int Level of the error + * @param string Error message + */ + public static function error_handler($errno, $errstr) + { + throw new ErrorException($errstr, 0, $errno); + } + + + /** + * Parse and validate charset name string (see #1485758). + * Sometimes charset string is malformed, there are also charset aliases + * but we need strict names for charset conversion (specially utf8 class) + * + * @param string $input Input charset name + * + * @return string The validated charset name + */ + public static function parse_charset($input) + { + static $charsets = array(); + $charset = strtoupper($input); + + if (isset($charsets[$input])) { + return $charsets[$input]; + } + + $charset = preg_replace(array( + '/^[^0-9A-Z]+/', // e.g. _ISO-8859-JP$SIO + '/\$.*$/', // e.g. _ISO-8859-JP$SIO + '/UNICODE-1-1-*/', // RFC1641/1642 + '/^X-/', // X- prefix (e.g. X-ROMAN8 => ROMAN8) + ), '', $charset); + + if ($charset == 'BINARY') { + return $charsets[$input] = null; + } + + // allow A-Z and 0-9 only + $str = preg_replace('/[^A-Z0-9]/', '', $charset); + + if (isset(self::$aliases[$str])) { + $result = self::$aliases[$str]; + } + // UTF + else if (preg_match('/U[A-Z][A-Z](7|8|16|32)(BE|LE)*/', $str, $m)) { + $result = 'UTF-' . $m[1] . $m[2]; + } + // ISO-8859 + else if (preg_match('/ISO8859([0-9]{0,2})/', $str, $m)) { + $iso = 'ISO-8859-' . ($m[1] ? $m[1] : 1); + // some clients sends windows-1252 text as latin1, + // it is safe to use windows-1252 for all latin1 + $result = $iso == 'ISO-8859-1' ? 'WINDOWS-1252' : $iso; + } + // handle broken charset names e.g. WINDOWS-1250HTTP-EQUIVCONTENT-TYPE + else if (preg_match('/(WIN|WINDOWS)([0-9]+)/', $str, $m)) { + $result = 'WINDOWS-' . $m[2]; + } + // LATIN + else if (preg_match('/LATIN(.*)/', $str, $m)) { + $aliases = array('2' => 2, '3' => 3, '4' => 4, '5' => 9, '6' => 10, + '7' => 13, '8' => 14, '9' => 15, '10' => 16, + 'ARABIC' => 6, 'CYRILLIC' => 5, 'GREEK' => 7, 'GREEK1' => 7, 'HEBREW' => 8 + ); + + // some clients sends windows-1252 text as latin1, + // it is safe to use windows-1252 for all latin1 + if ($m[1] == 1) { + $result = 'WINDOWS-1252'; + } + // if iconv is not supported we need ISO labels, it's also safe for iconv + else if (!empty($aliases[$m[1]])) { + $result = 'ISO-8859-'.$aliases[$m[1]]; + } + // iconv requires convertion of e.g. LATIN-1 to LATIN1 + else { + $result = $str; + } + } + else { + $result = $charset; + } + + $charsets[$input] = $result; + + return $result; + } + + + /** + * Convert a string from one charset to another. + * Uses mbstring and iconv functions if possible + * + * @param string Input string + * @param string Suspected charset of the input string + * @param string Target charset to convert to; defaults to RCUBE_CHARSET + * + * @return string Converted string + */ + public static function convert($str, $from, $to = null) + { + static $iconv_options = null; + static $mbstring_list = null; + static $mbstring_sch = null; + static $conv = null; + + $to = empty($to) ? RCUBE_CHARSET : $to; + $from = self::parse_charset($from); + + // It is a common case when UTF-16 charset is used with US-ASCII content (#1488654) + // In that case we can just skip the conversion (use UTF-8) + if ($from == 'UTF-16' && !preg_match('/[^\x00-\x7F]/', $str)) { + $from = 'UTF-8'; + } + + if ($from == $to || empty($str) || empty($from)) { + return $str; + } + + if ($iconv_options === null) { + if (function_exists('iconv')) { + // ignore characters not available in output charset + $iconv_options = '//IGNORE'; + if (iconv('', $iconv_options, '') === false) { + // iconv implementation does not support options + $iconv_options = ''; + } + } + } + + // convert charset using iconv module + if ($iconv_options !== null && $from != 'UTF7-IMAP' && $to != 'UTF7-IMAP') { + // throw an exception if iconv reports an illegal character in input + // it means that input string has been truncated + set_error_handler(array('rcube_charset', 'error_handler'), E_NOTICE); + try { + $_iconv = iconv($from, $to . $iconv_options, $str); + } catch (ErrorException $e) { + $_iconv = false; + } + restore_error_handler(); + + if ($_iconv !== false) { + return $_iconv; + } + } + + if ($mbstring_list === null) { + if (extension_loaded('mbstring')) { + $mbstring_sch = mb_substitute_character(); + $mbstring_list = mb_list_encodings(); + $mbstring_list = array_map('strtoupper', $mbstring_list); + } + } + + // convert charset using mbstring module + if ($mbstring_list !== null) { + $aliases['WINDOWS-1257'] = 'ISO-8859-13'; + // it happens that mbstring supports ASCII but not US-ASCII + if (($from == 'US-ASCII' || $to == 'US-ASCII') && !in_array('US-ASCII', $mbstring_list)) { + $aliases['US-ASCII'] = 'ASCII'; + } + + $mb_from = $aliases[$from] ? $aliases[$from] : $from; + $mb_to = $aliases[$to] ? $aliases[$to] : $to; + + // return if encoding found, string matches encoding and convert succeeded + if (in_array($mb_from, $mbstring_list) && in_array($mb_to, $mbstring_list)) { + if (mb_check_encoding($str, $mb_from)) { + // Do the same as //IGNORE with iconv + mb_substitute_character('none'); + $out = mb_convert_encoding($str, $mb_to, $mb_from); + mb_substitute_character($mbstring_sch); + + if ($out !== false) { + return $out; + } + } + } + } + + // convert charset using bundled classes/functions + if ($to == 'UTF-8') { + if ($from == 'UTF7-IMAP') { + if ($_str = self::utf7imap_to_utf8($str)) { + return $_str; + } + } + else if ($from == 'UTF-7') { + if ($_str = self::utf7_to_utf8($str)) { + return $_str; + } + } + else if ($from == 'ISO-8859-1' && function_exists('utf8_encode')) { + return utf8_encode($str); + } + else if (class_exists('utf8')) { + if (!$conv) { + $conv = new utf8($from); + } + else { + $conv->loadCharset($from); + } + + if ($_str = $conv->strToUtf8($str)) { + return $_str; + } + } + } + + // encode string for output + if ($from == 'UTF-8') { + // @TODO: we need a function for UTF-7 (RFC2152) conversion + if ($to == 'UTF7-IMAP' || $to == 'UTF-7') { + if ($_str = self::utf8_to_utf7imap($str)) { + return $_str; + } + } + else if ($to == 'ISO-8859-1' && function_exists('utf8_decode')) { + return utf8_decode($str); + } + else if (class_exists('utf8')) { + if (!$conv) { + $conv = new utf8($to); + } + else { + $conv->loadCharset($from); + } + + if ($_str = $conv->strToUtf8($str)) { + return $_str; + } + } + } + + // return original string + return $str; + } + + + /** + * Converts string from standard UTF-7 (RFC 2152) to UTF-8. + * + * @param string Input string (UTF-7) + * + * @return string Converted string (UTF-8) + */ + public static function utf7_to_utf8($str) + { + $Index_64 = array( + 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0, + 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0, + 0,0,0,0, 0,0,0,0, 0,0,0,1, 0,0,0,0, + 1,1,1,1, 1,1,1,1, 1,1,0,0, 0,0,0,0, + 0,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,1, + 1,1,1,1, 1,1,1,1, 1,1,1,0, 0,0,0,0, + 0,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,1, + 1,1,1,1, 1,1,1,1, 1,1,1,0, 0,0,0,0, + ); + + $u7len = strlen($str); + $str = strval($str); + $res = ''; + + for ($i=0; $u7len > 0; $i++, $u7len--) { + $u7 = $str[$i]; + if ($u7 == '+') { + $i++; + $u7len--; + $ch = ''; + + for (; $u7len > 0; $i++, $u7len--) { + $u7 = $str[$i]; + + if (!$Index_64[ord($u7)]) { + break; + } + + $ch .= $u7; + } + + if ($ch == '') { + if ($u7 == '-') { + $res .= '+'; + } + + continue; + } + + $res .= self::utf16_to_utf8(base64_decode($ch)); + } + else { + $res .= $u7; + } + } + + return $res; + } + + + /** + * Converts string from UTF-16 to UTF-8 (helper for utf-7 to utf-8 conversion) + * + * @param string Input string + * + * @return string The converted string + */ + public static function utf16_to_utf8($str) + { + $len = strlen($str); + $dec = ''; + + for ($i = 0; $i < $len; $i += 2) { + $c = ord($str[$i]) << 8 | ord($str[$i + 1]); + if ($c >= 0x0001 && $c <= 0x007F) { + $dec .= chr($c); + } + else if ($c > 0x07FF) { + $dec .= chr(0xE0 | (($c >> 12) & 0x0F)); + $dec .= chr(0x80 | (($c >> 6) & 0x3F)); + $dec .= chr(0x80 | (($c >> 0) & 0x3F)); + } + else { + $dec .= chr(0xC0 | (($c >> 6) & 0x1F)); + $dec .= chr(0x80 | (($c >> 0) & 0x3F)); + } + } + + return $dec; + } + + + /** + * Convert the data ($str) from RFC 2060's UTF-7 to UTF-8. + * If input data is invalid, return the original input string. + * RFC 2060 obviously intends the encoding to be unique (see + * point 5 in section 5.1.3), so we reject any non-canonical + * form, such as &ACY- (instead of &-) or &AMA-&AMA- (instead + * of &AMAAwA-). + * + * Translated from C to PHP by Thomas Bruederli <roundcube@gmail.com> + * + * @param string $str Input string (UTF7-IMAP) + * + * @return string Output string (UTF-8) + */ + public static function utf7imap_to_utf8($str) + { + $Index_64 = array( + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, 63,-1,-1,-1, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1,-1,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 + ); + + $u7len = strlen($str); + $str = strval($str); + $p = ''; + $err = ''; + + for ($i=0; $u7len > 0; $i++, $u7len--) { + $u7 = $str[$i]; + if ($u7 == '&') { + $i++; + $u7len--; + $u7 = $str[$i]; + + if ($u7len && $u7 == '-') { + $p .= '&'; + continue; + } + + $ch = 0; + $k = 10; + for (; $u7len > 0; $i++, $u7len--) { + $u7 = $str[$i]; + + if ((ord($u7) & 0x80) || ($b = $Index_64[ord($u7)]) == -1) { + break; + } + + if ($k > 0) { + $ch |= $b << $k; + $k -= 6; + } + else { + $ch |= $b >> (-$k); + if ($ch < 0x80) { + // Printable US-ASCII + if (0x20 <= $ch && $ch < 0x7f) { + return $err; + } + $p .= chr($ch); + } + else if ($ch < 0x800) { + $p .= chr(0xc0 | ($ch >> 6)); + $p .= chr(0x80 | ($ch & 0x3f)); + } + else { + $p .= chr(0xe0 | ($ch >> 12)); + $p .= chr(0x80 | (($ch >> 6) & 0x3f)); + $p .= chr(0x80 | ($ch & 0x3f)); + } + + $ch = ($b << (16 + $k)) & 0xffff; + $k += 10; + } + } + + // Non-zero or too many extra bits + if ($ch || $k < 6) { + return $err; + } + + // BASE64 not properly terminated + if (!$u7len || $u7 != '-') { + return $err; + } + + // Adjacent BASE64 sections + if ($u7len > 2 && $str[$i+1] == '&' && $str[$i+2] != '-') { + return $err; + } + } + // Not printable US-ASCII + else if (ord($u7) < 0x20 || ord($u7) >= 0x7f) { + return $err; + } + else { + $p .= $u7; + } + } + + return $p; + } + + + /** + * Convert the data ($str) from UTF-8 to RFC 2060's UTF-7. + * Unicode characters above U+FFFF are replaced by U+FFFE. + * If input data is invalid, return an empty string. + * + * Translated from C to PHP by Thomas Bruederli <roundcube@gmail.com> + * + * @param string $str Input string (UTF-8) + * + * @return string Output string (UTF7-IMAP) + */ + public static function utf8_to_utf7imap($str) + { + $B64Chars = array( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', + 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', + 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', + 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', '+', ',' + ); + + $u8len = strlen($str); + $base64 = 0; + $i = 0; + $p = ''; + $err = ''; + + while ($u8len) { + $u8 = $str[$i]; + $c = ord($u8); + + if ($c < 0x80) { + $ch = $c; + $n = 0; + } + else if ($c < 0xc2) { + return $err; + } + else if ($c < 0xe0) { + $ch = $c & 0x1f; + $n = 1; + } + else if ($c < 0xf0) { + $ch = $c & 0x0f; + $n = 2; + } + else if ($c < 0xf8) { + $ch = $c & 0x07; + $n = 3; + } + else if ($c < 0xfc) { + $ch = $c & 0x03; + $n = 4; + } + else if ($c < 0xfe) { + $ch = $c & 0x01; + $n = 5; + } + else { + return $err; + } + + $i++; + $u8len--; + + if ($n > $u8len) { + return $err; + } + + for ($j=0; $j < $n; $j++) { + $o = ord($str[$i+$j]); + if (($o & 0xc0) != 0x80) { + return $err; + } + $ch = ($ch << 6) | ($o & 0x3f); + } + + if ($n > 1 && !($ch >> ($n * 5 + 1))) { + return $err; + } + + $i += $n; + $u8len -= $n; + + if ($ch < 0x20 || $ch >= 0x7f) { + if (!$base64) { + $p .= '&'; + $base64 = 1; + $b = 0; + $k = 10; + } + if ($ch & ~0xffff) { + $ch = 0xfffe; + } + + $p .= $B64Chars[($b | $ch >> $k)]; + $k -= 6; + for (; $k >= 0; $k -= 6) { + $p .= $B64Chars[(($ch >> $k) & 0x3f)]; + } + + $b = ($ch << (-$k)) & 0x3f; + $k += 16; + } + else { + if ($base64) { + if ($k > 10) { + $p .= $B64Chars[$b]; + } + $p .= '-'; + $base64 = 0; + } + + $p .= chr($ch); + if (chr($ch) == '&') { + $p .= '-'; + } + } + } + + if ($base64) { + if ($k > 10) { + $p .= $B64Chars[$b]; + } + $p .= '-'; + } + + return $p; + } + + + /** + * A method to guess character set of a string. + * + * @param string $string String. + * @param string $failover Default result for failover. + * + * @return string Charset name + */ + public static function detect($string, $failover='') + { + if (substr($string, 0, 4) == "\0\0\xFE\xFF") return 'UTF-32BE'; // Big Endian + if (substr($string, 0, 4) == "\xFF\xFE\0\0") return 'UTF-32LE'; // Little Endian + if (substr($string, 0, 2) == "\xFE\xFF") return 'UTF-16BE'; // Big Endian + if (substr($string, 0, 2) == "\xFF\xFE") return 'UTF-16LE'; // Little Endian + if (substr($string, 0, 3) == "\xEF\xBB\xBF") return 'UTF-8'; + + // heuristics + if ($string[0] == "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-32BE'; + if ($string[0] != "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] == "\0") return 'UTF-32LE'; + if ($string[0] == "\0" && $string[1] != "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-16BE'; + if ($string[0] != "\0" && $string[1] == "\0" && $string[2] != "\0" && $string[3] == "\0") return 'UTF-16LE'; + + if (function_exists('mb_detect_encoding')) { + // FIXME: the order is important, because sometimes + // iso string is detected as euc-jp and etc. + $enc = array( + 'UTF-8', 'SJIS', 'GB2312', + 'ISO-8859-1', 'ISO-8859-2', 'ISO-8859-3', 'ISO-8859-4', + 'ISO-8859-5', 'ISO-8859-6', 'ISO-8859-7', 'ISO-8859-8', 'ISO-8859-9', + 'ISO-8859-10', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16', + 'WINDOWS-1252', 'WINDOWS-1251', 'EUC-JP', 'EUC-TW', 'KOI8-R', 'BIG5', + 'ISO-2022-KR', 'ISO-2022-JP', + ); + + $result = mb_detect_encoding($string, join(',', $enc)); + } + else { + // No match, check for UTF-8 + // from http://w3.org/International/questions/qa-forms-utf-8.html + if (preg_match('/\A( + [\x09\x0A\x0D\x20-\x7E] + | [\xC2-\xDF][\x80-\xBF] + | \xE0[\xA0-\xBF][\x80-\xBF] + | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} + | \xED[\x80-\x9F][\x80-\xBF] + | \xF0[\x90-\xBF][\x80-\xBF]{2} + | [\xF1-\xF3][\x80-\xBF]{3} + | \xF4[\x80-\x8F][\x80-\xBF]{2} + )*\z/xs', substr($string, 0, 2048)) + ) { + return 'UTF-8'; + } + } + + return $result ? $result : $failover; + } + + + /** + * Removes non-unicode characters from input. + * + * @param mixed $input String or array. + * + * @return mixed String or array + */ + public static function clean($input) + { + // handle input of type array + if (is_array($input)) { + foreach ($input as $idx => $val) { + $input[$idx] = self::clean($val); + } + return $input; + } + + if (!is_string($input) || $input == '') { + return $input; + } + + // iconv/mbstring are much faster (especially with long strings) + if (function_exists('mb_convert_encoding')) { + if (($res = mb_convert_encoding($input, 'UTF-8', 'UTF-8')) !== false) { + return $res; + } + } + + if (function_exists('iconv')) { + if (($res = @iconv('UTF-8', 'UTF-8//IGNORE', $input)) !== false) { + return $res; + } + } + + $seq = ''; + $out = ''; + $regexp = '/^('. +// '[\x00-\x7F]'. // UTF8-1 + '|[\xC2-\xDF][\x80-\xBF]'. // UTF8-2 + '|\xE0[\xA0-\xBF][\x80-\xBF]'. // UTF8-3 + '|[\xE1-\xEC][\x80-\xBF][\x80-\xBF]'. // UTF8-3 + '|\xED[\x80-\x9F][\x80-\xBF]'. // UTF8-3 + '|[\xEE-\xEF][\x80-\xBF][\x80-\xBF]'. // UTF8-3 + '|\xF0[\x90-\xBF][\x80-\xBF][\x80-\xBF]'. // UTF8-4 + '|[\xF1-\xF3][\x80-\xBF][\x80-\xBF][\x80-\xBF]'.// UTF8-4 + '|\xF4[\x80-\x8F][\x80-\xBF][\x80-\xBF]'. // UTF8-4 + ')$/'; + + for ($i = 0, $len = strlen($input); $i < $len; $i++) { + $chr = $input[$i]; + $ord = ord($chr); + + // 1-byte character + if ($ord <= 0x7F) { + if ($seq) { + $out .= preg_match($regexp, $seq) ? $seq : ''; + } + $seq = ''; + $out .= $chr; + // first (or second) byte of multibyte sequence + } + else if ($ord >= 0xC0) { + if (strlen($seq) > 1) { + $out .= preg_match($regexp, $seq) ? $seq : ''; + $seq = ''; + } + else if ($seq && ord($seq) < 0xC0) { + $seq = ''; + } + $seq .= $chr; + // next byte of multibyte sequence + } + else if ($seq) { + $seq .= $chr; + } + } + + if ($seq) { + $out .= preg_match($regexp, $seq) ? $seq : ''; + } + + return $out; + } + +} diff --git a/program/lib/Roundcube/rcube_config.php b/program/lib/Roundcube/rcube_config.php new file mode 100644 index 000000000..615faf3ad --- /dev/null +++ b/program/lib/Roundcube/rcube_config.php @@ -0,0 +1,441 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_config.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Class to read configuration settings | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Configuration class for Roundcube + * + * @package Framework + * @subpackage Core + */ +class rcube_config +{ + const DEFAULT_SKIN = 'larry'; + + private $prop = array(); + private $errors = array(); + private $userprefs = array(); + + /** + * Renamed options + * + * @var array + */ + private $legacy_props = array( + // new name => old name + 'default_folders' => 'default_imap_folders', + 'mail_pagesize' => 'pagesize', + 'addressbook_pagesize' => 'pagesize', + 'reply_mode' => 'top_posting', + 'refresh_interval' => 'keep_alive', + 'min_refresh_interval' => 'min_keep_alive', + ); + + + /** + * Object constructor + */ + public function __construct() + { + $this->load(); + + // Defaults, that we do not require you to configure, + // but contain information that is used in various + // locations in the code: + $this->set('contactlist_fields', array('name', 'firstname', 'surname', 'email')); + } + + + /** + * Load config from local config file + * + * @todo Remove global $CONFIG + */ + private function load() + { + // load main config file + if (!$this->load_from_file(RCUBE_CONFIG_DIR . 'main.inc.php')) + $this->errors[] = 'main.inc.php was not found.'; + + // load database config + if (!$this->load_from_file(RCUBE_CONFIG_DIR . 'db.inc.php')) + $this->errors[] = 'db.inc.php was not found.'; + + // load host-specific configuration + $this->load_host_config(); + + // set skin (with fallback to old 'skin_path' property) + if (empty($this->prop['skin'])) { + if (!empty($this->prop['skin_path'])) { + $this->prop['skin'] = str_replace('skins/', '', unslashify($this->prop['skin_path'])); + } + else { + $this->prop['skin'] = self::DEFAULT_SKIN; + } + } + + // larry is the new default skin :-) + if ($this->prop['skin'] == 'default') + $this->prop['skin'] = self::DEFAULT_SKIN; + + // fix paths + $this->prop['log_dir'] = $this->prop['log_dir'] ? realpath(unslashify($this->prop['log_dir'])) : RCUBE_INSTALL_PATH . 'logs'; + $this->prop['temp_dir'] = $this->prop['temp_dir'] ? realpath(unslashify($this->prop['temp_dir'])) : RCUBE_INSTALL_PATH . 'temp'; + + // fix default imap folders encoding + foreach (array('drafts_mbox', 'junk_mbox', 'sent_mbox', 'trash_mbox') as $folder) + $this->prop[$folder] = rcube_charset::convert($this->prop[$folder], RCUBE_CHARSET, 'UTF7-IMAP'); + + if (!empty($this->prop['default_folders'])) + foreach ($this->prop['default_folders'] as $n => $folder) + $this->prop['default_folders'][$n] = rcube_charset::convert($folder, RCUBE_CHARSET, 'UTF7-IMAP'); + + // set PHP error logging according to config + if ($this->prop['debug_level'] & 1) { + ini_set('log_errors', 1); + + if ($this->prop['log_driver'] == 'syslog') { + ini_set('error_log', 'syslog'); + } + else { + ini_set('error_log', $this->prop['log_dir'].'/errors'); + } + } + + // enable display_errors in 'show' level, but not for ajax requests + ini_set('display_errors', intval(empty($_REQUEST['_remote']) && ($this->prop['debug_level'] & 4))); + + // set timezone auto settings values + if ($this->prop['timezone'] == 'auto') { + $this->prop['_timezone_value'] = $this->client_timezone(); + } + else if (is_numeric($this->prop['timezone']) && ($tz = timezone_name_from_abbr("", $this->prop['timezone'] * 3600, 0))) { + $this->prop['timezone'] = $tz; + } + else if (empty($this->prop['timezone'])) { + $this->prop['timezone'] = 'UTC'; + } + + // remove deprecated properties + unset($this->prop['dst_active']); + + // export config data + $GLOBALS['CONFIG'] = &$this->prop; + } + + /** + * Load a host-specific config file if configured + * This will merge the host specific configuration with the given one + */ + private function load_host_config() + { + $fname = null; + + if (is_array($this->prop['include_host_config'])) { + $fname = $this->prop['include_host_config'][$_SERVER['HTTP_HOST']]; + } + else if (!empty($this->prop['include_host_config'])) { + $fname = preg_replace('/[^a-z0-9\.\-_]/i', '', $_SERVER['HTTP_HOST']) . '.inc.php'; + } + + if ($fname) { + $this->load_from_file(RCUBE_CONFIG_DIR . $fname); + } + } + + + /** + * Read configuration from a file + * and merge with the already stored config values + * + * @param string $fpath Full path to the config file to be loaded + * @return booelan True on success, false on failure + */ + public function load_from_file($fpath) + { + if (is_file($fpath) && is_readable($fpath)) { + // use output buffering, we don't need any output here + ob_start(); + include($fpath); + ob_end_clean(); + + if (is_array($rcmail_config)) { + $this->prop = array_merge($this->prop, $rcmail_config, $this->userprefs); + return true; + } + } + + return false; + } + + + /** + * Getter for a specific config parameter + * + * @param string $name Parameter name + * @param mixed $def Default value if not set + * @return mixed The requested config value + */ + public function get($name, $def = null) + { + if (isset($this->prop[$name])) { + $result = $this->prop[$name]; + } + else if (isset($this->legacy_props[$name])) { + return $this->get($this->legacy_props[$name], $def); + } + else { + $result = $def; + } + + $rcube = rcube::get_instance(); + + if ($name == 'timezone' && isset($this->prop['_timezone_value'])) { + $result = $this->prop['_timezone_value']; + } + else if ($name == 'client_mimetypes') { + if ($result == null && $def == null) + $result = 'text/plain,text/html,text/xml,image/jpeg,image/gif,image/png,image/bmp,image/tiff,application/x-javascript,application/pdf,application/x-shockwave-flash'; + if ($result && is_string($result)) + $result = explode(',', $result); + } + + $plugin = $rcube->plugins->exec_hook('config_get', array( + 'name' => $name, 'default' => $def, 'result' => $result)); + + return $plugin['result']; + } + + + /** + * Setter for a config parameter + * + * @param string $name Parameter name + * @param mixed $value Parameter value + */ + public function set($name, $value) + { + $this->prop[$name] = $value; + } + + + /** + * Override config options with the given values (eg. user prefs) + * + * @param array $prefs Hash array with config props to merge over + */ + public function merge($prefs) + { + $this->prop = array_merge($this->prop, $prefs, $this->userprefs); + } + + + /** + * Merge the given prefs over the current config + * and make sure that they survive further merging. + * + * @param array $prefs Hash array with user prefs + */ + public function set_user_prefs($prefs) + { + // Honor the dont_override setting for any existing user preferences + $dont_override = $this->get('dont_override'); + if (is_array($dont_override) && !empty($dont_override)) { + foreach ($dont_override as $key) { + unset($prefs[$key]); + } + } + + // convert user's timezone into the new format + if (is_numeric($prefs['timezone']) && ($tz = timezone_name_from_abbr('', $prefs['timezone'] * 3600, 0))) { + $prefs['timezone'] = $tz; + } + + // larry is the new default skin :-) + if ($prefs['skin'] == 'default') { + $prefs['skin'] = self::DEFAULT_SKIN; + } + + $this->userprefs = $prefs; + $this->prop = array_merge($this->prop, $prefs); + + // override timezone settings with client values + if ($this->prop['timezone'] == 'auto') { + $this->prop['_timezone_value'] = isset($_SESSION['timezone']) ? $this->client_timezone() : $this->prop['_timezone_value']; + } + else if (isset($this->prop['_timezone_value'])) + unset($this->prop['_timezone_value']); + } + + + /** + * Getter for all config options + * + * @return array Hash array containg all config properties + */ + public function all() + { + return $this->prop; + } + + /** + * Special getter for user's timezone offset including DST + * + * @return float Timezone offset (in hours) + * @deprecated + */ + public function get_timezone() + { + if ($tz = $this->get('timezone')) { + try { + $tz = new DateTimeZone($tz); + return $tz->getOffset(new DateTime('now')) / 3600; + } + catch (Exception $e) { + } + } + + return 0; + } + + /** + * Return requested DES crypto key. + * + * @param string $key Crypto key name + * @return string Crypto key + */ + public function get_crypto_key($key) + { + // Bomb out if the requested key does not exist + if (!array_key_exists($key, $this->prop)) { + rcube::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Request for unconfigured crypto key \"$key\"" + ), true, true); + } + + $key = $this->prop[$key]; + + // Bomb out if the configured key is not exactly 24 bytes long + if (strlen($key) != 24) { + rcube::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Configured crypto key '$key' is not exactly 24 bytes long" + ), true, true); + } + + return $key; + } + + + /** + * Try to autodetect operating system and find the correct line endings + * + * @return string The appropriate mail header delimiter + */ + public function header_delimiter() + { + // use the configured delimiter for headers + if (!empty($this->prop['mail_header_delimiter'])) { + $delim = $this->prop['mail_header_delimiter']; + if ($delim == "\n" || $delim == "\r\n") + return $delim; + else + rcube::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Invalid mail_header_delimiter setting" + ), true, false); + } + + $php_os = strtolower(substr(PHP_OS, 0, 3)); + + if ($php_os == 'win') + return "\r\n"; + + if ($php_os == 'mac') + return "\r\n"; + + return "\n"; + } + + + /** + * Return the mail domain configured for the given host + * + * @param string $host IMAP host + * @param boolean $encode If true, domain name will be converted to IDN ASCII + * @return string Resolved SMTP host + */ + public function mail_domain($host, $encode=true) + { + $domain = $host; + + if (is_array($this->prop['mail_domain'])) { + if (isset($this->prop['mail_domain'][$host])) + $domain = $this->prop['mail_domain'][$host]; + } + else if (!empty($this->prop['mail_domain'])) { + $domain = rcube_utils::parse_host($this->prop['mail_domain']); + } + + if ($encode) { + $domain = rcube_utils::idn_to_ascii($domain); + } + + return $domain; + } + + + /** + * Getter for error state + * + * @return mixed Error message on error, False if no errors + */ + public function get_error() + { + return empty($this->errors) ? false : join("\n", $this->errors); + } + + + /** + * Internal getter for client's (browser) timezone identifier + */ + private function client_timezone() + { + if (isset($_SESSION['timezone']) && is_numeric($_SESSION['timezone']) + && ($ctz = timezone_name_from_abbr("", $_SESSION['timezone'] * 3600, 0))) { + return $ctz; + } + else if (!empty($_SESSION['timezone'])) { + try { + $tz = timezone_open($_SESSION['timezone']); + return $tz->getName(); + } + catch (Exception $e) { /* gracefully ignore */ } + } + + // fallback to server's timezone + return date_default_timezone_get(); + } + +} diff --git a/program/lib/Roundcube/rcube_contacts.php b/program/lib/Roundcube/rcube_contacts.php new file mode 100644 index 000000000..5b4292a4c --- /dev/null +++ b/program/lib/Roundcube/rcube_contacts.php @@ -0,0 +1,1002 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_contacts.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2006-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Interface to the local address book database | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Model class for the local address book database + * + * @package Framework + * @subpackage Addressbook + */ +class rcube_contacts extends rcube_addressbook +{ + // protected for backward compat. with some plugins + protected $db_name = 'contacts'; + protected $db_groups = 'contactgroups'; + protected $db_groupmembers = 'contactgroupmembers'; + protected $vcard_fieldmap = array(); + + /** + * Store database connection. + * + * @var rcube_db + */ + private $db = null; + private $user_id = 0; + private $filter = null; + private $result = null; + private $cache; + private $table_cols = array('name', 'email', 'firstname', 'surname'); + private $fulltext_cols = array('name', 'firstname', 'surname', 'middlename', 'nickname', + 'jobtitle', 'organization', 'department', 'maidenname', 'email', 'phone', + 'address', 'street', 'locality', 'zipcode', 'region', 'country', 'website', 'im', 'notes'); + + // public properties + public $primary_key = 'contact_id'; + public $name; + public $readonly = false; + public $groups = true; + public $undelete = true; + public $list_page = 1; + public $page_size = 10; + public $group_id = 0; + public $ready = false; + public $coltypes = array('name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', + 'jobtitle', 'organization', 'department', 'assistant', 'manager', + 'gender', 'maidenname', 'spouse', 'email', 'phone', 'address', + 'birthday', 'anniversary', 'website', 'im', 'notes', 'photo'); + + const SEPARATOR = ','; + + + /** + * Object constructor + * + * @param object Instance of the rcube_db class + * @param integer User-ID + */ + function __construct($dbconn, $user) + { + $this->db = $dbconn; + $this->user_id = $user; + $this->ready = $this->db && !$this->db->is_error(); + } + + + /** + * Returns addressbook name + */ + function get_name() + { + return $this->name; + } + + + /** + * Save a search string for future listings + * + * @param string SQL params to use in listing method + */ + function set_search_set($filter) + { + $this->filter = $filter; + $this->cache = null; + } + + + /** + * Getter for saved search properties + * + * @return mixed Search properties used by this class + */ + function get_search_set() + { + return $this->filter; + } + + + /** + * Setter for the current group + * (empty, has to be re-implemented by extending class) + */ + function set_group($gid) + { + $this->group_id = $gid; + $this->cache = null; + } + + + /** + * Reset all saved results and search parameters + */ + function reset() + { + $this->result = null; + $this->filter = null; + $this->cache = null; + } + + + /** + * List all active contact groups of this source + * + * @param string Search string to match group name + * @return array Indexed list of contact groups, each a hash array + */ + function list_groups($search = null) + { + $results = array(); + + if (!$this->groups) + return $results; + + $sql_filter = $search ? " AND " . $this->db->ilike('name', '%'.$search.'%') : ''; + + $sql_result = $this->db->query( + "SELECT * FROM ".$this->db->table_name($this->db_groups). + " WHERE del<>1". + " AND user_id=?". + $sql_filter. + " ORDER BY name", + $this->user_id); + + while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $sql_arr['ID'] = $sql_arr['contactgroup_id']; + $results[] = $sql_arr; + } + + return $results; + } + + + /** + * Get group properties such as name and email address(es) + * + * @param string Group identifier + * @return array Group properties as hash array + */ + function get_group($group_id) + { + $sql_result = $this->db->query( + "SELECT * FROM ".$this->db->table_name($this->db_groups). + " WHERE del<>1". + " AND contactgroup_id=?". + " AND user_id=?", + $group_id, $this->user_id); + + if ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $sql_arr['ID'] = $sql_arr['contactgroup_id']; + return $sql_arr; + } + + return null; + } + + /** + * List the current set of contact records + * + * @param array List of cols to show, Null means all + * @param int Only return this number of records, use negative values for tail + * @param boolean True to skip the count query (select only) + * @return array Indexed list of contact records, each a hash array + */ + function list_records($cols=null, $subset=0, $nocount=false) + { + if ($nocount || $this->list_page <= 1) { + // create dummy result, we don't need a count now + $this->result = new rcube_result_set(); + } else { + // count all records + $this->result = $this->count(); + } + + $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; + $length = $subset != 0 ? abs($subset) : $this->page_size; + + if ($this->group_id) + $join = " LEFT JOIN ".$this->db->table_name($this->db_groupmembers)." AS m". + " ON (m.contact_id = c.".$this->primary_key.")"; + + $order_col = (in_array($this->sort_col, $this->table_cols) ? $this->sort_col : 'name'); + $order_cols = array('c.'.$order_col); + if ($order_col == 'firstname') + $order_cols[] = 'c.surname'; + else if ($order_col == 'surname') + $order_cols[] = 'c.firstname'; + if ($order_col != 'name') + $order_cols[] = 'c.name'; + $order_cols[] = 'c.email'; + + $sql_result = $this->db->limitquery( + "SELECT * FROM ".$this->db->table_name($this->db_name)." AS c" . + $join . + " WHERE c.del<>1" . + " AND c.user_id=?" . + ($this->group_id ? " AND m.contactgroup_id=?" : ""). + ($this->filter ? " AND (".$this->filter.")" : "") . + " ORDER BY ". $this->db->concat($order_cols) . + " " . $this->sort_order, + $start_row, + $length, + $this->user_id, + $this->group_id); + + // determine whether we have to parse the vcard or if only db cols are requested + $read_vcard = !$cols || count(array_intersect($cols, $this->table_cols)) < count($cols); + + while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $sql_arr['ID'] = $sql_arr[$this->primary_key]; + + if ($read_vcard) + $sql_arr = $this->convert_db_data($sql_arr); + else { + $sql_arr['email'] = explode(self::SEPARATOR, $sql_arr['email']); + $sql_arr['email'] = array_map('trim', $sql_arr['email']); + } + + $this->result->add($sql_arr); + } + + $cnt = count($this->result->records); + + // update counter + if ($nocount) + $this->result->count = $cnt; + else if ($this->list_page <= 1) { + if ($cnt < $this->page_size && $subset == 0) + $this->result->count = $cnt; + else if (isset($this->cache['count'])) + $this->result->count = $this->cache['count']; + else + $this->result->count = $this->_count(); + } + + return $this->result; + } + + + /** + * Search contacts + * + * @param mixed $fields The field name of array of field names to search in + * @param mixed $value Search value (or array of values when $fields is array) + * @param int $mode Matching mode: + * 0 - partial (*abc*), + * 1 - strict (=), + * 2 - prefix (abc*) + * @param boolean $select True if results are requested, False if count only + * @param boolean $nocount True to skip the count query (select only) + * @param array $required List of fields that cannot be empty + * + * @return object rcube_result_set Contact records and 'count' value + */ + function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()) + { + if (!is_array($fields)) + $fields = array($fields); + if (!is_array($required) && !empty($required)) + $required = array($required); + + $where = $and_where = array(); + $mode = intval($mode); + $WS = ' '; + $AS = self::SEPARATOR; + + foreach ($fields as $idx => $col) { + // direct ID search + if ($col == 'ID' || $col == $this->primary_key) { + $ids = !is_array($value) ? explode(self::SEPARATOR, $value) : $value; + $ids = $this->db->array2list($ids, 'integer'); + $where[] = 'c.' . $this->primary_key.' IN ('.$ids.')'; + continue; + } + // fulltext search in all fields + else if ($col == '*') { + $words = array(); + foreach (explode($WS, rcube_utils::normalize_string($value)) as $word) { + switch ($mode) { + case 1: // strict + $words[] = '(' . $this->db->ilike('words', $word . '%') + . ' OR ' . $this->db->ilike('words', '%' . $WS . $word . $WS . '%') + . ' OR ' . $this->db->ilike('words', '%' . $WS . $word) . ')'; + break; + case 2: // prefix + $words[] = '(' . $this->db->ilike('words', $word . '%') + . ' OR ' . $this->db->ilike('words', '%' . $WS . $word . '%') . ')'; + break; + default: // partial + $words[] = $this->db->ilike('words', '%' . $word . '%'); + } + } + $where[] = '(' . join(' AND ', $words) . ')'; + } + else { + $val = is_array($value) ? $value[$idx] : $value; + // table column + if (in_array($col, $this->table_cols)) { + switch ($mode) { + case 1: // strict + $where[] = '(' . $this->db->quoteIdentifier($col) . ' = ' . $this->db->quote($val) + . ' OR ' . $this->db->ilike($col, $val . $AS . '%') + . ' OR ' . $this->db->ilike($col, '%' . $AS . $val . $AS . '%') + . ' OR ' . $this->db->ilike($col, '%' . $AS . $val) . ')'; + break; + case 2: // prefix + $where[] = '(' . $this->db->ilike($col, $val . '%') + . ' OR ' . $this->db->ilike($col, $AS . $val . '%') . ')'; + break; + default: // partial + $where[] = $this->db->ilike($col, '%' . $val . '%'); + } + } + // vCard field + else { + if (in_array($col, $this->fulltext_cols)) { + foreach (rcube_utils::normalize_string($val, true) as $word) { + switch ($mode) { + case 1: // strict + $words[] = '(' . $this->db->ilike('words', $word . $WS . '%') + . ' OR ' . $this->db->ilike('words', '%' . $AS . $word . $WS .'%') + . ' OR ' . $this->db->ilike('words', '%' . $AS . $word) . ')'; + break; + case 2: // prefix + $words[] = '(' . $this->db->ilike('words', $word . '%') + . ' OR ' . $this->db->ilike('words', $AS . $word . '%') . ')'; + break; + default: // partial + $words[] = $this->db->ilike('words', '%' . $word . '%'); + } + } + $where[] = '(' . join(' AND ', $words) . ')'; + } + if (is_array($value)) + $post_search[$col] = mb_strtolower($val); + } + } + } + + foreach (array_intersect($required, $this->table_cols) as $col) { + $and_where[] = $this->db->quoteIdentifier($col).' <> '.$this->db->quote(''); + } + + if (!empty($where)) { + // use AND operator for advanced searches + $where = join(is_array($value) ? ' AND ' : ' OR ', $where); + } + + if (!empty($and_where)) + $where = ($where ? "($where) AND " : '') . join(' AND ', $and_where); + + // Post-searching in vCard data fields + // we will search in all records and then build a where clause for their IDs + if (!empty($post_search)) { + $ids = array(0); + // build key name regexp + $regexp = '/^(' . implode(array_keys($post_search), '|') . ')(?:.*)$/'; + // use initial WHERE clause, to limit records number if possible + if (!empty($where)) + $this->set_search_set($where); + + // count result pages + $cnt = $this->count(); + $pages = ceil($cnt / $this->page_size); + $scnt = count($post_search); + + // get (paged) result + for ($i=0; $i<$pages; $i++) { + $this->list_records(null, $i, true); + while ($row = $this->result->next()) { + $id = $row[$this->primary_key]; + $found = array(); + foreach (preg_grep($regexp, array_keys($row)) as $col) { + $pos = strpos($col, ':'); + $colname = $pos ? substr($col, 0, $pos) : $col; + $search = $post_search[$colname]; + foreach ((array)$row[$col] as $value) { + // composite field, e.g. address + foreach ((array)$value as $val) { + $val = mb_strtolower($val); + switch ($mode) { + case 1: + $got = ($val == $search); + break; + case 2: + $got = ($search == substr($val, 0, strlen($search))); + break; + default: + $got = (strpos($val, $search) !== false); + break; + } + + if ($got) { + $found[$colname] = true; + break 2; + } + } + } + } + // all fields match + if (count($found) >= $scnt) { + $ids[] = $id; + } + } + } + + // build WHERE clause + $ids = $this->db->array2list($ids, 'integer'); + $where = 'c.' . $this->primary_key.' IN ('.$ids.')'; + // reset counter + unset($this->cache['count']); + + // when we know we have an empty result + if ($ids == '0') { + $this->set_search_set($where); + return ($this->result = new rcube_result_set(0, 0)); + } + } + + if (!empty($where)) { + $this->set_search_set($where); + if ($select) + $this->list_records(null, 0, $nocount); + else + $this->result = $this->count(); + } + + return $this->result; + } + + + /** + * Count number of available contacts in database + * + * @return rcube_result_set Result object + */ + function count() + { + $count = isset($this->cache['count']) ? $this->cache['count'] : $this->_count(); + + return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); + } + + + /** + * Count number of available contacts in database + * + * @return int Contacts count + */ + private function _count() + { + if ($this->group_id) + $join = " LEFT JOIN ".$this->db->table_name($this->db_groupmembers)." AS m". + " ON (m.contact_id=c.".$this->primary_key.")"; + + // count contacts for this user + $sql_result = $this->db->query( + "SELECT COUNT(c.contact_id) AS rows". + " FROM ".$this->db->table_name($this->db_name)." AS c". + $join. + " WHERE c.del<>1". + " AND c.user_id=?". + ($this->group_id ? " AND m.contactgroup_id=?" : ""). + ($this->filter ? " AND (".$this->filter.")" : ""), + $this->user_id, + $this->group_id + ); + + $sql_arr = $this->db->fetch_assoc($sql_result); + + $this->cache['count'] = (int) $sql_arr['rows']; + + return $this->cache['count']; + } + + + /** + * Return the last result set + * + * @return mixed Result array or NULL if nothing selected yet + */ + function get_result() + { + return $this->result; + } + + + /** + * Get a specific contact record + * + * @param mixed record identifier(s) + * @return mixed Result object with all record fields or False if not found + */ + function get_record($id, $assoc=false) + { + // return cached result + if ($this->result && ($first = $this->result->first()) && $first[$this->primary_key] == $id) + return $assoc ? $first : $this->result; + + $this->db->query( + "SELECT * FROM ".$this->db->table_name($this->db_name). + " WHERE contact_id=?". + " AND user_id=?". + " AND del<>1", + $id, + $this->user_id + ); + + if ($sql_arr = $this->db->fetch_assoc()) { + $record = $this->convert_db_data($sql_arr); + $this->result = new rcube_result_set(1); + $this->result->add($record); + } + + return $assoc && $record ? $record : $this->result; + } + + + /** + * Get group assignments of a specific contact record + * + * @param mixed Record identifier + * @return array List of assigned groups as ID=>Name pairs + */ + function get_record_groups($id) + { + $results = array(); + + if (!$this->groups) + return $results; + + $sql_result = $this->db->query( + "SELECT cgm.contactgroup_id, cg.name FROM " . $this->db->table_name($this->db_groupmembers) . " AS cgm" . + " LEFT JOIN " . $this->db->table_name($this->db_groups) . " AS cg ON (cgm.contactgroup_id = cg.contactgroup_id AND cg.del<>1)" . + " WHERE cgm.contact_id=?", + $id + ); + while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $results[$sql_arr['contactgroup_id']] = $sql_arr['name']; + } + + return $results; + } + + + /** + * Check the given data before saving. + * If input not valid, the message to display can be fetched using get_error() + * + * @param array Assoziative array with data to save + * @param boolean Try to fix/complete record automatically + * @return boolean True if input is valid, False if not. + */ + public function validate(&$save_data, $autofix = false) + { + // validate e-mail addresses + $valid = parent::validate($save_data, $autofix); + + // require at least one e-mail address (syntax check is already done) + if ($valid && !array_filter($this->get_col_values('email', $save_data, true))) { + $this->set_error(self::ERROR_VALIDATE, 'noemailwarning'); + $valid = false; + } + + return $valid; + } + + + /** + * Create a new contact record + * + * @param array Associative array with save data + * @return integer|boolean The created record ID on success, False on error + */ + function insert($save_data, $check=false) + { + if (!is_array($save_data)) + return false; + + $insert_id = $existing = false; + + if ($check) { + foreach ($save_data as $col => $values) { + if (strpos($col, 'email') === 0) { + foreach ((array)$values as $email) { + if ($existing = $this->search('email', $email, false, false)) + break 2; + } + } + } + } + + $save_data = $this->convert_save_data($save_data); + $a_insert_cols = $a_insert_values = array(); + + foreach ($save_data as $col => $value) { + $a_insert_cols[] = $this->db->quoteIdentifier($col); + $a_insert_values[] = $this->db->quote($value); + } + + if (!$existing->count && !empty($a_insert_cols)) { + $this->db->query( + "INSERT INTO ".$this->db->table_name($this->db_name). + " (user_id, changed, del, ".join(', ', $a_insert_cols).")". + " VALUES (".intval($this->user_id).", ".$this->db->now().", 0, ".join(', ', $a_insert_values).")" + ); + + $insert_id = $this->db->insert_id($this->db_name); + } + + // also add the newly created contact to the active group + if ($insert_id && $this->group_id) + $this->add_to_group($this->group_id, $insert_id); + + $this->cache = null; + + return $insert_id; + } + + + /** + * Update a specific contact record + * + * @param mixed Record identifier + * @param array Assoziative array with save data + * @return boolean True on success, False on error + */ + function update($id, $save_cols) + { + $updated = false; + $write_sql = array(); + $record = $this->get_record($id, true); + $save_cols = $this->convert_save_data($save_cols, $record); + + foreach ($save_cols as $col => $value) { + $write_sql[] = sprintf("%s=%s", $this->db->quoteIdentifier($col), $this->db->quote($value)); + } + + if (!empty($write_sql)) { + $this->db->query( + "UPDATE ".$this->db->table_name($this->db_name). + " SET changed=".$this->db->now().", ".join(', ', $write_sql). + " WHERE contact_id=?". + " AND user_id=?". + " AND del<>1", + $id, + $this->user_id + ); + + $updated = $this->db->affected_rows(); + $this->result = null; // clear current result (from get_record()) + } + + return $updated; + } + + + private function convert_db_data($sql_arr) + { + $record = array(); + $record['ID'] = $sql_arr[$this->primary_key]; + + if ($sql_arr['vcard']) { + unset($sql_arr['email']); + $vcard = new rcube_vcard($sql_arr['vcard'], RCUBE_CHARSET, false, $this->vcard_fieldmap); + $record += $vcard->get_assoc() + $sql_arr; + } + else { + $record += $sql_arr; + $record['email'] = explode(self::SEPARATOR, $record['email']); + $record['email'] = array_map('trim', $record['email']); + } + + return $record; + } + + + private function convert_save_data($save_data, $record = array()) + { + $out = array(); + $words = ''; + + // copy values into vcard object + $vcard = new rcube_vcard($record['vcard'] ? $record['vcard'] : $save_data['vcard'], RCUBE_CHARSET, false, $this->vcard_fieldmap); + $vcard->reset(); + foreach ($save_data as $key => $values) { + list($field, $section) = explode(':', $key); + $fulltext = in_array($field, $this->fulltext_cols); + foreach ((array)$values as $value) { + if (isset($value)) + $vcard->set($field, $value, $section); + if ($fulltext && is_array($value)) + $words .= ' ' . rcube_utils::normalize_string(join(" ", $value)); + else if ($fulltext && strlen($value) >= 3) + $words .= ' ' . rcube_utils::normalize_string($value); + } + } + $out['vcard'] = $vcard->export(false); + + foreach ($this->table_cols as $col) { + $key = $col; + if (!isset($save_data[$key])) + $key .= ':home'; + if (isset($save_data[$key])) { + if (is_array($save_data[$key])) + $out[$col] = join(self::SEPARATOR, $save_data[$key]); + else + $out[$col] = $save_data[$key]; + } + } + + // save all e-mails in database column + $out['email'] = join(self::SEPARATOR, $vcard->email); + + // join words for fulltext search + $out['words'] = join(" ", array_unique(explode(" ", $words))); + + return $out; + } + + + /** + * Mark one or more contact records as deleted + * + * @param array Record identifiers + * @param boolean Remove record(s) irreversible (unsupported) + */ + function delete($ids, $force=true) + { + if (!is_array($ids)) + $ids = explode(self::SEPARATOR, $ids); + + $ids = $this->db->array2list($ids, 'integer'); + + // flag record as deleted (always) + $this->db->query( + "UPDATE ".$this->db->table_name($this->db_name). + " SET del=1, changed=".$this->db->now(). + " WHERE user_id=?". + " AND contact_id IN ($ids)", + $this->user_id + ); + + $this->cache = null; + + return $this->db->affected_rows(); + } + + + /** + * Undelete one or more contact records + * + * @param array Record identifiers + */ + function undelete($ids) + { + if (!is_array($ids)) + $ids = explode(self::SEPARATOR, $ids); + + $ids = $this->db->array2list($ids, 'integer'); + + // clear deleted flag + $this->db->query( + "UPDATE ".$this->db->table_name($this->db_name). + " SET del=0, changed=".$this->db->now(). + " WHERE user_id=?". + " AND contact_id IN ($ids)", + $this->user_id + ); + + $this->cache = null; + + return $this->db->affected_rows(); + } + + + /** + * Remove all records from the database + */ + function delete_all() + { + $this->cache = null; + + $this->db->query("UPDATE ".$this->db->table_name($this->db_name). + " SET del=1, changed=".$this->db->now(). + " WHERE user_id = ?", $this->user_id); + + return $this->db->affected_rows(); + } + + + /** + * Create a contact group with the given name + * + * @param string The group name + * @return mixed False on error, array with record props in success + */ + function create_group($name) + { + $result = false; + + // make sure we have a unique name + $name = $this->unique_groupname($name); + + $this->db->query( + "INSERT INTO ".$this->db->table_name($this->db_groups). + " (user_id, changed, name)". + " VALUES (".intval($this->user_id).", ".$this->db->now().", ".$this->db->quote($name).")" + ); + + if ($insert_id = $this->db->insert_id($this->db_groups)) + $result = array('id' => $insert_id, 'name' => $name); + + return $result; + } + + + /** + * Delete the given group (and all linked group members) + * + * @param string Group identifier + * @return boolean True on success, false if no data was changed + */ + function delete_group($gid) + { + // flag group record as deleted + $sql_result = $this->db->query( + "UPDATE ".$this->db->table_name($this->db_groups). + " SET del=1, changed=".$this->db->now(). + " WHERE contactgroup_id=?". + " AND user_id=?", + $gid, $this->user_id + ); + + $this->cache = null; + + return $this->db->affected_rows(); + } + + + /** + * Rename a specific contact group + * + * @param string Group identifier + * @param string New name to set for this group + * @return boolean New name on success, false if no data was changed + */ + function rename_group($gid, $newname, &$new_gid) + { + // make sure we have a unique name + $name = $this->unique_groupname($newname); + + $sql_result = $this->db->query( + "UPDATE ".$this->db->table_name($this->db_groups). + " SET name=?, changed=".$this->db->now(). + " WHERE contactgroup_id=?". + " AND user_id=?", + $name, $gid, $this->user_id + ); + + return $this->db->affected_rows() ? $name : false; + } + + + /** + * Add the given contact records the a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be added + * @return int Number of contacts added + */ + function add_to_group($group_id, $ids) + { + if (!is_array($ids)) + $ids = explode(self::SEPARATOR, $ids); + + $added = 0; + $exists = array(); + + // get existing assignments ... + $sql_result = $this->db->query( + "SELECT contact_id FROM ".$this->db->table_name($this->db_groupmembers). + " WHERE contactgroup_id=?". + " AND contact_id IN (".$this->db->array2list($ids, 'integer').")", + $group_id + ); + while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $exists[] = $sql_arr['contact_id']; + } + // ... and remove them from the list + $ids = array_diff($ids, $exists); + + foreach ($ids as $contact_id) { + $this->db->query( + "INSERT INTO ".$this->db->table_name($this->db_groupmembers). + " (contactgroup_id, contact_id, created)". + " VALUES (?, ?, ".$this->db->now().")", + $group_id, + $contact_id + ); + + if ($error = $this->db->is_error()) + $this->set_error(self::ERROR_SAVING, $error); + else + $added++; + } + + return $added; + } + + + /** + * Remove the given contact records from a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be removed + * @return int Number of deleted group members + */ + function remove_from_group($group_id, $ids) + { + if (!is_array($ids)) + $ids = explode(self::SEPARATOR, $ids); + + $ids = $this->db->array2list($ids, 'integer'); + + $sql_result = $this->db->query( + "DELETE FROM ".$this->db->table_name($this->db_groupmembers). + " WHERE contactgroup_id=?". + " AND contact_id IN ($ids)", + $group_id + ); + + return $this->db->affected_rows(); + } + + + /** + * Check for existing groups with the same name + * + * @param string Name to check + * @return string A group name which is unique for the current use + */ + private function unique_groupname($name) + { + $checkname = $name; + $num = 2; $hit = false; + + do { + $sql_result = $this->db->query( + "SELECT 1 FROM ".$this->db->table_name($this->db_groups). + " WHERE del<>1". + " AND user_id=?". + " AND name=?", + $this->user_id, + $checkname); + + // append number to make name unique + if ($hit = $this->db->fetch_array($sql_result)) { + $checkname = $name . ' ' . $num++; + } + } while ($hit); + + return $checkname; + } + +} diff --git a/program/lib/Roundcube/rcube_content_filter.php b/program/lib/Roundcube/rcube_content_filter.php new file mode 100644 index 000000000..99916a300 --- /dev/null +++ b/program/lib/Roundcube/rcube_content_filter.php @@ -0,0 +1,60 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_content_filter.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2011, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | PHP stream filter to detect evil content in mail attachments | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +/** + * PHP stream filter to detect html/javascript code in attachments + * + * @package Framework + * @subpackage Core + */ +class rcube_content_filter extends php_user_filter +{ + private $buffer = ''; + private $cutoff = 2048; + + function onCreate() + { + $this->cutoff = rand(2048, 3027); + return true; + } + + function filter($in, $out, &$consumed, $closing) + { + while ($bucket = stream_bucket_make_writeable($in)) { + $this->buffer .= $bucket->data; + + // check for evil content and abort + if (preg_match('/<(script|iframe|object)/i', $this->buffer)) { + return PSFS_ERR_FATAL; + } + + // keep buffer small enough + if (strlen($this->buffer) > 4096) { + $this->buffer = substr($this->buffer, $this->cutoff); + } + + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } +} diff --git a/program/lib/Roundcube/rcube_csv2vcard.php b/program/lib/Roundcube/rcube_csv2vcard.php new file mode 100644 index 000000000..850c0c4c3 --- /dev/null +++ b/program/lib/Roundcube/rcube_csv2vcard.php @@ -0,0 +1,382 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_csv2vcard.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | CSV to vCard data conversion | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * CSV to vCard data converter + * + * @package Framework + * @subpackage Addressbook + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_csv2vcard +{ + /** + * CSV to vCard fields mapping + * + * @var array + */ + protected $csv2vcard_map = array( + // MS Outlook 2010 + 'anniversary' => 'anniversary', + 'assistants_name' => 'assistant', + 'assistants_phone' => 'phone:assistant', + 'birthday' => 'birthday', + 'business_city' => 'locality:work', + 'business_countryregion' => 'country:work', + 'business_fax' => 'phone:work,fax', + 'business_phone' => 'phone:work', + 'business_phone_2' => 'phone:work2', + 'business_postal_code' => 'zipcode:work', + 'business_state' => 'region:work', + 'business_street' => 'street:work', + //'business_street_2' => '', + //'business_street_3' => '', + 'car_phone' => 'phone:car', + 'categories' => 'categories', + //'children' => '', + 'company' => 'organization', + //'company_main_phone' => '', + 'department' => 'department', + //'email_2_address' => '', //@TODO + //'email_2_type' => '', + //'email_3_address' => '', //@TODO + //'email_3_type' => '', + 'email_address' => 'email:main', + //'email_type' => '', + 'first_name' => 'firstname', + 'gender' => 'gender', + 'home_city' => 'locality:home', + 'home_countryregion' => 'country:home', + 'home_fax' => 'phone:home,fax', + 'home_phone' => 'phone:home', + 'home_phone_2' => 'phone:home2', + 'home_postal_code' => 'zipcode:home', + 'home_state' => 'region:home', + 'home_street' => 'street:home', + //'home_street_2' => '', + //'home_street_3' => '', + //'initials' => '', + //'isdn' => '', + 'job_title' => 'jobtitle', + //'keywords' => '', + //'language' => '', + 'last_name' => 'surname', + //'location' => '', + 'managers_name' => 'manager', + 'middle_name' => 'middlename', + //'mileage' => '', + 'mobile_phone' => 'phone:cell', + 'notes' => 'notes', + //'office_location' => '', + 'other_city' => 'locality:other', + 'other_countryregion' => 'country:other', + 'other_fax' => 'phone:other,fax', + 'other_phone' => 'phone:other', + 'other_postal_code' => 'zipcode:other', + 'other_state' => 'region:other', + 'other_street' => 'street:other', + //'other_street_2' => '', + //'other_street_3' => '', + 'pager' => 'phone:pager', + 'primary_phone' => 'phone:pref', + //'profession' => '', + //'radio_phone' => '', + 'spouse' => 'spouse', + 'suffix' => 'suffix', + 'title' => 'title', + 'web_page' => 'website:homepage', + + // Thunderbird + 'birth_day' => 'birthday-d', + 'birth_month' => 'birthday-m', + 'birth_year' => 'birthday-y', + 'display_name' => 'displayname', + 'fax_number' => 'phone:fax', + 'home_address' => 'street:home', + //'home_address_2' => '', + 'home_country' => 'country:home', + 'home_zipcode' => 'zipcode:home', + 'mobile_number' => 'phone:cell', + 'nickname' => 'nickname', + 'organization' => 'organization', + 'pager_number' => 'phone:pager', + 'primary_email' => 'email:pref', + 'secondary_email' => 'email:other', + 'web_page_1' => 'website:homepage', + 'web_page_2' => 'website:other', + 'work_phone' => 'phone:work', + 'work_address' => 'street:work', + //'work_address_2' => '', + 'work_country' => 'country:work', + 'work_zipcode' => 'zipcode:work', + ); + + /** + * CSV label to text mapping for English + * + * @var array + */ + protected $label_map = array( + // MS Outlook 2010 + 'anniversary' => "Anniversary", + 'assistants_name' => "Assistant's Name", + 'assistants_phone' => "Assistant's Phone", + 'birthday' => "Birthday", + 'business_city' => "Business City", + 'business_countryregion' => "Business Country/Region", + 'business_fax' => "Business Fax", + 'business_phone' => "Business Phone", + 'business_phone_2' => "Business Phone 2", + 'business_postal_code' => "Business Postal Code", + 'business_state' => "Business State", + 'business_street' => "Business Street", + //'business_street_2' => "Business Street 2", + //'business_street_3' => "Business Street 3", + 'car_phone' => "Car Phone", + 'categories' => "Categories", + //'children' => "Children", + 'company' => "Company", + //'company_main_phone' => "Company Main Phone", + 'department' => "Department", + //'directory_server' => "Directory Server", + //'email_2_address' => "E-mail 2 Address", + //'email_2_type' => "E-mail 2 Type", + //'email_3_address' => "E-mail 3 Address", + //'email_3_type' => "E-mail 3 Type", + 'email_address' => "E-mail Address", + //'email_type' => "E-mail Type", + 'first_name' => "First Name", + 'gender' => "Gender", + 'home_city' => "Home City", + 'home_countryregion' => "Home Country/Region", + 'home_fax' => "Home Fax", + 'home_phone' => "Home Phone", + 'home_phone_2' => "Home Phone 2", + 'home_postal_code' => "Home Postal Code", + 'home_state' => "Home State", + 'home_street' => "Home Street", + //'home_street_2' => "Home Street 2", + //'home_street_3' => "Home Street 3", + //'initials' => "Initials", + //'isdn' => "ISDN", + 'job_title' => "Job Title", + //'keywords' => "Keywords", + //'language' => "Language", + 'last_name' => "Last Name", + //'location' => "Location", + 'managers_name' => "Manager's Name", + 'middle_name' => "Middle Name", + //'mileage' => "Mileage", + 'mobile_phone' => "Mobile Phone", + 'notes' => "Notes", + //'office_location' => "Office Location", + 'other_city' => "Other City", + 'other_countryregion' => "Other Country/Region", + 'other_fax' => "Other Fax", + 'other_phone' => "Other Phone", + 'other_postal_code' => "Other Postal Code", + 'other_state' => "Other State", + 'other_street' => "Other Street", + //'other_street_2' => "Other Street 2", + //'other_street_3' => "Other Street 3", + 'pager' => "Pager", + 'primary_phone' => "Primary Phone", + //'profession' => "Profession", + //'radio_phone' => "Radio Phone", + 'spouse' => "Spouse", + 'suffix' => "Suffix", + 'title' => "Title", + 'web_page' => "Web Page", + + // Thunderbird + 'birth_day' => "Birth Day", + 'birth_month' => "Birth Month", + 'birth_year' => "Birth Year", + 'display_name' => "Display Name", + 'fax_number' => "Fax Number", + 'home_address' => "Home Address", + //'home_address_2' => "Home Address 2", + 'home_country' => "Home Country", + 'home_zipcode' => "Home ZipCode", + 'mobile_number' => "Mobile Number", + 'nickname' => "Nickname", + 'organization' => "Organization", + 'pager_number' => "Pager Namber", + 'primary_email' => "Primary Email", + 'secondary_email' => "Secondary Email", + 'web_page_1' => "Web Page 1", + 'web_page_2' => "Web Page 2", + 'work_phone' => "Work Phone", + 'work_address' => "Work Address", + //'work_address_2' => "Work Address 2", + 'work_country' => "Work Country", + 'work_zipcode' => "Work ZipCode", + ); + + protected $local_label_map = array(); + protected $vcards = array(); + protected $map = array(); + + + /** + * Class constructor + * + * @param string $lang File language + */ + public function __construct($lang = 'en_US') + { + // Localize fields map + if ($lang && $lang != 'en_US') { + if (file_exists(RCUBE_LOCALIZATION_DIR . "$lang/csv2vcard.inc")) { + include RCUBE_LOCALIZATION_DIR . "$lang/csv2vcard.inc"; + } + + if (!empty($map)) { + $this->local_label_map = array_merge($this->label_map, $map); + } + } + + $this->label_map = array_flip($this->label_map); + $this->local_label_map = array_flip($this->local_label_map); + } + + /** + * + */ + public function import($csv) + { + // convert to UTF-8 + $head = substr($csv, 0, 4096); + $fallback = rcube::get_instance()->config->get('default_charset', 'ISO-8859-1'); // fallback to Latin-1? + $charset = rcube_charset::detect($head, RCUBE_CHARSET); + $csv = rcube_charset::convert($csv, $charset); + $head = ''; + + $this->map = array(); + + // Parse file + foreach (preg_split("/[\r\n]+/", $csv) as $i => $line) { + $line = trim($line); + if (empty($line)) { + continue; + } + + $elements = rcube_utils::explode_quoted_string(',', $line); + + if (empty($elements)) { + continue; + } + + // Parse header + if (empty($this->map)) { + $this->parse_header($elements); + if (empty($this->map)) { + break; + } + } + // Parse data row + else { + $this->csv_to_vcard($elements); + } + } + } + + /** + * @return array rcube_vcard List of vcards + */ + public function export() + { + return $this->vcards; + } + + /** + * Parse CSV header line, detect fields mapping + */ + protected function parse_header($elements) + { + $map1 = array(); + $map2 = array(); + $size = count($elements); + + // check English labels + for ($i = 0; $i < $size; $i++) { + $label = $this->label_map[$elements[$i]]; + if ($label && !empty($this->csv2vcard_map[$label])) { + $map1[$i] = $this->csv2vcard_map[$label]; + } + } + // check localized labels + if (!empty($this->local_label_map)) { + for ($i = 0; $i < $size; $i++) { + $label = $this->local_label_map[$elements[$i]]; + if ($label && !empty($this->csv2vcard_map[$label])) { + $map2[$i] = $this->csv2vcard_map[$label]; + } + } + } + + $this->map = count($map1) >= count($map2) ? $map1 : $map2; + } + + /** + * Convert CSV data row to vCard + */ + protected function csv_to_vcard($data) + { + $contact = array(); + foreach ($this->map as $idx => $name) { + $value = $data[$idx]; + if ($value !== null && $value !== '') { + $contact[$name] = $value; + } + } + + if (empty($contact)) { + return; + } + + // Handle special values + if (!empty($contact['birthday-d']) && !empty($contact['birthday-m']) && !empty($contact['birthday-y'])) { + $contact['birthday'] = $contact['birthday-y'] .'-' .$contact['birthday-m'] . '-' . $contact['birthday-d']; + } + + foreach (array('birthday', 'anniversary') as $key) { + if (!empty($contact[$key]) && $contact[$key] == '0/0/00') { // @TODO: localization? + unset($contact[$key]); + } + } + + if (!empty($contact['gender']) && ($gender = strtolower($contact['gender']))) { + if (!in_array($gender, array('male', 'female'))) { + unset($contact['gender']); + } + } + + // Create vcard object + $vcard = new rcube_vcard(); + foreach ($contact as $name => $value) { + $name = explode(':', $name); + $vcard->set($name[0], $value, $name[1]); + } + + // add to the list + $this->vcards[] = $vcard; + } +} diff --git a/program/lib/Roundcube/rcube_db.php b/program/lib/Roundcube/rcube_db.php new file mode 100644 index 000000000..5d8c4a534 --- /dev/null +++ b/program/lib/Roundcube/rcube_db.php @@ -0,0 +1,1009 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface. + * This is a wrapper for the PHP PDO. + * + * @package Framework + * @sbpackage Database + */ +class rcube_db +{ + public $db_provider; + + protected $db_dsnw; // DSN for write operations + protected $db_dsnr; // DSN for read operations + protected $db_connected = false; // Already connected ? + protected $db_mode; // Connection mode + protected $dbh; // Connection handle + + protected $db_error = false; + protected $db_error_msg = ''; + protected $conn_failure = false; + protected $a_query_results = array('dummy'); + protected $last_res_id = 0; + protected $db_index = 0; + protected $tables; + protected $variables; + + protected $options = array( + // column/table quotes + 'identifier_start' => '"', + 'identifier_end' => '"', + ); + + + /** + * Factory, returns driver-specific instance of the class + * + * @param string $db_dsnw DSN for read/write operations + * @param string $db_dsnr Optional DSN for read only operations + * @param bool $pconn Enables persistent connections + * + * @return rcube_db Object instance + */ + public static function factory($db_dsnw, $db_dsnr = '', $pconn = false) + { + $driver = strtolower(substr($db_dsnw, 0, strpos($db_dsnw, ':'))); + $driver_map = array( + 'sqlite2' => 'sqlite', + 'sybase' => 'mssql', + 'dblib' => 'mssql', + 'mysqli' => 'mysql', + ); + + $driver = isset($driver_map[$driver]) ? $driver_map[$driver] : $driver; + $class = "rcube_db_$driver"; + + if (!class_exists($class)) { + rcube::raise_error(array('code' => 600, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => "Configuration error. Unsupported database driver: $driver"), + true, true); + } + + return new $class($db_dsnw, $db_dsnr, $pconn); + } + + /** + * Object constructor + * + * @param string $db_dsnw DSN for read/write operations + * @param string $db_dsnr Optional DSN for read only operations + * @param bool $pconn Enables persistent connections + */ + public function __construct($db_dsnw, $db_dsnr = '', $pconn = false) + { + if (empty($db_dsnr)) { + $db_dsnr = $db_dsnw; + } + + $this->db_dsnw = $db_dsnw; + $this->db_dsnr = $db_dsnr; + $this->db_pconn = $pconn; + + $this->db_dsnw_array = self::parse_dsn($db_dsnw); + $this->db_dsnr_array = self::parse_dsn($db_dsnr); + + // Initialize driver class + $this->init(); + } + + /** + * Initialization of the object with driver specific code + */ + protected function init() + { + // To be used by driver classes + } + + /** + * Connect to specific database + * + * @param array $dsn DSN for DB connections + * + * @return PDO database handle + */ + protected function dsn_connect($dsn) + { + $this->db_error = false; + $this->db_error_msg = null; + + // Get database specific connection options + $dsn_string = $this->dsn_string($dsn); + $dsn_options = $this->dsn_options($dsn); + + if ($db_pconn) { + $dsn_options[PDO::ATTR_PERSISTENT] = true; + } + + // Connect + try { + // with this check we skip fatal error on PDO object creation + if (!class_exists('PDO', false)) { + throw new Exception('PDO extension not loaded. See http://php.net/manual/en/intro.pdo.php'); + } + + $this->conn_prepare($dsn); + + $dbh = new PDO($dsn_string, $dsn['username'], $dsn['password'], $dsn_options); + + // don't throw exceptions or warnings + $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); + } + catch (Exception $e) { + $this->db_error = true; + $this->db_error_msg = $e->getMessage(); + + rcube::raise_error(array('code' => 500, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => $this->db_error_msg), true, false); + + return null; + } + + $this->conn_configure($dsn, $dbh); + + return $dbh; + } + + /** + * Driver-specific preparation of database connection + * + * @param array $dsn DSN for DB connections + */ + protected function conn_prepare($dsn) + { + } + + /** + * Driver-specific configuration of database connection + * + * @param array $dsn DSN for DB connections + * @param PDO $dbh Connection handler + */ + protected function conn_configure($dsn, $dbh) + { + } + + /** + * Driver-specific database character set setting + * + * @param string $charset Character set name + */ + protected function set_charset($charset) + { + $this->query("SET NAMES 'utf8'"); + } + + /** + * Connect to appropriate database depending on the operation + * + * @param string $mode Connection mode (r|w) + */ + public function db_connect($mode) + { + // previous connection failed, don't attempt to connect again + if ($this->conn_failure) { + return; + } + + // no replication + if ($this->db_dsnw == $this->db_dsnr) { + $mode = 'w'; + } + + // Already connected + if ($this->db_connected) { + // connected to db with the same or "higher" mode + if ($this->db_mode == 'w' || $this->db_mode == $mode) { + return; + } + } + + $dsn = ($mode == 'r') ? $this->db_dsnr_array : $this->db_dsnw_array; + + $this->dbh = $this->dsn_connect($dsn); + $this->db_connected = is_object($this->dbh); + + // use write-master when read-only fails + if (!$this->db_connected && $mode == 'r') { + $mode = 'w'; + $this->dbh = $this->dsn_connect($this->db_dsnw_array); + $this->db_connected = is_object($this->dbh); + } + + if ($this->db_connected) { + $this->db_mode = $mode; + $this->set_charset('utf8'); + } + else { + $this->conn_failure = true; + } + } + + /** + * Activate/deactivate debug mode + * + * @param boolean $dbg True if SQL queries should be logged + */ + public function set_debug($dbg = true) + { + $this->options['debug_mode'] = $dbg; + } + + /** + * Writes debug information/query to 'sql' log file + * + * @param string $query SQL query + */ + protected function debug($query) + { + if ($this->options['debug_mode']) { + rcube::write_log('sql', '[' . (++$this->db_index) . '] ' . $query . ';'); + } + } + + /** + * Getter for error state + * + * @param int $res_id Optional query result identifier + * + * @return string Error message + */ + public function is_error($res_id = null) + { + if ($res_id !== null) { + return $this->_get_result($res_id) === false ? $this->db_error_msg : null; + } + + return $this->db_error ? $this->db_error_msg : null; + } + + /** + * Connection state checker + * + * @return boolean True if in connected state + */ + public function is_connected() + { + return !is_object($this->dbh) ? false : $this->db_connected; + } + + /** + * Is database replication configured? + * + * @return bool Returns true if dsnw != dsnr + */ + public function is_replicated() + { + return !empty($this->db_dsnr) && $this->db_dsnw != $this->db_dsnr; + } + + /** + * Get database runtime variables + * + * @param string $varname Variable name + * @param mixed $default Default value if variable is not set + * + * @return mixed Variable value or default + */ + public function get_variable($varname, $default = null) + { + // to be implemented by driver class + return $default; + } + + /** + * Execute a SQL query + * + * @param string SQL query to execute + * @param mixed Values to be inserted in query + * + * @return number Query handle identifier + */ + public function query() + { + $params = func_get_args(); + $query = array_shift($params); + + // Support one argument of type array, instead of n arguments + if (count($params) == 1 && is_array($params[0])) { + $params = $params[0]; + } + + return $this->_query($query, 0, 0, $params); + } + + /** + * Execute a SQL query with limits + * + * @param string SQL query to execute + * @param int Offset for LIMIT statement + * @param int Number of rows for LIMIT statement + * @param mixed Values to be inserted in query + * + * @return int Query handle identifier + */ + public function limitquery() + { + $params = func_get_args(); + $query = array_shift($params); + $offset = array_shift($params); + $numrows = array_shift($params); + + return $this->_query($query, $offset, $numrows, $params); + } + + /** + * Execute a SQL query with limits + * + * @param string $query SQL query to execute + * @param int $offset Offset for LIMIT statement + * @param int $numrows Number of rows for LIMIT statement + * @param array $params Values to be inserted in query + * + * @return int Query handle identifier + */ + protected function _query($query, $offset, $numrows, $params) + { + // Read or write ? + $mode = preg_match('/^(select|show)/i', ltrim($query)) ? 'r' : 'w'; + + $this->db_connect($mode); + + // check connection before proceeding + if (!$this->is_connected()) { + return null; + } + + if ($numrows || $offset) { + $query = $this->set_limit($query, $numrows, $offset); + } + + $params = (array) $params; + + // Because in Roundcube we mostly use queries that are + // executed only once, we will not use prepared queries + $pos = 0; + $idx = 0; + + while ($pos = strpos($query, '?', $pos)) { + if ($query[$pos+1] == '?') { // skip escaped ? + $pos += 2; + } + else { + $val = $this->quote($params[$idx++]); + unset($params[$idx-1]); + $query = substr_replace($query, $val, $pos, 1); + $pos += strlen($val); + } + } + + // replace escaped ? back to normal + $query = rtrim(strtr($query, array('??' => '?')), ';'); + + $this->debug($query); + + $query = $this->dbh->query($query); + + if ($query === false) { + $error = $this->dbh->errorInfo(); + $this->db_error = true; + $this->db_error_msg = sprintf('[%s] %s', $error[1], $error[2]); + + rcube::raise_error(array('code' => 500, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => $this->db_error_msg), true, false); + } + + // add result, even if it's an error + return $this->_add_result($query); + } + + /** + * Get number of affected rows for the last query + * + * @param number $res_id Optional query handle identifier + * + * @return int Number of rows or false on failure + */ + public function affected_rows($res_id = null) + { + if ($result = $this->_get_result($res_id)) { + return $result->rowCount(); + } + + return 0; + } + + /** + * Get last inserted record ID + * + * @param string $table Table name (to find the incremented sequence) + * + * @return mixed ID or false on failure + */ + public function insert_id($table = '') + { + if (!$this->db_connected || $this->db_mode == 'r') { + return false; + } + + if ($table) { + // resolve table name + $table = $this->table_name($table); + } + + $id = $this->dbh->lastInsertId($table); + + return $id; + } + + /** + * Get an associative array for one row + * If no query handle is specified, the last query will be taken as reference + * + * @param int $res_id Optional query handle identifier + * + * @return mixed Array with col values or false on failure + */ + public function fetch_assoc($res_id = null) + { + $result = $this->_get_result($res_id); + return $this->_fetch_row($result, PDO::FETCH_ASSOC); + } + + /** + * Get an index array for one row + * If no query handle is specified, the last query will be taken as reference + * + * @param int $res_id Optional query handle identifier + * + * @return mixed Array with col values or false on failure + */ + public function fetch_array($res_id = null) + { + $result = $this->_get_result($res_id); + return $this->_fetch_row($result, PDO::FETCH_NUM); + } + + /** + * Get col values for a result row + * + * @param PDOStatement $result Result handle + * @param int $mode Fetch mode identifier + * + * @return mixed Array with col values or false on failure + */ + protected function _fetch_row($result, $mode) + { + if (!is_object($result) || !$this->is_connected()) { + return false; + } + + return $result->fetch($mode); + } + + /** + * Adds LIMIT,OFFSET clauses to the query + * + * @param string $query SQL query + * @param int $limit Number of rows + * @param int $offset Offset + * + * @return string SQL query + */ + protected function set_limit($query, $limit = 0, $offset = 0) + { + if ($limit) { + $query .= ' LIMIT ' . intval($limit); + } + + if ($offset) { + $query .= ' OFFSET ' . intval($offset); + } + + return $query; + } + + /** + * Returns list of tables in a database + * + * @return array List of all tables of the current database + */ + public function list_tables() + { + // get tables if not cached + if ($this->tables === null) { + $q = $this->query('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_NAME'); + + if ($res = $this->_get_result($q)) { + $this->tables = $res->fetchAll(PDO::FETCH_COLUMN, 0); + } + else { + $this->tables = array(); + } + } + + return $this->tables; + } + + /** + * Returns list of columns in database table + * + * @param string $table Table name + * + * @return array List of table cols + */ + public function list_cols($table) + { + $q = $this->query('SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ?', + array($table)); + + if ($res = $this->_get_result($q)) { + return $res->fetchAll(PDO::FETCH_COLUMN, 0); + } + + return array(); + } + + /** + * Formats input so it can be safely used in a query + * + * @param mixed $input Value to quote + * @param string $type Type of data + * + * @return string Quoted/converted string for use in query + */ + public function quote($input, $type = null) + { + // handle int directly for better performance + if ($type == 'integer' || $type == 'int') { + return intval($input); + } + + if (is_null($input)) { + return 'NULL'; + } + + // create DB handle if not available + if (!$this->dbh) { + $this->db_connect('r'); + } + + if ($this->dbh) { + $map = array( + 'bool' => PDO::PARAM_BOOL, + 'integer' => PDO::PARAM_INT, + ); + $type = isset($map[$type]) ? $map[$type] : PDO::PARAM_STR; + return strtr($this->dbh->quote($input, $type), array('?' => '??')); // escape ? + } + + return 'NULL'; + } + + /** + * Quotes a string so it can be safely used as a table or column name + * + * @param string $str Value to quote + * + * @return string Quoted string for use in query + * @deprecated Replaced by rcube_db::quote_identifier + * @see rcube_db::quote_identifier + */ + public function quoteIdentifier($str) + { + return $this->quote_identifier($str); + } + + /** + * Quotes a string so it can be safely used as a table or column name + * + * @param string $str Value to quote + * + * @return string Quoted string for use in query + */ + public function quote_identifier($str) + { + $start = $this->options['identifier_start']; + $end = $this->options['identifier_end']; + $name = array(); + + foreach (explode('.', $str) as $elem) { + $elem = str_replace(array($start, $end), '', $elem); + $name[] = $start . $elem . $end; + } + + return implode($name, '.'); + } + + /** + * Return SQL function for current time and date + * + * @return string SQL function to use in query + */ + public function now() + { + return "now()"; + } + + /** + * Return list of elements for use with SQL's IN clause + * + * @param array $arr Input array + * @param string $type Type of data + * + * @return string Comma-separated list of quoted values for use in query + */ + public function array2list($arr, $type = null) + { + if (!is_array($arr)) { + return $this->quote($arr, $type); + } + + foreach ($arr as $idx => $item) { + $arr[$idx] = $this->quote($item, $type); + } + + return implode(',', $arr); + } + + /** + * Return SQL statement to convert a field value into a unix timestamp + * + * This method is deprecated and should not be used anymore due to limitations + * of timestamp functions in Mysql (year 2038 problem) + * + * @param string $field Field name + * + * @return string SQL statement to use in query + * @deprecated + */ + public function unixtimestamp($field) + { + return "UNIX_TIMESTAMP($field)"; + } + + /** + * Return SQL statement to convert from a unix timestamp + * + * @param int $timestamp Unix timestamp + * + * @return string Date string in db-specific format + */ + public function fromunixtime($timestamp) + { + return date("'Y-m-d H:i:s'", $timestamp); + } + + /** + * Return SQL statement for case insensitive LIKE + * + * @param string $column Field name + * @param string $value Search value + * + * @return string SQL statement to use in query + */ + public function ilike($column, $value) + { + return $this->quote_identifier($column).' LIKE '.$this->quote($value); + } + + /** + * Abstract SQL statement for value concatenation + * + * @return string SQL statement to be used in query + */ + public function concat(/* col1, col2, ... */) + { + $args = func_get_args(); + if (is_array($args[0])) { + $args = $args[0]; + } + + return '(' . join(' || ', $args) . ')'; + } + + /** + * Encodes non-UTF-8 characters in string/array/object (recursive) + * + * @param mixed $input Data to fix + * + * @return mixed Properly UTF-8 encoded data + */ + public static function encode($input) + { + if (is_object($input)) { + foreach (get_object_vars($input) as $idx => $value) { + $input->$idx = self::encode($value); + } + return $input; + } + else if (is_array($input)) { + foreach ($input as $idx => $value) { + $input[$idx] = self::encode($value); + } + return $input; + } + + return utf8_encode($input); + } + + /** + * Decodes encoded UTF-8 string/object/array (recursive) + * + * @param mixed $input Input data + * + * @return mixed Decoded data + */ + public static function decode($input) + { + if (is_object($input)) { + foreach (get_object_vars($input) as $idx => $value) { + $input->$idx = self::decode($value); + } + return $input; + } + else if (is_array($input)) { + foreach ($input as $idx => $value) { + $input[$idx] = self::decode($value); + } + return $input; + } + + return utf8_decode($input); + } + + /** + * Adds a query result and returns a handle ID + * + * @param object $res Query handle + * + * @return int Handle ID + */ + protected function _add_result($res) + { + $this->last_res_id = sizeof($this->a_query_results); + $this->a_query_results[$this->last_res_id] = $res; + + return $this->last_res_id; + } + + /** + * Resolves a given handle ID and returns the according query handle + * If no ID is specified, the last resource handle will be returned + * + * @param int $res_id Handle ID + * + * @return mixed Resource handle or false on failure + */ + protected function _get_result($res_id = null) + { + if ($res_id == null) { + $res_id = $this->last_res_id; + } + + if (!empty($this->a_query_results[$res_id])) { + return $this->a_query_results[$res_id]; + } + + return false; + } + + /** + * Return correct name for a specific database table + * + * @param string $table Table name + * + * @return string Translated table name + */ + public function table_name($table) + { + $rcube = rcube::get_instance(); + + // return table name if configured + $config_key = 'db_table_'.$table; + + if ($name = $rcube->config->get($config_key)) { + return $name; + } + + return $table; + } + + /** + * MDB2 DSN string parser + * + * @param string $sequence Secuence name + * + * @return array DSN parameters + */ + public static function parse_dsn($dsn) + { + if (empty($dsn)) { + return null; + } + + // Find phptype and dbsyntax + if (($pos = strpos($dsn, '://')) !== false) { + $str = substr($dsn, 0, $pos); + $dsn = substr($dsn, $pos + 3); + } + else { + $str = $dsn; + $dsn = null; + } + + // Get phptype and dbsyntax + // $str => phptype(dbsyntax) + if (preg_match('|^(.+?)\((.*?)\)$|', $str, $arr)) { + $parsed['phptype'] = $arr[1]; + $parsed['dbsyntax'] = !$arr[2] ? $arr[1] : $arr[2]; + } + else { + $parsed['phptype'] = $str; + $parsed['dbsyntax'] = $str; + } + + if (empty($dsn)) { + return $parsed; + } + + // Get (if found): username and password + // $dsn => username:password@protocol+hostspec/database + if (($at = strrpos($dsn,'@')) !== false) { + $str = substr($dsn, 0, $at); + $dsn = substr($dsn, $at + 1); + if (($pos = strpos($str, ':')) !== false) { + $parsed['username'] = rawurldecode(substr($str, 0, $pos)); + $parsed['password'] = rawurldecode(substr($str, $pos + 1)); + } + else { + $parsed['username'] = rawurldecode($str); + } + } + + // Find protocol and hostspec + + // $dsn => proto(proto_opts)/database + if (preg_match('|^([^(]+)\((.*?)\)/?(.*?)$|', $dsn, $match)) { + $proto = $match[1]; + $proto_opts = $match[2] ? $match[2] : false; + $dsn = $match[3]; + } + // $dsn => protocol+hostspec/database (old format) + else { + if (strpos($dsn, '+') !== false) { + list($proto, $dsn) = explode('+', $dsn, 2); + } + if ( strpos($dsn, '//') === 0 + && strpos($dsn, '/', 2) !== false + && $parsed['phptype'] == 'oci8' + ) { + //oracle's "Easy Connect" syntax: + //"username/password@[//]host[:port][/service_name]" + //e.g. "scott/tiger@//mymachine:1521/oracle" + $proto_opts = $dsn; + $pos = strrpos($proto_opts, '/'); + $dsn = substr($proto_opts, $pos + 1); + $proto_opts = substr($proto_opts, 0, $pos); + } + else if (strpos($dsn, '/') !== false) { + list($proto_opts, $dsn) = explode('/', $dsn, 2); + } + else { + $proto_opts = $dsn; + $dsn = null; + } + } + + // process the different protocol options + $parsed['protocol'] = (!empty($proto)) ? $proto : 'tcp'; + $proto_opts = rawurldecode($proto_opts); + if (strpos($proto_opts, ':') !== false) { + list($proto_opts, $parsed['port']) = explode(':', $proto_opts); + } + if ($parsed['protocol'] == 'tcp') { + $parsed['hostspec'] = $proto_opts; + } + else if ($parsed['protocol'] == 'unix') { + $parsed['socket'] = $proto_opts; + } + + // Get dabase if any + // $dsn => database + if ($dsn) { + // /database + if (($pos = strpos($dsn, '?')) === false) { + $parsed['database'] = rawurldecode($dsn); + // /database?param1=value1¶m2=value2 + } + else { + $parsed['database'] = rawurldecode(substr($dsn, 0, $pos)); + $dsn = substr($dsn, $pos + 1); + if (strpos($dsn, '&') !== false) { + $opts = explode('&', $dsn); + } + else { // database?param1=value1 + $opts = array($dsn); + } + foreach ($opts as $opt) { + list($key, $value) = explode('=', $opt); + if (!array_key_exists($key, $parsed) || false === $parsed[$key]) { + // don't allow params overwrite + $parsed[$key] = rawurldecode($value); + } + } + } + } + + return $parsed; + } + + /** + * Returns PDO DSN string from DSN array + * + * @param array $dsn DSN parameters + * + * @return string DSN string + */ + protected function dsn_string($dsn) + { + $params = array(); + $result = $dsn['phptype'] . ':'; + + if ($dsn['hostspec']) { + $params[] = 'host=' . $dsn['hostspec']; + } + + if ($dsn['port']) { + $params[] = 'port=' . $dsn['port']; + } + + if ($dsn['database']) { + $params[] = 'dbname=' . $dsn['database']; + } + + if (!empty($params)) { + $result .= implode(';', $params); + } + + return $result; + } + + /** + * Returns driver-specific connection options + * + * @param array $dsn DSN parameters + * + * @return array Connection options + */ + protected function dsn_options($dsn) + { + $result = array(); + + return $result; + } +} diff --git a/program/lib/Roundcube/rcube_db_mssql.php b/program/lib/Roundcube/rcube_db_mssql.php new file mode 100644 index 000000000..c95663c74 --- /dev/null +++ b/program/lib/Roundcube/rcube_db_mssql.php @@ -0,0 +1,156 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db_mssql.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | for MS SQL Server database | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface + * This is a wrapper for the PHP PDO + * + * @package Framework + * @subpackage Database + */ +class rcube_db_mssql extends rcube_db +{ + public $db_provider = 'mssql'; + + /** + * Driver initialization + */ + protected function init() + { + $this->options['identifier_start'] = '['; + $this->options['identifier_end'] = ']'; + } + + /** + * Character setting + */ + protected function set_charset($charset) + { + // UTF-8 is default + } + + /** + * Return SQL function for current time and date + * + * @return string SQL function to use in query + */ + public function now() + { + return "getdate()"; + } + + /** + * Return SQL statement to convert a field value into a unix timestamp + * + * This method is deprecated and should not be used anymore due to limitations + * of timestamp functions in Mysql (year 2038 problem) + * + * @param string $field Field name + * + * @return string SQL statement to use in query + * @deprecated + */ + public function unixtimestamp($field) + { + return "DATEDIFF(second, '19700101', $field) + DATEDIFF(second, GETDATE(), GETUTCDATE())"; + } + + /** + * Abstract SQL statement for value concatenation + * + * @return string SQL statement to be used in query + */ + public function concat(/* col1, col2, ... */) + { + $args = func_get_args(); + + if (is_array($args[0])) { + $args = $args[0]; + } + + return '(' . join('+', $args) . ')'; + } + + /** + * Adds TOP (LIMIT,OFFSET) clause to the query + * + * @param string $query SQL query + * @param int $limit Number of rows + * @param int $offset Offset + * + * @return string SQL query + */ + protected function set_limit($query, $limit = 0, $offset = 0) + { + $limit = intval($limit); + $offset = intval($offset); + + $orderby = stristr($query, 'ORDER BY'); + if ($orderby !== false) { + $sort = (stripos($orderby, ' desc') !== false) ? 'desc' : 'asc'; + $order = str_ireplace('ORDER BY', '', $orderby); + $order = trim(preg_replace('/\bASC\b|\bDESC\b/i', '', $order)); + } + + $query = preg_replace('/^SELECT\s/i', 'SELECT TOP ' . ($limit + $offset) . ' ', $query); + + $query = 'SELECT * FROM (SELECT TOP ' . $limit . ' * FROM (' . $query . ') AS inner_tbl'; + if ($orderby !== false) { + $query .= ' ORDER BY ' . $order . ' '; + $query .= (stripos($sort, 'asc') !== false) ? 'DESC' : 'ASC'; + } + $query .= ') AS outer_tbl'; + if ($orderby !== false) { + $query .= ' ORDER BY ' . $order . ' ' . $sort; + } + + return $query; + } + + /** + * Returns PDO DSN string from DSN array + */ + protected function dsn_string($dsn) + { + $params = array(); + $result = $dsn['phptype'] . ':'; + + if ($dsn['hostspec']) { + $host = $dsn['hostspec']; + if ($dsn['port']) { + $host .= ',' . $dsn['port']; + } + $params[] = 'host=' . $host; + } + + if ($dsn['database']) { + $params[] = 'dbname=' . $dsn['database']; + } + + if (!empty($params)) { + $result .= implode(';', $params); + } + + return $result; + } +} diff --git a/program/lib/Roundcube/rcube_db_mysql.php b/program/lib/Roundcube/rcube_db_mysql.php new file mode 100644 index 000000000..1c5ba1de7 --- /dev/null +++ b/program/lib/Roundcube/rcube_db_mysql.php @@ -0,0 +1,159 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db_mysql.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | for MySQL database | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface + * + * This is a wrapper for the PHP PDO + * + * @package Framework + * @subpackage Database + */ +class rcube_db_mysql extends rcube_db +{ + public $db_provider = 'mysql'; + + /** + * Driver initialization/configuration + */ + protected function init() + { + // SQL identifiers quoting + $this->options['identifier_start'] = '`'; + $this->options['identifier_end'] = '`'; + } + + /** + * Abstract SQL statement for value concatenation + * + * @return string SQL statement to be used in query + */ + public function concat(/* col1, col2, ... */) + { + $args = func_get_args(); + + if (is_array($args[0])) { + $args = $args[0]; + } + + return 'CONCAT(' . join(', ', $args) . ')'; + } + + /** + * Returns PDO DSN string from DSN array + * + * @param array $dsn DSN parameters + * + * @return string Connection string + */ + protected function dsn_string($dsn) + { + $params = array(); + $result = 'mysql:'; + + if ($dsn['database']) { + $params[] = 'dbname=' . $dsn['database']; + } + + if ($dsn['hostspec']) { + $params[] = 'host=' . $dsn['hostspec']; + } + + if ($dsn['port']) { + $params[] = 'port=' . $dsn['port']; + } + + if ($dsn['socket']) { + $params[] = 'unix_socket=' . $dsn['socket']; + } + + $params[] = 'charset=utf8'; + + if (!empty($params)) { + $result .= implode(';', $params); + } + + return $result; + } + + /** + * Returns driver-specific connection options + * + * @param array $dsn DSN parameters + * + * @return array Connection options + */ + protected function dsn_options($dsn) + { + $result = array(); + + if (!empty($dsn['key'])) { + $result[PDO::MYSQL_ATTR_KEY] = $dsn['key']; + } + + if (!empty($dsn['cipher'])) { + $result[PDO::MYSQL_ATTR_CIPHER] = $dsn['cipher']; + } + + if (!empty($dsn['cert'])) { + $result[PDO::MYSQL_ATTR_SSL_CERT] = $dsn['cert']; + } + + if (!empty($dsn['capath'])) { + $result[PDO::MYSQL_ATTR_SSL_CAPATH] = $dsn['capath']; + } + + if (!empty($dsn['ca'])) { + $result[PDO::MYSQL_ATTR_SSL_CA] = $dsn['ca']; + } + + // Always return matching (not affected only) rows count + $result[PDO::MYSQL_ATTR_FOUND_ROWS] = true; + + return $result; + } + + /** + * Get database runtime variables + * + * @param string $varname Variable name + * @param mixed $default Default value if variable is not set + * + * @return mixed Variable value or default + */ + public function get_variable($varname, $default = null) + { + if (!isset($this->variables)) { + $this->variables = array(); + + $result = $this->query('SHOW VARIABLES'); + + while ($sql_arr = $this->fetch_array($result)) { + $this->variables[$row[0]] = $row[1]; + } + } + + return isset($this->variables[$varname]) ? $this->variables[$varname] : $default; + } + +} diff --git a/program/lib/Roundcube/rcube_db_pgsql.php b/program/lib/Roundcube/rcube_db_pgsql.php new file mode 100644 index 000000000..797860a84 --- /dev/null +++ b/program/lib/Roundcube/rcube_db_pgsql.php @@ -0,0 +1,136 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db_pgsql.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | for PostgreSQL database | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface + * This is a wrapper for the PHP PDO + * + * @package Framework + * @subpackage Database + */ +class rcube_db_pgsql extends rcube_db +{ + public $db_provider = 'postgres'; + + /** + * Get last inserted record ID + * + * @param string $table Table name (to find the incremented sequence) + * + * @return mixed ID or false on failure + */ + public function insert_id($table = null) + { + if (!$this->db_connected || $this->db_mode == 'r') { + return false; + } + + if ($table) { + $table = $this->sequence_name($table); + } + + $id = $this->dbh->lastInsertId($table); + + return $id; + } + + /** + * Return correct name for a specific database sequence + * + * @param string $sequence Secuence name + * + * @return string Translated sequence name + */ + protected function sequence_name($sequence) + { + $rcube = rcube::get_instance(); + + // return sequence name if configured + $config_key = 'db_sequence_'.$sequence; + + if ($name = $rcube->config->get($config_key)) { + return $name; + } + + return $sequence; + } + + /** + * Return SQL statement to convert a field value into a unix timestamp + * + * This method is deprecated and should not be used anymore due to limitations + * of timestamp functions in Mysql (year 2038 problem) + * + * @param string $field Field name + * + * @return string SQL statement to use in query + * @deprecated + */ + public function unixtimestamp($field) + { + return "EXTRACT (EPOCH FROM $field)"; + } + + /** + * Return SQL statement for case insensitive LIKE + * + * @param string $column Field name + * @param string $value Search value + * + * @return string SQL statement to use in query + */ + public function ilike($column, $value) + { + return $this->quote_identifier($column) . ' ILIKE ' . $this->quote($value); + } + + /** + * Get database runtime variables + * + * @param string $varname Variable name + * @param mixed $default Default value if variable is not set + * + * @return mixed Variable value or default + */ + public function get_variable($varname, $default = null) + { + // There's a known case when max_allowed_packet is queried + // PostgreSQL doesn't have such limit, return immediately + if ($varname == 'max_allowed_packet') { + return $default; + } + + if (!isset($this->variables)) { + $this->variables = array(); + + $result = $this->query('SHOW ALL'); + + while ($row = $this->fetch_array($result)) { + $this->variables[$row[0]] = $row[1]; + } + } + + return isset($this->variables[$varname]) ? $this->variables[$varname] : $default; + } + +} diff --git a/program/lib/Roundcube/rcube_db_sqlite.php b/program/lib/Roundcube/rcube_db_sqlite.php new file mode 100644 index 000000000..65dcb6d6e --- /dev/null +++ b/program/lib/Roundcube/rcube_db_sqlite.php @@ -0,0 +1,179 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db_sqlite.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | for SQLite database | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface + * This is a wrapper for the PHP PDO + * + * @package Framework + * @subpackage Database + */ +class rcube_db_sqlite extends rcube_db +{ + public $db_provider = 'sqlite'; + + /** + * Database character set + */ + protected function set_charset($charset) + { + } + + /** + * Prepare connection + */ + protected function conn_prepare($dsn) + { + // Create database file, required by PDO to exist on connection + if (!empty($dsn['database']) && !file_exists($dsn['database'])) { + $created = touch($dsn['database']); + + // File mode setting, for compat. with MDB2 + if (!empty($dsn['mode']) && $created) { + chmod($dsn['database'], octdec($dsn['mode'])); + } + } + } + + /** + * Configure connection, create database if not exists + */ + protected function conn_configure($dsn, $dbh) + { + // we emulate via callback some missing functions + $dbh->sqliteCreateFunction('unix_timestamp', array('rcube_db_sqlite', 'sqlite_unix_timestamp'), 1); + $dbh->sqliteCreateFunction('now', array('rcube_db_sqlite', 'sqlite_now'), 0); + + // Initialize database structure in file is empty + if (!empty($dsn['database']) && !filesize($dsn['database'])) { + $data = file_get_contents(RCUBE_INSTALL_PATH . 'SQL/sqlite.initial.sql'); + + if (strlen($data)) { + $this->debug('INITIALIZE DATABASE'); + + $q = $dbh->exec($data); + + if ($q === false) { + $error = $dbh->errorInfo(); + $this->db_error = true; + $this->db_error_msg = sprintf('[%s] %s', $error[1], $error[2]); + + rcube::raise_error(array('code' => 500, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => $this->db_error_msg), true, false); + } + } + } + } + + /** + * Callback for sqlite: unix_timestamp() + */ + public static function sqlite_unix_timestamp($timestamp = '') + { + $timestamp = trim($timestamp); + if (!$timestamp) { + $ret = time(); + } + else if (!preg_match('/^[0-9]+$/s', $timestamp)) { + $ret = strtotime($timestamp); + } + else { + $ret = $timestamp; + } + + return $ret; + } + + /** + * Callback for sqlite: now() + */ + public static function sqlite_now() + { + return date("Y-m-d H:i:s"); + } + + /** + * Returns list of tables in database + * + * @return array List of all tables of the current database + */ + public function list_tables() + { + if ($this->tables === null) { + $q = $this->query('SELECT name FROM sqlite_master' + .' WHERE type = \'table\' ORDER BY name'); + + if ($res = $this->_get_result($q)) { + $this->tables = $res->fetchAll(PDO::FETCH_COLUMN, 0); + } + else { + $this->tables = array(); + } + } + + return $this->tables; + } + + /** + * Returns list of columns in database table + * + * @param string $table Table name + * + * @return array List of table cols + */ + public function list_cols($table) + { + $q = $this->query('SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', + array('table', $table)); + + $columns = array(); + + if ($sql = $this->fetch_array($q)) { + $sql = $sql[0]; + $start_pos = strpos($sql, '('); + $end_pos = strrpos($sql, ')'); + $sql = substr($sql, $start_pos+1, $end_pos-$start_pos-1); + $lines = explode(',', $sql); + + foreach ($lines as $line) { + $line = explode(' ', trim($line)); + + if ($line[0] && strpos($line[0], '--') !== 0) { + $column = $line[0]; + $columns[] = trim($column, '"'); + } + } + } + + return $columns; + } + + /** + * Build DSN string for PDO constructor + */ + protected function dsn_string($dsn) + { + return $dsn['phptype'] . ':' . $dsn['database']; + } +} diff --git a/program/lib/Roundcube/rcube_db_sqlsrv.php b/program/lib/Roundcube/rcube_db_sqlsrv.php new file mode 100644 index 000000000..8b6ffe807 --- /dev/null +++ b/program/lib/Roundcube/rcube_db_sqlsrv.php @@ -0,0 +1,157 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_db_sqlsrv.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Database wrapper class that implements PHP PDO functions | + | for MS SQL Server database | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Database independent query interface + * This is a wrapper for the PHP PDO + * + * @package Framework + * @subpackage Database + */ +class rcube_db_sqlsrv extends rcube_db +{ + public $db_provider = 'mssql'; + + /** + * Driver initialization + */ + protected function init() + { + $this->options['identifier_start'] = '['; + $this->options['identifier_end'] = ']'; + } + + /** + * Database character set setting + */ + protected function set_charset($charset) + { + // UTF-8 is default + } + + /** + * Return SQL function for current time and date + * + * @return string SQL function to use in query + */ + public function now() + { + return "getdate()"; + } + + /** + * Return SQL statement to convert a field value into a unix timestamp + * + * This method is deprecated and should not be used anymore due to limitations + * of timestamp functions in Mysql (year 2038 problem) + * + * @param string $field Field name + * + * @return string SQL statement to use in query + * @deprecated + */ + public function unixtimestamp($field) + { + return "DATEDIFF(second, '19700101', $field) + DATEDIFF(second, GETDATE(), GETUTCDATE())"; + } + + /** + * Abstract SQL statement for value concatenation + * + * @return string SQL statement to be used in query + */ + public function concat(/* col1, col2, ... */) + { + $args = func_get_args(); + + if (is_array($args[0])) { + $args = $args[0]; + } + + return '(' . join('+', $args) . ')'; + } + + /** + * Adds TOP (LIMIT,OFFSET) clause to the query + * + * @param string $query SQL query + * @param int $limit Number of rows + * @param int $offset Offset + * + * @return string SQL query + */ + protected function set_limit($query, $limit = 0, $offset = 0) + { + $limit = intval($limit); + $offset = intval($offset); + + $orderby = stristr($query, 'ORDER BY'); + if ($orderby !== false) { + $sort = (stripos($orderby, ' desc') !== false) ? 'desc' : 'asc'; + $order = str_ireplace('ORDER BY', '', $orderby); + $order = trim(preg_replace('/\bASC\b|\bDESC\b/i', '', $order)); + } + + $query = preg_replace('/^SELECT\s/i', 'SELECT TOP ' . ($limit + $offset) . ' ', $query); + + $query = 'SELECT * FROM (SELECT TOP ' . $limit . ' * FROM (' . $query . ') AS inner_tbl'; + if ($orderby !== false) { + $query .= ' ORDER BY ' . $order . ' '; + $query .= (stripos($sort, 'asc') !== false) ? 'DESC' : 'ASC'; + } + $query .= ') AS outer_tbl'; + if ($orderby !== false) { + $query .= ' ORDER BY ' . $order . ' ' . $sort; + } + + return $query; + } + + /** + * Returns PDO DSN string from DSN array + */ + protected function dsn_string($dsn) + { + $params = array(); + $result = 'sqlsrv:'; + + if ($dsn['hostspec']) { + $host = $dsn['hostspec']; + + if ($dsn['port']) { + $host .= ',' . $dsn['port']; + } + $params[] = 'Server=' . $host; + } + + if ($dsn['database']) { + $params[] = 'Database=' . $dsn['database']; + } + + if (!empty($params)) { + $result .= implode(';', $params); + } + + return $result; + } +} diff --git a/program/lib/Roundcube/rcube_image.php b/program/lib/Roundcube/rcube_image.php new file mode 100644 index 000000000..b72a24c51 --- /dev/null +++ b/program/lib/Roundcube/rcube_image.php @@ -0,0 +1,268 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_image.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Image resizer and converter | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Image resizer and converter + * + * @package Framework + * @subpackage Utils + */ +class rcube_image +{ + private $image_file; + + const TYPE_GIF = 1; + const TYPE_JPG = 2; + const TYPE_PNG = 3; + const TYPE_TIF = 4; + + public static $extensions = array( + self::TYPE_GIF => 'gif', + self::TYPE_JPG => 'jpg', + self::TYPE_PNG => 'png', + self::TYPE_TIF => 'tif', + ); + + + function __construct($filename) + { + $this->image_file = $filename; + } + + /** + * Get image properties. + * + * @return mixed Hash array with image props like type, width, height + */ + public function props() + { + // use GD extension + if (function_exists('getimagesize') && ($imsize = @getimagesize($this->image_file))) { + $width = $imsize[0]; + $height = $imsize[1]; + $gd_type = $imsize['2']; + $type = image_type_to_extension($imsize['2'], false); + } + + // use ImageMagick + if (!$type && ($data = $this->identify())) { + list($type, $width, $height) = $data; + } + + if ($type) { + return array( + 'type' => $type, + 'gd_type' => $gd_type, + 'width' => $width, + 'height' => $height, + ); + } + } + + /** + * Resize image to a given size + * + * @param int $size Max width/height size + * @param string $filename Output filename + * @param boolean $browser_compat Convert to image type displayable by any browser + * + * @return mixed Output type on success, False on failure + */ + public function resize($size, $filename = null, $browser_compat = false) + { + $result = false; + $rcube = rcube::get_instance(); + $convert = $rcube->config->get('im_convert_path', false); + $props = $this->props(); + + if (!$filename) { + $filename = $this->image_file; + } + + // use Imagemagick + if ($convert) { + $p['out'] = $filename; + $p['in'] = $this->image_file; + $p['size'] = $size.'x'.$size; + $type = $props['type']; + + if (!$type && ($data = $this->identify())) { + $type = $data[0]; + } + + $type = strtr($type, array("jpeg" => "jpg", "tiff" => "tif", "ps" => "eps", "ept" => "eps")); + $p['intype'] = $type; + + // convert to an image format every browser can display + if ($browser_compat && !in_array($type, array('jpg','gif','png'))) { + $type = 'jpg'; + } + + $p += array('type' => $type, 'types' => "bmp,eps,gif,jp2,jpg,png,svg,tif", 'quality' => 75); + $p['-opts'] = array('-resize' => $p['size'].'>'); + + if (in_array($type, explode(',', $p['types']))) { // Valid type? + $result = rcube::exec($convert . ' 2>&1 -flatten -auto-orient -colorspace RGB -quality {quality} {-opts} {intype}:{in} {type}:{out}', $p); + } + + if ($result === '') { + return $type; + } + } + + // use GD extension + $gd_types = array(IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG); + if ($props['gd_type'] && in_array($props['gd_type'], $gd_types)) { + if ($props['gd_type'] == IMAGETYPE_JPEG) { + $image = imagecreatefromjpeg($this->image_file); + } + elseif($props['gd_type'] == IMAGETYPE_GIF) { + $image = imagecreatefromgif($this->image_file); + } + elseif($props['gd_type'] == IMAGETYPE_PNG) { + $image = imagecreatefrompng($this->image_file); + } + + $scale = $size / max($props['width'], $props['height']); + $width = $props['width'] * $scale; + $height = $props['height'] * $scale; + + $new_image = imagecreatetruecolor($width, $height); + + // Fix transparency of gif/png image + if ($props['gd_type'] != IMAGETYPE_JPEG) { + imagealphablending($new_image, false); + imagesavealpha($new_image, true); + $transparent = imagecolorallocatealpha($new_image, 255, 255, 255, 127); + imagefilledrectangle($new_image, 0, 0, $width, $height, $transparent); + } + + imagecopyresampled($new_image, $image, 0, 0, 0, 0, $width, $height, $props['width'], $props['height']); + $image = $new_image; + + if ($props['gd_type'] == IMAGETYPE_JPEG) { + $result = imagejpeg($image, $filename, 75); + $type = 'jpg'; + } + elseif($props['gd_type'] == IMAGETYPE_GIF) { + $result = imagegif($image, $filename); + $type = 'gid'; + } + elseif($props['gd_type'] == IMAGETYPE_PNG) { + $result = imagepng($image, $filename, 6, PNG_ALL_FILTERS); + $type = 'png'; + } + + if ($result) { + return $type; + } + } + + // @TODO: print error to the log? + return false; + } + + /** + * Convert image to a given type + * + * @param int $type Destination file type (see class constants) + * @param string $filename Output filename (if empty, original file will be used + * and filename extension will be modified) + * + * @return bool True on success, False on failure + */ + public function convert($type, $filename = null) + { + $rcube = rcube::get_instance(); + $convert = $rcube->config->get('im_convert_path', false); + + if (!$filename) { + $filename = $this->image_file; + + // modify extension + if ($extension = self::$extensions[$type]) { + $filename = preg_replace('/\.[^.]+$/', '', $filename) . '.' . $extension; + } + } + + // use ImageMagick + if ($convert) { + $p['in'] = $this->image_file; + $p['out'] = $filename; + $p['type'] = self::$extensions[$type]; + + $result = rcube::exec($convert . ' 2>&1 -colorspace RGB -quality 75 {in} {type}:{out}', $p); + + if ($result === '') { + return true; + } + } + + // use GD extension (TIFF isn't supported) + $props = $this->props(); + $gd_types = array(IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG); + + if ($props['gd_type'] && in_array($props['gd_type'], $gd_types)) { + if ($props['gd_type'] == IMAGETYPE_JPEG) { + $image = imagecreatefromjpeg($this->image_file); + } + else if ($props['gd_type'] == IMAGETYPE_GIF) { + $image = imagecreatefromgif($this->image_file); + } + else if ($props['gd_type'] == IMAGETYPE_PNG) { + $image = imagecreatefrompng($this->image_file); + } + + if ($type == self::TYPE_JPG) { + $result = imagejpeg($image, $filename, 75); + } + else if ($type == self::TYPE_GIF) { + $result = imagegif($image, $filename); + } + else if ($type == self::TYPE_PNG) { + $result = imagepng($image, $filename, 6, PNG_ALL_FILTERS); + } + } + + // @TODO: print error to the log? + return false; + } + + /** + * Identify command handler. + */ + private function identify() + { + $rcube = rcube::get_instance(); + + if ($cmd = $rcube->config->get('im_identify_path')) { + $args = array('in' => $this->image_file, 'format' => "%m %[fx:w] %[fx:h]"); + $id = rcube::exec($cmd. ' 2>/dev/null -format {format} {in}', $args); + + if ($id) { + return explode(' ', strtolower($id)); + } + } + } + +} diff --git a/program/lib/Roundcube/rcube_imap.php b/program/lib/Roundcube/rcube_imap.php new file mode 100644 index 000000000..8ca24dec7 --- /dev/null +++ b/program/lib/Roundcube/rcube_imap.php @@ -0,0 +1,4172 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_imap.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | IMAP Storage Engine | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Interface class for accessing an IMAP server + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_imap extends rcube_storage +{ + /** + * Instance of rcube_imap_generic + * + * @var rcube_imap_generic + */ + public $conn; + + /** + * Instance of rcube_imap_cache + * + * @var rcube_imap_cache + */ + protected $mcache; + + /** + * Instance of rcube_cache + * + * @var rcube_cache + */ + protected $cache; + + /** + * Internal (in-memory) cache + * + * @var array + */ + protected $icache = array(); + + protected $list_page = 1; + protected $delimiter; + protected $namespace; + protected $sort_field = ''; + protected $sort_order = 'DESC'; + protected $struct_charset; + protected $uid_id_map = array(); + protected $msg_headers = array(); + protected $search_set; + protected $search_string = ''; + protected $search_charset = ''; + protected $search_sort_field = ''; + protected $search_threads = false; + protected $search_sorted = false; + protected $options = array('auth_method' => 'check'); + protected $caching = false; + protected $messages_caching = false; + protected $threading = false; + + + /** + * Object constructor. + */ + public function __construct() + { + $this->conn = new rcube_imap_generic(); + + // Set namespace and delimiter from session, + // so some methods would work before connection + if (isset($_SESSION['imap_namespace'])) { + $this->namespace = $_SESSION['imap_namespace']; + } + if (isset($_SESSION['imap_delimiter'])) { + $this->delimiter = $_SESSION['imap_delimiter']; + } + } + + + /** + * Magic getter for backward compat. + * + * @deprecated. + */ + public function __get($name) + { + if (isset($this->{$name})) { + return $this->{$name}; + } + } + + + /** + * Connect to an IMAP server + * + * @param string $host Host to connect + * @param string $user Username for IMAP account + * @param string $pass Password for IMAP account + * @param integer $port Port to connect to + * @param string $use_ssl SSL schema (either ssl or tls) or null if plain connection + * + * @return boolean TRUE on success, FALSE on failure + */ + public function connect($host, $user, $pass, $port=143, $use_ssl=null) + { + // check for OpenSSL support in PHP build + if ($use_ssl && extension_loaded('openssl')) { + $this->options['ssl_mode'] = $use_ssl == 'imaps' ? 'ssl' : $use_ssl; + } + else if ($use_ssl) { + rcube::raise_error(array('code' => 403, 'type' => 'imap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "OpenSSL not available"), true, false); + $port = 143; + } + + $this->options['port'] = $port; + + if ($this->options['debug']) { + $this->set_debug(true); + + $this->options['ident'] = array( + 'name' => 'Roundcube', + 'version' => RCUBE_VERSION, + 'php' => PHP_VERSION, + 'os' => PHP_OS, + 'command' => $_SERVER['REQUEST_URI'], + ); + } + + $attempt = 0; + do { + $data = rcube::get_instance()->plugins->exec_hook('imap_connect', + array_merge($this->options, array('host' => $host, 'user' => $user, + 'attempt' => ++$attempt))); + + if (!empty($data['pass'])) { + $pass = $data['pass']; + } + + $this->conn->connect($data['host'], $data['user'], $pass, $data); + } while(!$this->conn->connected() && $data['retry']); + + $config = array( + 'host' => $data['host'], + 'user' => $data['user'], + 'password' => $pass, + 'port' => $port, + 'ssl' => $use_ssl, + ); + + $this->options = array_merge($this->options, $config); + $this->connect_done = true; + + if ($this->conn->connected()) { + // get namespace and delimiter + $this->set_env(); + return true; + } + // write error log + else if ($this->conn->error) { + if ($pass && $user) { + $message = sprintf("Login failed for %s from %s. %s", + $user, rcube_utils::remote_ip(), $this->conn->error); + + rcube::raise_error(array('code' => 403, 'type' => 'imap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => $message), true, false); + } + } + + return false; + } + + + /** + * Close IMAP connection. + * Usually done on script shutdown + */ + public function close() + { + $this->conn->closeConnection(); + if ($this->mcache) { + $this->mcache->close(); + } + } + + + /** + * Check connection state, connect if not connected. + * + * @return bool Connection state. + */ + public function check_connection() + { + // Establish connection if it wasn't done yet + if (!$this->connect_done && !empty($this->options['user'])) { + return $this->connect( + $this->options['host'], + $this->options['user'], + $this->options['password'], + $this->options['port'], + $this->options['ssl'] + ); + } + + return $this->is_connected(); + } + + + /** + * Checks IMAP connection. + * + * @return boolean TRUE on success, FALSE on failure + */ + public function is_connected() + { + return $this->conn->connected(); + } + + + /** + * Returns code of last error + * + * @return int Error code + */ + public function get_error_code() + { + return $this->conn->errornum; + } + + + /** + * Returns text of last error + * + * @return string Error string + */ + public function get_error_str() + { + return $this->conn->error; + } + + + /** + * Returns code of last command response + * + * @return int Response code + */ + public function get_response_code() + { + switch ($this->conn->resultcode) { + case 'NOPERM': + return self::NOPERM; + case 'READ-ONLY': + return self::READONLY; + case 'TRYCREATE': + return self::TRYCREATE; + case 'INUSE': + return self::INUSE; + case 'OVERQUOTA': + return self::OVERQUOTA; + case 'ALREADYEXISTS': + return self::ALREADYEXISTS; + case 'NONEXISTENT': + return self::NONEXISTENT; + case 'CONTACTADMIN': + return self::CONTACTADMIN; + default: + return self::UNKNOWN; + } + } + + + /** + * Activate/deactivate debug mode + * + * @param boolean $dbg True if IMAP conversation should be logged + */ + public function set_debug($dbg = true) + { + $this->options['debug'] = $dbg; + $this->conn->setDebug($dbg, array($this, 'debug_handler')); + } + + + /** + * Set internal folder reference. + * All operations will be perfomed on this folder. + * + * @param string $folder Folder name + */ + public function set_folder($folder) + { + if ($this->folder == $folder) { + return; + } + + $this->folder = $folder; + + // clear messagecount cache for this folder + $this->clear_messagecount($folder); + } + + + /** + * Save a search result for future message listing methods + * + * @param array $set Search set, result from rcube_imap::get_search_set(): + * 0 - searching criteria, string + * 1 - search result, rcube_result_index|rcube_result_thread + * 2 - searching character set, string + * 3 - sorting field, string + * 4 - true if sorted, bool + */ + public function set_search_set($set) + { + $set = (array)$set; + + $this->search_string = $set[0]; + $this->search_set = $set[1]; + $this->search_charset = $set[2]; + $this->search_sort_field = $set[3]; + $this->search_sorted = $set[4]; + $this->search_threads = is_a($this->search_set, 'rcube_result_thread'); + } + + + /** + * Return the saved search set as hash array + * + * @return array Search set + */ + public function get_search_set() + { + if (empty($this->search_set)) { + return null; + } + + return array( + $this->search_string, + $this->search_set, + $this->search_charset, + $this->search_sort_field, + $this->search_sorted, + ); + } + + + /** + * Returns the IMAP server's capability. + * + * @param string $cap Capability name + * + * @return mixed Capability value or TRUE if supported, FALSE if not + */ + public function get_capability($cap) + { + $cap = strtoupper($cap); + $sess_key = "STORAGE_$cap"; + + if (!isset($_SESSION[$sess_key])) { + if (!$this->check_connection()) { + return false; + } + + $_SESSION[$sess_key] = $this->conn->getCapability($cap); + } + + return $_SESSION[$sess_key]; + } + + + /** + * Checks the PERMANENTFLAGS capability of the current folder + * and returns true if the given flag is supported by the IMAP server + * + * @param string $flag Permanentflag name + * + * @return boolean True if this flag is supported + */ + public function check_permflag($flag) + { + $flag = strtoupper($flag); + $imap_flag = $this->conn->flags[$flag]; + $perm_flags = $this->get_permflags($this->folder); + + return in_array_nocase($imap_flag, $perm_flags); + } + + + /** + * Returns PERMANENTFLAGS of the specified folder + * + * @param string $folder Folder name + * + * @return array Flags + */ + public function get_permflags($folder) + { + if (!strlen($folder)) { + return array(); + } +/* + Checking PERMANENTFLAGS is rather rare, so we disable caching of it + Re-think when we'll use it for more than only MDNSENT flag + + $cache_key = 'mailboxes.permanentflags.' . $folder; + $permflags = $this->get_cache($cache_key); + + if ($permflags !== null) { + return explode(' ', $permflags); + } +*/ + if (!$this->check_connection()) { + return array(); + } + + if ($this->conn->select($folder)) { + $permflags = $this->conn->data['PERMANENTFLAGS']; + } + else { + return array(); + } + + if (!is_array($permflags)) { + $permflags = array(); + } +/* + // Store permflags as string to limit cached object size + $this->update_cache($cache_key, implode(' ', $permflags)); +*/ + return $permflags; + } + + + /** + * Returns the delimiter that is used by the IMAP server for folder separation + * + * @return string Delimiter string + * @access public + */ + public function get_hierarchy_delimiter() + { + return $this->delimiter; + } + + + /** + * Get namespace + * + * @param string $name Namespace array index: personal, other, shared, prefix + * + * @return array Namespace data + */ + public function get_namespace($name = null) + { + $ns = $this->namespace; + + if ($name) { + return isset($ns[$name]) ? $ns[$name] : null; + } + + unset($ns['prefix']); + return $ns; + } + + + /** + * Sets delimiter and namespaces + */ + protected function set_env() + { + if ($this->delimiter !== null && $this->namespace !== null) { + return; + } + + $config = rcube::get_instance()->config; + $imap_personal = $config->get('imap_ns_personal'); + $imap_other = $config->get('imap_ns_other'); + $imap_shared = $config->get('imap_ns_shared'); + $imap_delimiter = $config->get('imap_delimiter'); + + if (!$this->check_connection()) { + return; + } + + $ns = $this->conn->getNamespace(); + + // Set namespaces (NAMESPACE supported) + if (is_array($ns)) { + $this->namespace = $ns; + } + else { + $this->namespace = array( + 'personal' => NULL, + 'other' => NULL, + 'shared' => NULL, + ); + } + + if ($imap_delimiter) { + $this->delimiter = $imap_delimiter; + } + if (empty($this->delimiter)) { + $this->delimiter = $this->namespace['personal'][0][1]; + } + if (empty($this->delimiter)) { + $this->delimiter = $this->conn->getHierarchyDelimiter(); + } + if (empty($this->delimiter)) { + $this->delimiter = '/'; + } + + // Overwrite namespaces + if ($imap_personal !== null) { + $this->namespace['personal'] = NULL; + foreach ((array)$imap_personal as $dir) { + $this->namespace['personal'][] = array($dir, $this->delimiter); + } + } + if ($imap_other !== null) { + $this->namespace['other'] = NULL; + foreach ((array)$imap_other as $dir) { + if ($dir) { + $this->namespace['other'][] = array($dir, $this->delimiter); + } + } + } + if ($imap_shared !== null) { + $this->namespace['shared'] = NULL; + foreach ((array)$imap_shared as $dir) { + if ($dir) { + $this->namespace['shared'][] = array($dir, $this->delimiter); + } + } + } + + // Find personal namespace prefix for mod_folder() + // Prefix can be removed when there is only one personal namespace + if (is_array($this->namespace['personal']) && count($this->namespace['personal']) == 1) { + $this->namespace['prefix'] = $this->namespace['personal'][0][0]; + } + + $_SESSION['imap_namespace'] = $this->namespace; + $_SESSION['imap_delimiter'] = $this->delimiter; + } + + + /** + * Get message count for a specific folder + * + * @param string $folder Folder name + * @param string $mode Mode for count [ALL|THREADS|UNSEEN|RECENT] + * @param boolean $force Force reading from server and update cache + * @param boolean $status Enables storing folder status info (max UID/count), + * required for folder_status() + * + * @return int Number of messages + */ + public function count($folder='', $mode='ALL', $force=false, $status=true) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + return $this->countmessages($folder, $mode, $force, $status); + } + + + /** + * protected method for getting nr of messages + * + * @param string $folder Folder name + * @param string $mode Mode for count [ALL|THREADS|UNSEEN|RECENT] + * @param boolean $force Force reading from server and update cache + * @param boolean $status Enables storing folder status info (max UID/count), + * required for folder_status() + * + * @return int Number of messages + * @see rcube_imap::count() + */ + protected function countmessages($folder, $mode='ALL', $force=false, $status=true) + { + $mode = strtoupper($mode); + + // count search set, assume search set is always up-to-date (don't check $force flag) + if ($this->search_string && $folder == $this->folder && ($mode == 'ALL' || $mode == 'THREADS')) { + if ($mode == 'ALL') { + return $this->search_set->count_messages(); + } + else { + return $this->search_set->count(); + } + } + + $a_folder_cache = $this->get_cache('messagecount'); + + // return cached value + if (!$force && is_array($a_folder_cache[$folder]) && isset($a_folder_cache[$folder][$mode])) { + return $a_folder_cache[$folder][$mode]; + } + + if (!is_array($a_folder_cache[$folder])) { + $a_folder_cache[$folder] = array(); + } + + if ($mode == 'THREADS') { + $res = $this->fetch_threads($folder, $force); + $count = $res->count(); + + if ($status) { + $msg_count = $res->count_messages(); + $this->set_folder_stats($folder, 'cnt', $msg_count); + $this->set_folder_stats($folder, 'maxuid', $msg_count ? $this->id2uid($msg_count, $folder) : 0); + } + } + // Need connection here + else if (!$this->check_connection()) { + return 0; + } + // RECENT count is fetched a bit different + else if ($mode == 'RECENT') { + $count = $this->conn->countRecent($folder); + } + // use SEARCH for message counting + else if (!empty($this->options['skip_deleted'])) { + $search_str = "ALL UNDELETED"; + $keys = array('COUNT'); + + if ($mode == 'UNSEEN') { + $search_str .= " UNSEEN"; + } + else { + if ($this->messages_caching) { + $keys[] = 'ALL'; + } + if ($status) { + $keys[] = 'MAX'; + } + } + + // @TODO: if $force==false && $mode == 'ALL' we could try to use cache index here + + // get message count using (E)SEARCH + // not very performant but more precise (using UNDELETED) + $index = $this->conn->search($folder, $search_str, true, $keys); + $count = $index->count(); + + if ($mode == 'ALL') { + // Cache index data, will be used in index_direct() + $this->icache['undeleted_idx'] = $index; + + if ($status) { + $this->set_folder_stats($folder, 'cnt', $count); + $this->set_folder_stats($folder, 'maxuid', $index->max()); + } + } + } + else { + if ($mode == 'UNSEEN') { + $count = $this->conn->countUnseen($folder); + } + else { + $count = $this->conn->countMessages($folder); + if ($status) { + $this->set_folder_stats($folder,'cnt', $count); + $this->set_folder_stats($folder, 'maxuid', $count ? $this->id2uid($count, $folder) : 0); + } + } + } + + $a_folder_cache[$folder][$mode] = (int)$count; + + // write back to cache + $this->update_cache('messagecount', $a_folder_cache); + + return (int)$count; + } + + + /** + * Public method for listing headers + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param string $sort_field Header field to sort by + * @param string $sort_order Sort order [ASC|DESC] + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + */ + public function list_messages($folder='', $page=NULL, $sort_field=NULL, $sort_order=NULL, $slice=0) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + return $this->_list_messages($folder, $page, $sort_field, $sort_order, $slice); + } + + + /** + * protected method for listing message headers + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param string $sort_field Header field to sort by + * @param string $sort_order Sort order [ASC|DESC] + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + * @see rcube_imap::list_messages + */ + protected function _list_messages($folder='', $page=NULL, $sort_field=NULL, $sort_order=NULL, $slice=0) + { + if (!strlen($folder)) { + return array(); + } + + $this->set_sort_order($sort_field, $sort_order); + $page = $page ? $page : $this->list_page; + + // use saved message set + if ($this->search_string && $folder == $this->folder) { + return $this->list_search_messages($folder, $page, $slice); + } + + if ($this->threading) { + return $this->list_thread_messages($folder, $page, $slice); + } + + // get UIDs of all messages in the folder, sorted + $index = $this->index($folder, $this->sort_field, $this->sort_order); + + if ($index->is_empty()) { + return array(); + } + + $from = ($page-1) * $this->page_size; + $to = $from + $this->page_size; + + $index->slice($from, $to - $from); + + if ($slice) { + $index->slice(-$slice, $slice); + } + + // fetch reqested messages headers + $a_index = $index->get(); + $a_msg_headers = $this->fetch_headers($folder, $a_index); + + return array_values($a_msg_headers); + } + + + /** + * protected method for listing message headers using threads + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + * @see rcube_imap::list_messages + */ + protected function list_thread_messages($folder, $page, $slice=0) + { + // get all threads (not sorted) + if ($mcache = $this->get_mcache_engine()) { + $threads = $mcache->get_thread($folder); + } + else { + $threads = $this->fetch_threads($folder); + } + + return $this->fetch_thread_headers($folder, $threads, $page, $slice); + } + + /** + * Method for fetching threads data + * + * @param string $folder Folder name + * @param bool $force Use IMAP server, no cache + * + * @return rcube_imap_thread Thread data object + */ + function fetch_threads($folder, $force = false) + { + if (!$force && ($mcache = $this->get_mcache_engine())) { + // don't store in self's internal cache, cache has it's own internal cache + return $mcache->get_thread($folder); + } + + if (empty($this->icache['threads'])) { + if (!$this->check_connection()) { + return new rcube_result_thread(); + } + + // get all threads + $result = $this->conn->thread($folder, $this->threading, + $this->options['skip_deleted'] ? 'UNDELETED' : '', true); + + // add to internal (fast) cache + $this->icache['threads'] = $result; + } + + return $this->icache['threads']; + } + + + /** + * protected method for fetching threaded messages headers + * + * @param string $folder Folder name + * @param rcube_result_thread $threads Threads data object + * @param int $page List page number + * @param int $slice Number of threads to slice + * + * @return array Messages headers + */ + protected function fetch_thread_headers($folder, $threads, $page, $slice=0) + { + // Sort thread structure + $this->sort_threads($threads); + + $from = ($page-1) * $this->page_size; + $to = $from + $this->page_size; + + $threads->slice($from, $to - $from); + + if ($slice) { + $threads->slice(-$slice, $slice); + } + + // Get UIDs of all messages in all threads + $a_index = $threads->get(); + + // fetch reqested headers from server + $a_msg_headers = $this->fetch_headers($folder, $a_index); + + unset($a_index); + + // Set depth, has_children and unread_children fields in headers + $this->set_thread_flags($a_msg_headers, $threads); + + return array_values($a_msg_headers); + } + + + /** + * protected method for setting threaded messages flags: + * depth, has_children and unread_children + * + * @param array $headers Reference to headers array indexed by message UID + * @param rcube_result_thread $threads Threads data object + * + * @return array Message headers array indexed by message UID + */ + protected function set_thread_flags(&$headers, $threads) + { + $parents = array(); + + list ($msg_depth, $msg_children) = $threads->get_thread_data(); + + foreach ($headers as $uid => $header) { + $depth = $msg_depth[$uid]; + $parents = array_slice($parents, 0, $depth); + + if (!empty($parents)) { + $headers[$uid]->parent_uid = end($parents); + if (empty($header->flags['SEEN'])) + $headers[$parents[0]]->unread_children++; + } + array_push($parents, $uid); + + $headers[$uid]->depth = $depth; + $headers[$uid]->has_children = $msg_children[$uid]; + } + } + + + /** + * protected method for listing a set of message headers (search results) + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + */ + protected function list_search_messages($folder, $page, $slice=0) + { + if (!strlen($folder) || empty($this->search_set) || $this->search_set->is_empty()) { + return array(); + } + + // use saved messages from searching + if ($this->threading) { + return $this->list_search_thread_messages($folder, $page, $slice); + } + + // search set is threaded, we need a new one + if ($this->search_threads) { + $this->search('', $this->search_string, $this->search_charset, $this->sort_field); + } + + $index = clone $this->search_set; + $from = ($page-1) * $this->page_size; + $to = $from + $this->page_size; + + // return empty array if no messages found + if ($index->is_empty()) { + return array(); + } + + // quickest method (default sorting) + if (!$this->search_sort_field && !$this->sort_field) { + $got_index = true; + } + // sorted messages, so we can first slice array and then fetch only wanted headers + else if ($this->search_sorted) { // SORT searching result + $got_index = true; + // reset search set if sorting field has been changed + if ($this->sort_field && $this->search_sort_field != $this->sort_field) { + $this->search('', $this->search_string, $this->search_charset, $this->sort_field); + + $index = clone $this->search_set; + + // return empty array if no messages found + if ($index->is_empty()) { + return array(); + } + } + } + + if ($got_index) { + if ($this->sort_order != $index->get_parameters('ORDER')) { + $index->revert(); + } + + // get messages uids for one page + $index->slice($from, $to-$from); + + if ($slice) { + $index->slice(-$slice, $slice); + } + + // fetch headers + $a_index = $index->get(); + $a_msg_headers = $this->fetch_headers($folder, $a_index); + + return array_values($a_msg_headers); + } + + // SEARCH result, need sorting + $cnt = $index->count(); + + // 300: experimantal value for best result + if (($cnt > 300 && $cnt > $this->page_size) || !$this->sort_field) { + // use memory less expensive (and quick) method for big result set + $index = clone $this->index('', $this->sort_field, $this->sort_order); + // get messages uids for one page... + $index->slice($start_msg, min($cnt-$from, $this->page_size)); + + if ($slice) { + $index->slice(-$slice, $slice); + } + + // ...and fetch headers + $a_index = $index->get(); + $a_msg_headers = $this->fetch_headers($folder, $a_index); + + return array_values($a_msg_headers); + } + else { + // for small result set we can fetch all messages headers + $a_index = $index->get(); + $a_msg_headers = $this->fetch_headers($folder, $a_index, false); + + // return empty array if no messages found + if (!is_array($a_msg_headers) || empty($a_msg_headers)) { + return array(); + } + + if (!$this->check_connection()) { + return array(); + } + + // if not already sorted + $a_msg_headers = $this->conn->sortHeaders( + $a_msg_headers, $this->sort_field, $this->sort_order); + + // only return the requested part of the set + $slice_length = min($this->page_size, $cnt - ($to > $cnt ? $from : $to)); + $a_msg_headers = array_slice(array_values($a_msg_headers), $from, $slice_length); + + if ($slice) { + $a_msg_headers = array_slice($a_msg_headers, -$slice, $slice); + } + + return $a_msg_headers; + } + } + + + /** + * protected method for listing a set of threaded message headers (search results) + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + * @see rcube_imap::list_search_messages() + */ + protected function list_search_thread_messages($folder, $page, $slice=0) + { + // update search_set if previous data was fetched with disabled threading + if (!$this->search_threads) { + if ($this->search_set->is_empty()) { + return array(); + } + $this->search('', $this->search_string, $this->search_charset, $this->sort_field); + } + + return $this->fetch_thread_headers($folder, clone $this->search_set, $page, $slice); + } + + + /** + * Fetches messages headers (by UID) + * + * @param string $folder Folder name + * @param array $msgs Message UIDs + * @param bool $sort Enables result sorting by $msgs + * @param bool $force Disables cache use + * + * @return array Messages headers indexed by UID + */ + function fetch_headers($folder, $msgs, $sort = true, $force = false) + { + if (empty($msgs)) { + return array(); + } + + if (!$force && ($mcache = $this->get_mcache_engine())) { + $headers = $mcache->get_messages($folder, $msgs); + } + else if (!$this->check_connection()) { + return array(); + } + else { + // fetch reqested headers from server + $headers = $this->conn->fetchHeaders( + $folder, $msgs, true, false, $this->get_fetch_headers()); + } + + if (empty($headers)) { + return array(); + } + + foreach ($headers as $h) { + $a_msg_headers[$h->uid] = $h; + } + + if ($sort) { + // use this class for message sorting + $sorter = new rcube_message_header_sorter(); + $sorter->set_index($msgs); + $sorter->sort_headers($a_msg_headers); + } + + return $a_msg_headers; + } + + + /** + * Returns current status of folder + * + * We compare the maximum UID to determine the number of + * new messages because the RECENT flag is not reliable. + * + * @param string $folder Folder name + * + * @return int Folder status + */ + public function folder_status($folder = null) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + $old = $this->get_folder_stats($folder); + + // refresh message count -> will update + $this->countmessages($folder, 'ALL', true); + + $result = 0; + + if (empty($old)) { + return $result; + } + + $new = $this->get_folder_stats($folder); + + // got new messages + if ($new['maxuid'] > $old['maxuid']) { + $result += 1; + } + // some messages has been deleted + if ($new['cnt'] < $old['cnt']) { + $result += 2; + } + + // @TODO: optional checking for messages flags changes (?) + // @TODO: UIDVALIDITY checking + + return $result; + } + + + /** + * Stores folder statistic data in session + * @TODO: move to separate DB table (cache?) + * + * @param string $folder Folder name + * @param string $name Data name + * @param mixed $data Data value + */ + protected function set_folder_stats($folder, $name, $data) + { + $_SESSION['folders'][$folder][$name] = $data; + } + + + /** + * Gets folder statistic data + * + * @param string $folder Folder name + * + * @return array Stats data + */ + protected function get_folder_stats($folder) + { + if ($_SESSION['folders'][$folder]) { + return (array) $_SESSION['folders'][$folder]; + } + + return array(); + } + + + /** + * Return sorted list of message UIDs + * + * @param string $folder Folder to get index from + * @param string $sort_field Sort column + * @param string $sort_order Sort order [ASC, DESC] + * + * @return rcube_result_index|rcube_result_thread List of messages (UIDs) + */ + public function index($folder = '', $sort_field = NULL, $sort_order = NULL) + { + if ($this->threading) { + return $this->thread_index($folder, $sort_field, $sort_order); + } + + $this->set_sort_order($sort_field, $sort_order); + + if (!strlen($folder)) { + $folder = $this->folder; + } + + // we have a saved search result, get index from there + if ($this->search_string) { + if ($this->search_threads) { + $this->search($folder, $this->search_string, $this->search_charset, $this->sort_field); + } + + // use message index sort as default sorting + if (!$this->sort_field || $this->search_sorted) { + if ($this->sort_field && $this->search_sort_field != $this->sort_field) { + $this->search($folder, $this->search_string, $this->search_charset, $this->sort_field); + } + $index = $this->search_set; + } + else if (!$this->check_connection()) { + return new rcube_result_index(); + } + else { + $index = $this->conn->index($folder, $this->search_set->get(), + $this->sort_field, $this->options['skip_deleted'], true, true); + } + + if ($this->sort_order != $index->get_parameters('ORDER')) { + $index->revert(); + } + + return $index; + } + + // check local cache + if ($mcache = $this->get_mcache_engine()) { + $index = $mcache->get_index($folder, $this->sort_field, $this->sort_order); + } + // fetch from IMAP server + else { + $index = $this->index_direct( + $folder, $this->sort_field, $this->sort_order); + } + + return $index; + } + + + /** + * Return sorted list of message UIDs ignoring current search settings. + * Doesn't uses cache by default. + * + * @param string $folder Folder to get index from + * @param string $sort_field Sort column + * @param string $sort_order Sort order [ASC, DESC] + * @param bool $skip_cache Disables cache usage + * + * @return rcube_result_index Sorted list of message UIDs + */ + public function index_direct($folder, $sort_field = null, $sort_order = null, $skip_cache = true) + { + if (!$skip_cache && ($mcache = $this->get_mcache_engine())) { + $index = $mcache->get_index($folder, $sort_field, $sort_order); + } + // use message index sort as default sorting + else if (!$sort_field) { + // use search result from count() if possible + if ($this->options['skip_deleted'] && !empty($this->icache['undeleted_idx']) + && $this->icache['undeleted_idx']->get_parameters('ALL') !== null + && $this->icache['undeleted_idx']->get_parameters('MAILBOX') == $folder + ) { + $index = $this->icache['undeleted_idx']; + } + else if (!$this->check_connection()) { + return new rcube_result_index(); + } + else { + $index = $this->conn->search($folder, + 'ALL' .($this->options['skip_deleted'] ? ' UNDELETED' : ''), true); + } + } + else if (!$this->check_connection()) { + return new rcube_result_index(); + } + // fetch complete message index + else { + if ($this->get_capability('SORT')) { + $index = $this->conn->sort($folder, $sort_field, + $this->options['skip_deleted'] ? 'UNDELETED' : '', true); + } + + if (empty($index) || $index->is_error()) { + $index = $this->conn->index($folder, "1:*", $sort_field, + $this->options['skip_deleted'], false, true); + } + } + + if ($sort_order != $index->get_parameters('ORDER')) { + $index->revert(); + } + + return $index; + } + + + /** + * Return index of threaded message UIDs + * + * @param string $folder Folder to get index from + * @param string $sort_field Sort column + * @param string $sort_order Sort order [ASC, DESC] + * + * @return rcube_result_thread Message UIDs + */ + public function thread_index($folder='', $sort_field=NULL, $sort_order=NULL) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + // we have a saved search result, get index from there + if ($this->search_string && $this->search_threads && $folder == $this->folder) { + $threads = $this->search_set; + } + else { + // get all threads (default sort order) + $threads = $this->fetch_threads($folder); + } + + $this->set_sort_order($sort_field, $sort_order); + $this->sort_threads($threads); + + return $threads; + } + + + /** + * Sort threaded result, using THREAD=REFS method + * + * @param rcube_result_thread $threads Threads result set + */ + protected function sort_threads($threads) + { + if ($threads->is_empty()) { + return; + } + + // THREAD=ORDEREDSUBJECT: sorting by sent date of root message + // THREAD=REFERENCES: sorting by sent date of root message + // THREAD=REFS: sorting by the most recent date in each thread + + if ($this->sort_field && ($this->sort_field != 'date' || $this->get_capability('THREAD') != 'REFS')) { + $index = $this->index_direct($this->folder, $this->sort_field, $this->sort_order, false); + + if (!$index->is_empty()) { + $threads->sort($index); + } + } + else { + if ($this->sort_order != $threads->get_parameters('ORDER')) { + $threads->revert(); + } + } + } + + + /** + * Invoke search request to IMAP server + * + * @param string $folder Folder name to search in + * @param string $str Search criteria + * @param string $charset Search charset + * @param string $sort_field Header field to sort by + * + * @todo: Search criteria should be provided in non-IMAP format, eg. array + */ + public function search($folder='', $str='ALL', $charset=NULL, $sort_field=NULL) + { + if (!$str) { + $str = 'ALL'; + } + + if (!strlen($folder)) { + $folder = $this->folder; + } + + $results = $this->search_index($folder, $str, $charset, $sort_field); + + $this->set_search_set(array($str, $results, $charset, $sort_field, + $this->threading || $this->search_sorted ? true : false)); + } + + + /** + * Direct (real and simple) SEARCH request (without result sorting and caching). + * + * @param string $mailbox Mailbox name to search in + * @param string $str Search string + * + * @return rcube_result_index Search result (UIDs) + */ + public function search_once($folder = null, $str = 'ALL') + { + if (!$str) { + return 'ALL'; + } + + if (!strlen($folder)) { + $folder = $this->folder; + } + + if (!$this->check_connection()) { + return new rcube_result_index(); + } + + $index = $this->conn->search($folder, $str, true); + + return $index; + } + + + /** + * protected search method + * + * @param string $folder Folder name + * @param string $criteria Search criteria + * @param string $charset Charset + * @param string $sort_field Sorting field + * + * @return rcube_result_index|rcube_result_thread Search results (UIDs) + * @see rcube_imap::search() + */ + protected function search_index($folder, $criteria='ALL', $charset=NULL, $sort_field=NULL) + { + $orig_criteria = $criteria; + + if (!$this->check_connection()) { + if ($this->threading) { + return new rcube_result_thread(); + } + else { + return new rcube_result_index(); + } + } + + if ($this->options['skip_deleted'] && !preg_match('/UNDELETED/', $criteria)) { + $criteria = 'UNDELETED '.$criteria; + } + + // unset CHARSET if criteria string is ASCII, this way + // SEARCH won't be re-sent after "unsupported charset" response + if ($charset && $charset != 'US-ASCII' && is_ascii($criteria)) { + $charset = 'US-ASCII'; + } + + if ($this->threading) { + $threads = $this->conn->thread($folder, $this->threading, $criteria, true, $charset); + + // Error, try with US-ASCII (RFC5256: SORT/THREAD must support US-ASCII and UTF-8, + // but I've seen that Courier doesn't support UTF-8) + if ($threads->is_error() && $charset && $charset != 'US-ASCII') { + $threads = $this->conn->thread($folder, $this->threading, + $this->convert_criteria($criteria, $charset), true, 'US-ASCII'); + } + + return $threads; + } + + if ($sort_field && $this->get_capability('SORT')) { + $charset = $charset ? $charset : $this->default_charset; + $messages = $this->conn->sort($folder, $sort_field, $criteria, true, $charset); + + // Error, try with US-ASCII (RFC5256: SORT/THREAD must support US-ASCII and UTF-8, + // but I've seen Courier with disabled UTF-8 support) + if ($messages->is_error() && $charset && $charset != 'US-ASCII') { + $messages = $this->conn->sort($folder, $sort_field, + $this->convert_criteria($criteria, $charset), true, 'US-ASCII'); + } + + if (!$messages->is_error()) { + $this->search_sorted = true; + return $messages; + } + } + + $messages = $this->conn->search($folder, + ($charset && $charset != 'US-ASCII' ? "CHARSET $charset " : '') . $criteria, true); + + // Error, try with US-ASCII (some servers may support only US-ASCII) + if ($messages->is_error() && $charset && $charset != 'US-ASCII') { + $messages = $this->conn->search($folder, + $this->convert_criteria($criteria, $charset), true); + } + + $this->search_sorted = false; + + return $messages; + } + + + /** + * Converts charset of search criteria string + * + * @param string $str Search string + * @param string $charset Original charset + * @param string $dest_charset Destination charset (default US-ASCII) + * + * @return string Search string + */ + protected function convert_criteria($str, $charset, $dest_charset='US-ASCII') + { + // convert strings to US_ASCII + if (preg_match_all('/\{([0-9]+)\}\r\n/', $str, $matches, PREG_OFFSET_CAPTURE)) { + $last = 0; $res = ''; + foreach ($matches[1] as $m) { + $string_offset = $m[1] + strlen($m[0]) + 4; // {}\r\n + $string = substr($str, $string_offset - 1, $m[0]); + $string = rcube_charset::convert($string, $charset, $dest_charset); + if ($string === false) { + continue; + } + $res .= substr($str, $last, $m[1] - $last - 1) . rcube_imap_generic::escape($string); + $last = $m[0] + $string_offset - 1; + } + if ($last < strlen($str)) { + $res .= substr($str, $last, strlen($str)-$last); + } + } + // strings for conversion not found + else { + $res = $str; + } + + return $res; + } + + + /** + * Refresh saved search set + * + * @return array Current search set + */ + public function refresh_search() + { + if (!empty($this->search_string)) { + $this->search('', $this->search_string, $this->search_charset, $this->search_sort_field); + } + + return $this->get_search_set(); + } + + + /** + * Return message headers object of a specific message + * + * @param int $id Message UID + * @param string $folder Folder to read from + * @param bool $force True to skip cache + * + * @return rcube_message_header Message headers + */ + public function get_message_headers($uid, $folder = null, $force = false) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + // get cached headers + if (!$force && $uid && ($mcache = $this->get_mcache_engine())) { + $headers = $mcache->get_message($folder, $uid); + } + else if (!$this->check_connection()) { + $headers = false; + } + else { + $headers = $this->conn->fetchHeader( + $folder, $uid, true, true, $this->get_fetch_headers()); + } + + return $headers; + } + + + /** + * Fetch message headers and body structure from the IMAP server and build + * an object structure similar to the one generated by PEAR::Mail_mimeDecode + * + * @param int $uid Message UID to fetch + * @param string $folder Folder to read from + * + * @return object rcube_message_header Message data + */ + public function get_message($uid, $folder = null) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + // Check internal cache + if (!empty($this->icache['message'])) { + if (($headers = $this->icache['message']) && $headers->uid == $uid) { + return $headers; + } + } + + $headers = $this->get_message_headers($uid, $folder); + + // message doesn't exist? + if (empty($headers)) { + return null; + } + + // structure might be cached + if (!empty($headers->structure)) { + return $headers; + } + + $this->msg_uid = $uid; + + if (!$this->check_connection()) { + return $headers; + } + + if (empty($headers->bodystructure)) { + $headers->bodystructure = $this->conn->getStructure($folder, $uid, true); + } + + $structure = $headers->bodystructure; + + if (empty($structure)) { + return $headers; + } + + // set message charset from message headers + if ($headers->charset) { + $this->struct_charset = $headers->charset; + } + else { + $this->struct_charset = $this->structure_charset($structure); + } + + $headers->ctype = strtolower($headers->ctype); + + // Here we can recognize malformed BODYSTRUCTURE and + // 1. [@TODO] parse the message in other way to create our own message structure + // 2. or just show the raw message body. + // Example of structure for malformed MIME message: + // ("text" "plain" NIL NIL NIL "7bit" 2154 70 NIL NIL NIL) + if ($headers->ctype && !is_array($structure[0]) && $headers->ctype != 'text/plain' + && strtolower($structure[0].'/'.$structure[1]) == 'text/plain') { + // we can handle single-part messages, by simple fix in structure (#1486898) + if (preg_match('/^(text|application)\/(.*)/', $headers->ctype, $m)) { + $structure[0] = $m[1]; + $structure[1] = $m[2]; + } + else { + // Try to parse the message using Mail_mimeDecode package + // We need a better solution, Mail_mimeDecode parses message + // in memory, which wouldn't work for very big messages, + // (it uses up to 10x more memory than the message size) + // it's also buggy and not actively developed + if ($headers->size && rcube_utils::mem_check($headers->size * 10)) { + $raw_msg = $this->get_raw_body($uid); + $struct = rcube_mime::parse_message($raw_msg); + } + else { + return $headers; + } + } + } + + if (empty($struct)) { + $struct = $this->structure_part($structure, 0, '', $headers); + } + + // don't trust given content-type + if (empty($struct->parts) && !empty($headers->ctype)) { + $struct->mime_id = '1'; + $struct->mimetype = strtolower($headers->ctype); + list($struct->ctype_primary, $struct->ctype_secondary) = explode('/', $struct->mimetype); + } + + $headers->structure = $struct; + + return $this->icache['message'] = $headers; + } + + + /** + * Build message part object + * + * @param array $part + * @param int $count + * @param string $parent + */ + protected function structure_part($part, $count=0, $parent='', $mime_headers=null) + { + $struct = new rcube_message_part; + $struct->mime_id = empty($parent) ? (string)$count : "$parent.$count"; + + // multipart + if (is_array($part[0])) { + $struct->ctype_primary = 'multipart'; + + /* RFC3501: BODYSTRUCTURE fields of multipart part + part1 array + part2 array + part3 array + .... + 1. subtype + 2. parameters (optional) + 3. description (optional) + 4. language (optional) + 5. location (optional) + */ + + // find first non-array entry + for ($i=1; $i<count($part); $i++) { + if (!is_array($part[$i])) { + $struct->ctype_secondary = strtolower($part[$i]); + break; + } + } + + $struct->mimetype = 'multipart/'.$struct->ctype_secondary; + + // build parts list for headers pre-fetching + for ($i=0; $i<count($part); $i++) { + if (!is_array($part[$i])) { + break; + } + // fetch message headers if message/rfc822 + // or named part (could contain Content-Location header) + if (!is_array($part[$i][0])) { + $tmp_part_id = $struct->mime_id ? $struct->mime_id.'.'.($i+1) : $i+1; + if (strtolower($part[$i][0]) == 'message' && strtolower($part[$i][1]) == 'rfc822') { + $mime_part_headers[] = $tmp_part_id; + } + else if (in_array('name', (array)$part[$i][2]) && empty($part[$i][3])) { + $mime_part_headers[] = $tmp_part_id; + } + } + } + + // pre-fetch headers of all parts (in one command for better performance) + // @TODO: we could do this before _structure_part() call, to fetch + // headers for parts on all levels + if ($mime_part_headers) { + $mime_part_headers = $this->conn->fetchMIMEHeaders($this->folder, + $this->msg_uid, $mime_part_headers); + } + + $struct->parts = array(); + for ($i=0, $count=0; $i<count($part); $i++) { + if (!is_array($part[$i])) { + break; + } + $tmp_part_id = $struct->mime_id ? $struct->mime_id.'.'.($i+1) : $i+1; + $struct->parts[] = $this->structure_part($part[$i], ++$count, $struct->mime_id, + $mime_part_headers[$tmp_part_id]); + } + + return $struct; + } + + /* RFC3501: BODYSTRUCTURE fields of non-multipart part + 0. type + 1. subtype + 2. parameters + 3. id + 4. description + 5. encoding + 6. size + -- text + 7. lines + -- message/rfc822 + 7. envelope structure + 8. body structure + 9. lines + -- + x. md5 (optional) + x. disposition (optional) + x. language (optional) + x. location (optional) + */ + + // regular part + $struct->ctype_primary = strtolower($part[0]); + $struct->ctype_secondary = strtolower($part[1]); + $struct->mimetype = $struct->ctype_primary.'/'.$struct->ctype_secondary; + + // read content type parameters + if (is_array($part[2])) { + $struct->ctype_parameters = array(); + for ($i=0; $i<count($part[2]); $i+=2) { + $struct->ctype_parameters[strtolower($part[2][$i])] = $part[2][$i+1]; + } + + if (isset($struct->ctype_parameters['charset'])) { + $struct->charset = $struct->ctype_parameters['charset']; + } + } + + // #1487700: workaround for lack of charset in malformed structure + if (empty($struct->charset) && !empty($mime_headers) && $mime_headers->charset) { + $struct->charset = $mime_headers->charset; + } + + // read content encoding + if (!empty($part[5])) { + $struct->encoding = strtolower($part[5]); + $struct->headers['content-transfer-encoding'] = $struct->encoding; + } + + // get part size + if (!empty($part[6])) { + $struct->size = intval($part[6]); + } + + // read part disposition + $di = 8; + if ($struct->ctype_primary == 'text') { + $di += 1; + } + else if ($struct->mimetype == 'message/rfc822') { + $di += 3; + } + + if (is_array($part[$di]) && count($part[$di]) == 2) { + $struct->disposition = strtolower($part[$di][0]); + + if (is_array($part[$di][1])) { + for ($n=0; $n<count($part[$di][1]); $n+=2) { + $struct->d_parameters[strtolower($part[$di][1][$n])] = $part[$di][1][$n+1]; + } + } + } + + // get message/rfc822's child-parts + if (is_array($part[8]) && $di != 8) { + $struct->parts = array(); + for ($i=0, $count=0; $i<count($part[8]); $i++) { + if (!is_array($part[8][$i])) { + break; + } + $struct->parts[] = $this->structure_part($part[8][$i], ++$count, $struct->mime_id); + } + } + + // get part ID + if (!empty($part[3])) { + $struct->content_id = $part[3]; + $struct->headers['content-id'] = $part[3]; + + if (empty($struct->disposition)) { + $struct->disposition = 'inline'; + } + } + + // fetch message headers if message/rfc822 or named part (could contain Content-Location header) + if ($struct->ctype_primary == 'message' || ($struct->ctype_parameters['name'] && !$struct->content_id)) { + if (empty($mime_headers)) { + $mime_headers = $this->conn->fetchPartHeader( + $this->folder, $this->msg_uid, true, $struct->mime_id); + } + + if (is_string($mime_headers)) { + $struct->headers = rcube_mime::parse_headers($mime_headers) + $struct->headers; + } + else if (is_object($mime_headers)) { + $struct->headers = get_object_vars($mime_headers) + $struct->headers; + } + + // get real content-type of message/rfc822 + if ($struct->mimetype == 'message/rfc822') { + // single-part + if (!is_array($part[8][0])) { + $struct->real_mimetype = strtolower($part[8][0] . '/' . $part[8][1]); + } + // multi-part + else { + for ($n=0; $n<count($part[8]); $n++) { + if (!is_array($part[8][$n])) { + break; + } + } + $struct->real_mimetype = 'multipart/' . strtolower($part[8][$n]); + } + } + + if ($struct->ctype_primary == 'message' && empty($struct->parts)) { + if (is_array($part[8]) && $di != 8) { + $struct->parts[] = $this->structure_part($part[8], ++$count, $struct->mime_id); + } + } + } + + // normalize filename property + $this->set_part_filename($struct, $mime_headers); + + return $struct; + } + + + /** + * Set attachment filename from message part structure + * + * @param rcube_message_part $part Part object + * @param string $headers Part's raw headers + */ + protected function set_part_filename(&$part, $headers=null) + { + if (!empty($part->d_parameters['filename'])) { + $filename_mime = $part->d_parameters['filename']; + } + else if (!empty($part->d_parameters['filename*'])) { + $filename_encoded = $part->d_parameters['filename*']; + } + else if (!empty($part->ctype_parameters['name*'])) { + $filename_encoded = $part->ctype_parameters['name*']; + } + // RFC2231 value continuations + // TODO: this should be rewrited to support RFC2231 4.1 combinations + else if (!empty($part->d_parameters['filename*0'])) { + $i = 0; + while (isset($part->d_parameters['filename*'.$i])) { + $filename_mime .= $part->d_parameters['filename*'.$i]; + $i++; + } + // some servers (eg. dovecot-1.x) have no support for parameter value continuations + // we must fetch and parse headers "manually" + if ($i<2) { + if (!$headers) { + $headers = $this->conn->fetchPartHeader( + $this->folder, $this->msg_uid, true, $part->mime_id); + } + $filename_mime = ''; + $i = 0; + while (preg_match('/filename\*'.$i.'\s*=\s*"*([^"\n;]+)[";]*/', $headers, $matches)) { + $filename_mime .= $matches[1]; + $i++; + } + } + } + else if (!empty($part->d_parameters['filename*0*'])) { + $i = 0; + while (isset($part->d_parameters['filename*'.$i.'*'])) { + $filename_encoded .= $part->d_parameters['filename*'.$i.'*']; + $i++; + } + if ($i<2) { + if (!$headers) { + $headers = $this->conn->fetchPartHeader( + $this->folder, $this->msg_uid, true, $part->mime_id); + } + $filename_encoded = ''; + $i = 0; $matches = array(); + while (preg_match('/filename\*'.$i.'\*\s*=\s*"*([^"\n;]+)[";]*/', $headers, $matches)) { + $filename_encoded .= $matches[1]; + $i++; + } + } + } + else if (!empty($part->ctype_parameters['name*0'])) { + $i = 0; + while (isset($part->ctype_parameters['name*'.$i])) { + $filename_mime .= $part->ctype_parameters['name*'.$i]; + $i++; + } + if ($i<2) { + if (!$headers) { + $headers = $this->conn->fetchPartHeader( + $this->folder, $this->msg_uid, true, $part->mime_id); + } + $filename_mime = ''; + $i = 0; $matches = array(); + while (preg_match('/\s+name\*'.$i.'\s*=\s*"*([^"\n;]+)[";]*/', $headers, $matches)) { + $filename_mime .= $matches[1]; + $i++; + } + } + } + else if (!empty($part->ctype_parameters['name*0*'])) { + $i = 0; + while (isset($part->ctype_parameters['name*'.$i.'*'])) { + $filename_encoded .= $part->ctype_parameters['name*'.$i.'*']; + $i++; + } + if ($i<2) { + if (!$headers) { + $headers = $this->conn->fetchPartHeader( + $this->folder, $this->msg_uid, true, $part->mime_id); + } + $filename_encoded = ''; + $i = 0; $matches = array(); + while (preg_match('/\s+name\*'.$i.'\*\s*=\s*"*([^"\n;]+)[";]*/', $headers, $matches)) { + $filename_encoded .= $matches[1]; + $i++; + } + } + } + // read 'name' after rfc2231 parameters as it may contains truncated filename (from Thunderbird) + else if (!empty($part->ctype_parameters['name'])) { + $filename_mime = $part->ctype_parameters['name']; + } + // Content-Disposition + else if (!empty($part->headers['content-description'])) { + $filename_mime = $part->headers['content-description']; + } + else { + return; + } + + // decode filename + if (!empty($filename_mime)) { + if (!empty($part->charset)) { + $charset = $part->charset; + } + else if (!empty($this->struct_charset)) { + $charset = $this->struct_charset; + } + else { + $charset = rcube_charset::detect($filename_mime, $this->default_charset); + } + + $part->filename = rcube_mime::decode_mime_string($filename_mime, $charset); + } + else if (!empty($filename_encoded)) { + // decode filename according to RFC 2231, Section 4 + if (preg_match("/^([^']*)'[^']*'(.*)$/", $filename_encoded, $fmatches)) { + $filename_charset = $fmatches[1]; + $filename_encoded = $fmatches[2]; + } + + $part->filename = rcube_charset::convert(urldecode($filename_encoded), $filename_charset); + } + } + + + /** + * Get charset name from message structure (first part) + * + * @param array $structure Message structure + * + * @return string Charset name + */ + protected function structure_charset($structure) + { + while (is_array($structure)) { + if (is_array($structure[2]) && $structure[2][0] == 'charset') { + return $structure[2][1]; + } + $structure = $structure[0]; + } + } + + + /** + * Fetch message body of a specific message from the server + * + * @param int $uid Message UID + * @param string $part Part number + * @param rcube_message_part $o_part Part object created by get_structure() + * @param mixed $print True to print part, ressource to write part contents in + * @param resource $fp File pointer to save the message part + * @param boolean $skip_charset_conv Disables charset conversion + * @param int $max_bytes Only read this number of bytes + * + * @return string Message/part body if not printed + */ + public function get_message_part($uid, $part=1, $o_part=NULL, $print=NULL, $fp=NULL, $skip_charset_conv=false, $max_bytes=0) + { + if (!$this->check_connection()) { + return null; + } + + // get part data if not provided + if (!is_object($o_part)) { + $structure = $this->conn->getStructure($this->folder, $uid, true); + $part_data = rcube_imap_generic::getStructurePartData($structure, $part); + + $o_part = new rcube_message_part; + $o_part->ctype_primary = $part_data['type']; + $o_part->encoding = $part_data['encoding']; + $o_part->charset = $part_data['charset']; + $o_part->size = $part_data['size']; + } + + if ($o_part && $o_part->size) { + $body = $this->conn->handlePartBody($this->folder, $uid, true, + $part ? $part : 'TEXT', $o_part->encoding, $print, $fp, $o_part->ctype_primary == 'text', $max_bytes); + } + + if ($fp || $print) { + return true; + } + + // convert charset (if text or message part) + if ($body && preg_match('/^(text|message)$/', $o_part->ctype_primary)) { + // Remove NULL characters if any (#1486189) + if (strpos($body, "\x00") !== false) { + $body = str_replace("\x00", '', $body); + } + + if (!$skip_charset_conv) { + if (!$o_part->charset || strtoupper($o_part->charset) == 'US-ASCII') { + // try to extract charset information from HTML meta tag (#1488125) + if ($o_part->ctype_secondary == 'html' && preg_match('/<meta[^>]+charset=([a-z0-9-_]+)/i', $body, $m)) { + $o_part->charset = strtoupper($m[1]); + } + else { + $o_part->charset = $this->default_charset; + } + } + $body = rcube_charset::convert($body, $o_part->charset); + } + } + + return $body; + } + + + /** + * Returns the whole message source as string (or saves to a file) + * + * @param int $uid Message UID + * @param resource $fp File pointer to save the message + * + * @return string Message source string + */ + public function get_raw_body($uid, $fp=null) + { + if (!$this->check_connection()) { + return null; + } + + return $this->conn->handlePartBody($this->folder, $uid, + true, null, null, false, $fp); + } + + + /** + * Returns the message headers as string + * + * @param int $uid Message UID + * + * @return string Message headers string + */ + public function get_raw_headers($uid) + { + if (!$this->check_connection()) { + return null; + } + + return $this->conn->fetchPartHeader($this->folder, $uid, true); + } + + + /** + * Sends the whole message source to stdout + * + * @param int $uid Message UID + * @param bool $formatted Enables line-ending formatting + */ + public function print_raw_body($uid, $formatted = true) + { + if (!$this->check_connection()) { + return; + } + + $this->conn->handlePartBody($this->folder, $uid, true, null, null, true, null, $formatted); + } + + + /** + * Set message flag to one or several messages + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $flag Flag to set: SEEN, UNDELETED, DELETED, RECENT, ANSWERED, DRAFT, MDNSENT + * @param string $folder Folder name + * @param boolean $skip_cache True to skip message cache clean up + * + * @return boolean Operation status + */ + public function set_flag($uids, $flag, $folder=null, $skip_cache=false) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + if (!$this->check_connection()) { + return false; + } + + $flag = strtoupper($flag); + list($uids, $all_mode) = $this->parse_uids($uids); + + if (strpos($flag, 'UN') === 0) { + $result = $this->conn->unflag($folder, $uids, substr($flag, 2)); + } + else { + $result = $this->conn->flag($folder, $uids, $flag); + } + + if ($result && !$skip_cache) { + // reload message headers if cached + // update flags instead removing from cache + if ($mcache = $this->get_mcache_engine()) { + $status = strpos($flag, 'UN') !== 0; + $mflag = preg_replace('/^UN/', '', $flag); + $mcache->change_flag($folder, $all_mode ? null : explode(',', $uids), + $mflag, $status); + } + + // clear cached counters + if ($flag == 'SEEN' || $flag == 'UNSEEN') { + $this->clear_messagecount($folder, 'SEEN'); + $this->clear_messagecount($folder, 'UNSEEN'); + } + else if ($flag == 'DELETED' || $flag == 'UNDELETED') { + $this->clear_messagecount($folder, 'DELETED'); + // remove cached messages + if ($this->options['skip_deleted']) { + $this->clear_message_cache($folder, $all_mode ? null : explode(',', $uids)); + } + } + } + + return $result; + } + + + /** + * Append a mail message (source) to a specific folder + * + * @param string $folder Target folder + * @param string $message The message source string or filename + * @param string $headers Headers string if $message contains only the body + * @param boolean $is_file True if $message is a filename + * @param array $flags Message flags + * @param mixed $date Message internal date + * + * @return int|bool Appended message UID or True on success, False on error + */ + public function save_message($folder, &$message, $headers='', $is_file=false, $flags = array(), $date = null) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + if (!$this->check_connection()) { + return false; + } + + // make sure folder exists + if (!$this->folder_exists($folder)) { + return false; + } + + $date = $this->date_format($date); + + if ($is_file) { + $saved = $this->conn->appendFromFile($folder, $message, $headers, $flags, $date); + } + else { + $saved = $this->conn->append($folder, $message, $flags, $date); + } + + if ($saved) { + // increase messagecount of the target folder + $this->set_messagecount($folder, 'ALL', 1); + } + + return $saved; + } + + + /** + * Move a message from one folder to another + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $to_mbox Target folder + * @param string $from_mbox Source folder + * + * @return boolean True on success, False on error + */ + public function move_message($uids, $to_mbox, $from_mbox='') + { + if (!strlen($from_mbox)) { + $from_mbox = $this->folder; + } + + if ($to_mbox === $from_mbox) { + return false; + } + + list($uids, $all_mode) = $this->parse_uids($uids); + + // exit if no message uids are specified + if (empty($uids)) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + // make sure folder exists + if ($to_mbox != 'INBOX' && !$this->folder_exists($to_mbox)) { + if (in_array($to_mbox, $this->default_folders)) { + if (!$this->create_folder($to_mbox, true)) { + return false; + } + } + else { + return false; + } + } + + $config = rcube::get_instance()->config; + $to_trash = $to_mbox == $config->get('trash_mbox'); + + // flag messages as read before moving them + if ($to_trash && $config->get('read_when_deleted')) { + // don't flush cache (4th argument) + $this->set_flag($uids, 'SEEN', $from_mbox, true); + } + + // move messages + $moved = $this->conn->move($uids, $from_mbox, $to_mbox); + + // send expunge command in order to have the moved message + // really deleted from the source folder + if ($moved) { + $this->expunge_message($uids, $from_mbox, false); + $this->clear_messagecount($from_mbox); + $this->clear_messagecount($to_mbox); + } + // moving failed + else if ($to_trash && $config->get('delete_always', false)) { + $moved = $this->delete_message($uids, $from_mbox); + } + + if ($moved) { + // unset threads internal cache + unset($this->icache['threads']); + + // remove message ids from search set + if ($this->search_set && $from_mbox == $this->folder) { + // threads are too complicated to just remove messages from set + if ($this->search_threads || $all_mode) { + $this->refresh_search(); + } + else { + $this->search_set->filter(explode(',', $uids)); + } + } + + // remove cached messages + // @TODO: do cache update instead of clearing it + $this->clear_message_cache($from_mbox, $all_mode ? null : explode(',', $uids)); + } + + return $moved; + } + + + /** + * Copy a message from one folder to another + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $to_mbox Target folder + * @param string $from_mbox Source folder + * + * @return boolean True on success, False on error + */ + public function copy_message($uids, $to_mbox, $from_mbox='') + { + if (!strlen($from_mbox)) { + $from_mbox = $this->folder; + } + + list($uids, $all_mode) = $this->parse_uids($uids); + + // exit if no message uids are specified + if (empty($uids)) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + // make sure folder exists + if ($to_mbox != 'INBOX' && !$this->folder_exists($to_mbox)) { + if (in_array($to_mbox, $this->default_folders)) { + if (!$this->create_folder($to_mbox, true)) { + return false; + } + } + else { + return false; + } + } + + // copy messages + $copied = $this->conn->copy($uids, $from_mbox, $to_mbox); + + if ($copied) { + $this->clear_messagecount($to_mbox); + } + + return $copied; + } + + + /** + * Mark messages as deleted and expunge them + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $folder Source folder + * + * @return boolean True on success, False on error + */ + public function delete_message($uids, $folder='') + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + list($uids, $all_mode) = $this->parse_uids($uids); + + // exit if no message uids are specified + if (empty($uids)) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + $deleted = $this->conn->flag($folder, $uids, 'DELETED'); + + if ($deleted) { + // send expunge command in order to have the deleted message + // really deleted from the folder + $this->expunge_message($uids, $folder, false); + $this->clear_messagecount($folder); + unset($this->uid_id_map[$folder]); + + // unset threads internal cache + unset($this->icache['threads']); + + // remove message ids from search set + if ($this->search_set && $folder == $this->folder) { + // threads are too complicated to just remove messages from set + if ($this->search_threads || $all_mode) { + $this->refresh_search(); + } + else { + $this->search_set->filter(explode(',', $uids)); + } + } + + // remove cached messages + $this->clear_message_cache($folder, $all_mode ? null : explode(',', $uids)); + } + + return $deleted; + } + + + /** + * Send IMAP expunge command and clear cache + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $folder Folder name + * @param boolean $clear_cache False if cache should not be cleared + * + * @return boolean True on success, False on failure + */ + public function expunge_message($uids, $folder = null, $clear_cache = true) + { + if ($uids && $this->get_capability('UIDPLUS')) { + list($uids, $all_mode) = $this->parse_uids($uids); + } + else { + $uids = null; + } + + if (!strlen($folder)) { + $folder = $this->folder; + } + + if (!$this->check_connection()) { + return false; + } + + // force folder selection and check if folder is writeable + // to prevent a situation when CLOSE is executed on closed + // or EXPUNGE on read-only folder + $result = $this->conn->select($folder); + if (!$result) { + return false; + } + + if (!$this->conn->data['READ-WRITE']) { + $this->conn->setError(rcube_imap_generic::ERROR_READONLY, "Folder is read-only"); + return false; + } + + // CLOSE(+SELECT) should be faster than EXPUNGE + if (empty($uids) || $all_mode) { + $result = $this->conn->close(); + } + else { + $result = $this->conn->expunge($folder, $uids); + } + + if ($result && $clear_cache) { + $this->clear_message_cache($folder, $all_mode ? null : explode(',', $uids)); + $this->clear_messagecount($folder); + } + + return $result; + } + + + /* -------------------------------- + * folder managment + * --------------------------------*/ + + /** + * Public method for listing subscribed folders. + * + * @param string $root Optional root folder + * @param string $name Optional name pattern + * @param string $filter Optional filter + * @param string $rights Optional ACL requirements + * @param bool $skip_sort Enable to return unsorted list (for better performance) + * + * @return array List of folders + */ + public function list_folders_subscribed($root='', $name='*', $filter=null, $rights=null, $skip_sort=false) + { + $cache_key = $root.':'.$name; + if (!empty($filter)) { + $cache_key .= ':'.(is_string($filter) ? $filter : serialize($filter)); + } + $cache_key .= ':'.$rights; + $cache_key = 'mailboxes.'.md5($cache_key); + + // get cached folder list + $a_mboxes = $this->get_cache($cache_key); + if (is_array($a_mboxes)) { + return $a_mboxes; + } + + // Give plugins a chance to provide a list of folders + $data = rcube::get_instance()->plugins->exec_hook('storage_folders', + array('root' => $root, 'name' => $name, 'filter' => $filter, 'mode' => 'LSUB')); + + if (isset($data['folders'])) { + $a_mboxes = $data['folders']; + } + else { + $a_mboxes = $this->list_folders_subscribed_direct($root, $name); + } + + if (!is_array($a_mboxes)) { + return array(); + } + + // filter folders list according to rights requirements + if ($rights && $this->get_capability('ACL')) { + $a_mboxes = $this->filter_rights($a_mboxes, $rights); + } + + // INBOX should always be available + if ((!$filter || $filter == 'mail') && !in_array('INBOX', $a_mboxes)) { + array_unshift($a_mboxes, 'INBOX'); + } + + // sort folders (always sort for cache) + if (!$skip_sort || $this->cache) { + $a_mboxes = $this->sort_folder_list($a_mboxes); + } + + // write folders list to cache + $this->update_cache($cache_key, $a_mboxes); + + return $a_mboxes; + } + + + /** + * Method for direct folders listing (LSUB) + * + * @param string $root Optional root folder + * @param string $name Optional name pattern + * + * @return array List of subscribed folders + * @see rcube_imap::list_folders_subscribed() + */ + public function list_folders_subscribed_direct($root='', $name='*') + { + if (!$this->check_connection()) { + return null; + } + + $config = rcube::get_instance()->config; + + // Server supports LIST-EXTENDED, we can use selection options + // #1486225: Some dovecot versions returns wrong result using LIST-EXTENDED + $list_extended = !$config->get('imap_force_lsub') && $this->get_capability('LIST-EXTENDED'); + if ($list_extended) { + // This will also set folder options, LSUB doesn't do that + $a_folders = $this->conn->listMailboxes($root, $name, + NULL, array('SUBSCRIBED')); + } + else { + // retrieve list of folders from IMAP server using LSUB + $a_folders = $this->conn->listSubscribed($root, $name); + } + + if (!is_array($a_folders)) { + return array(); + } + + // #1486796: some server configurations doesn't return folders in all namespaces + if ($root == '' && $name == '*' && $config->get('imap_force_ns')) { + $this->list_folders_update($a_folders, ($list_extended ? 'ext-' : '') . 'subscribed'); + } + + if ($list_extended) { + // unsubscribe non-existent folders, remove from the list + // we can do this only when LIST response is available + if (is_array($a_folders) && $name == '*' && !empty($this->conn->data['LIST'])) { + foreach ($a_folders as $idx => $folder) { + if (($opts = $this->conn->data['LIST'][$folder]) + && in_array('\\NonExistent', $opts) + ) { + $this->conn->unsubscribe($folder); + unset($a_folders[$idx]); + } + } + } + } + else { + // unsubscribe non-existent folders, remove them from the list, + // we can do this only when LIST response is available + if (is_array($a_folders) && $name == '*' && !empty($this->conn->data['LIST'])) { + foreach ($a_folders as $idx => $folder) { + if (!isset($this->conn->data['LIST'][$folder]) + || in_array('\\Noselect', $this->conn->data['LIST'][$folder]) + ) { + // Some servers returns \Noselect for existing folders + if (!$this->folder_exists($folder)) { + $this->conn->unsubscribe($folder); + unset($a_folders[$idx]); + } + } + } + } + } + + return $a_folders; + } + + + /** + * Get a list of all folders available on the server + * + * @param string $root IMAP root dir + * @param string $name Optional name pattern + * @param mixed $filter Optional filter + * @param string $rights Optional ACL requirements + * @param bool $skip_sort Enable to return unsorted list (for better performance) + * + * @return array Indexed array with folder names + */ + public function list_folders($root='', $name='*', $filter=null, $rights=null, $skip_sort=false) + { + $cache_key = $root.':'.$name; + if (!empty($filter)) { + $cache_key .= ':'.(is_string($filter) ? $filter : serialize($filter)); + } + $cache_key .= ':'.$rights; + $cache_key = 'mailboxes.list.'.md5($cache_key); + + // get cached folder list + $a_mboxes = $this->get_cache($cache_key); + if (is_array($a_mboxes)) { + return $a_mboxes; + } + + // Give plugins a chance to provide a list of folders + $data = rcube::get_instance()->plugins->exec_hook('storage_folders', + array('root' => $root, 'name' => $name, 'filter' => $filter, 'mode' => 'LIST')); + + if (isset($data['folders'])) { + $a_mboxes = $data['folders']; + } + else { + // retrieve list of folders from IMAP server + $a_mboxes = $this->list_folders_direct($root, $name); + } + + if (!is_array($a_mboxes)) { + $a_mboxes = array(); + } + + // INBOX should always be available + if ((!$filter || $filter == 'mail') && !in_array('INBOX', $a_mboxes)) { + array_unshift($a_mboxes, 'INBOX'); + } + + // cache folder attributes + if ($root == '' && $name == '*' && empty($filter) && !empty($this->conn->data)) { + $this->update_cache('mailboxes.attributes', $this->conn->data['LIST']); + } + + // filter folders list according to rights requirements + if ($rights && $this->get_capability('ACL')) { + $a_folders = $this->filter_rights($a_folders, $rights); + } + + // filter folders and sort them + if (!$skip_sort) { + $a_mboxes = $this->sort_folder_list($a_mboxes); + } + + // write folders list to cache + $this->update_cache($cache_key, $a_mboxes); + + return $a_mboxes; + } + + + /** + * Method for direct folders listing (LIST) + * + * @param string $root Optional root folder + * @param string $name Optional name pattern + * + * @return array List of folders + * @see rcube_imap::list_folders() + */ + public function list_folders_direct($root='', $name='*') + { + if (!$this->check_connection()) { + return null; + } + + $result = $this->conn->listMailboxes($root, $name); + + if (!is_array($result)) { + return array(); + } + + $config = rcube::get_instance()->config; + + // #1486796: some server configurations doesn't return folders in all namespaces + if ($root == '' && $name == '*' && $config->get('imap_force_ns')) { + $this->list_folders_update($result); + } + + return $result; + } + + + /** + * Fix folders list by adding folders from other namespaces. + * Needed on some servers eg. Courier IMAP + * + * @param array $result Reference to folders list + * @param string $type Listing type (ext-subscribed, subscribed or all) + */ + private function list_folders_update(&$result, $type = null) + { + $delim = $this->get_hierarchy_delimiter(); + $namespace = $this->get_namespace(); + $search = array(); + + // build list of namespace prefixes + foreach ((array)$namespace as $ns) { + if (is_array($ns)) { + foreach ($ns as $ns_data) { + if (strlen($ns_data[0])) { + $search[] = $ns_data[0]; + } + } + } + } + + if (!empty($search)) { + // go through all folders detecting namespace usage + foreach ($result as $folder) { + foreach ($search as $idx => $prefix) { + if (strpos($folder, $prefix) === 0) { + unset($search[$idx]); + } + } + if (empty($search)) { + break; + } + } + + // get folders in hidden namespaces and add to the result + foreach ($search as $prefix) { + if ($type == 'ext-subscribed') { + $list = $this->conn->listMailboxes('', $prefix . '*', null, array('SUBSCRIBED')); + } + else if ($type == 'subscribed') { + $list = $this->conn->listSubscribed('', $prefix . '*'); + } + else { + $list = $this->conn->listMailboxes('', $prefix . '*'); + } + + if (!empty($list)) { + $result = array_merge($result, $list); + } + } + } + } + + + /** + * Filter the given list of folders according to access rights + */ + protected function filter_rights($a_folders, $rights) + { + $regex = '/('.$rights.')/'; + foreach ($a_folders as $idx => $folder) { + $myrights = join('', (array)$this->my_rights($folder)); + if ($myrights !== null && !preg_match($regex, $myrights)) { + unset($a_folders[$idx]); + } + } + + return $a_folders; + } + + + /** + * Get mailbox quota information + * added by Nuny + * + * @return mixed Quota info or False if not supported + */ + public function get_quota() + { + if ($this->get_capability('QUOTA') && $this->check_connection()) { + return $this->conn->getQuota(); + } + + return false; + } + + + /** + * Get folder size (size of all messages in a folder) + * + * @param string $folder Folder name + * + * @return int Folder size in bytes, False on error + */ + public function folder_size($folder) + { + if (!$this->check_connection()) { + return 0; + } + + // @TODO: could we try to use QUOTA here? + $result = $this->conn->fetchHeaderIndex($folder, '1:*', 'SIZE', false); + + if (is_array($result)) { + $result = array_sum($result); + } + + return $result; + } + + + /** + * Subscribe to a specific folder(s) + * + * @param array $folders Folder name(s) + * + * @return boolean True on success + */ + public function subscribe($folders) + { + // let this common function do the main work + return $this->change_subscription($folders, 'subscribe'); + } + + + /** + * Unsubscribe folder(s) + * + * @param array $a_mboxes Folder name(s) + * + * @return boolean True on success + */ + public function unsubscribe($folders) + { + // let this common function do the main work + return $this->change_subscription($folders, 'unsubscribe'); + } + + + /** + * Create a new folder on the server and register it in local cache + * + * @param string $folder New folder name + * @param boolean $subscribe True if the new folder should be subscribed + * + * @return boolean True on success + */ + public function create_folder($folder, $subscribe=false) + { + if (!$this->check_connection()) { + return false; + } + + $result = $this->conn->createFolder($folder); + + // try to subscribe it + if ($result) { + // clear cache + $this->clear_cache('mailboxes', true); + + if ($subscribe) { + $this->subscribe($folder); + } + } + + return $result; + } + + + /** + * Set a new name to an existing folder + * + * @param string $folder Folder to rename + * @param string $new_name New folder name + * + * @return boolean True on success + */ + public function rename_folder($folder, $new_name) + { + if (!strlen($new_name)) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + $delm = $this->get_hierarchy_delimiter(); + + // get list of subscribed folders + if ((strpos($folder, '%') === false) && (strpos($folder, '*') === false)) { + $a_subscribed = $this->list_folders_subscribed('', $folder . $delm . '*'); + $subscribed = $this->folder_exists($folder, true); + } + else { + $a_subscribed = $this->list_folders_subscribed(); + $subscribed = in_array($folder, $a_subscribed); + } + + $result = $this->conn->renameFolder($folder, $new_name); + + if ($result) { + // unsubscribe the old folder, subscribe the new one + if ($subscribed) { + $this->conn->unsubscribe($folder); + $this->conn->subscribe($new_name); + } + + // check if folder children are subscribed + foreach ($a_subscribed as $c_subscribed) { + if (strpos($c_subscribed, $folder.$delm) === 0) { + $this->conn->unsubscribe($c_subscribed); + $this->conn->subscribe(preg_replace('/^'.preg_quote($folder, '/').'/', + $new_name, $c_subscribed)); + + // clear cache + $this->clear_message_cache($c_subscribed); + } + } + + // clear cache + $this->clear_message_cache($folder); + $this->clear_cache('mailboxes', true); + } + + return $result; + } + + + /** + * Remove folder from server + * + * @param string $folder Folder name + * + * @return boolean True on success + */ + function delete_folder($folder) + { + $delm = $this->get_hierarchy_delimiter(); + + if (!$this->check_connection()) { + return false; + } + + // get list of folders + if ((strpos($folder, '%') === false) && (strpos($folder, '*') === false)) { + $sub_mboxes = $this->list_folders('', $folder . $delm . '*'); + } + else { + $sub_mboxes = $this->list_folders(); + } + + // send delete command to server + $result = $this->conn->deleteFolder($folder); + + if ($result) { + // unsubscribe folder + $this->conn->unsubscribe($folder); + + foreach ($sub_mboxes as $c_mbox) { + if (strpos($c_mbox, $folder.$delm) === 0) { + $this->conn->unsubscribe($c_mbox); + if ($this->conn->deleteFolder($c_mbox)) { + $this->clear_message_cache($c_mbox); + } + } + } + + // clear folder-related cache + $this->clear_message_cache($folder); + $this->clear_cache('mailboxes', true); + } + + return $result; + } + + + /** + * Create all folders specified as default + */ + public function create_default_folders() + { + // create default folders if they do not exist + foreach ($this->default_folders as $folder) { + if (!$this->folder_exists($folder)) { + $this->create_folder($folder, true); + } + else if (!$this->folder_exists($folder, true)) { + $this->subscribe($folder); + } + } + } + + + /** + * Checks if folder exists and is subscribed + * + * @param string $folder Folder name + * @param boolean $subscription Enable subscription checking + * + * @return boolean TRUE or FALSE + */ + public function folder_exists($folder, $subscription=false) + { + if ($folder == 'INBOX') { + return true; + } + + $key = $subscription ? 'subscribed' : 'existing'; + + if (is_array($this->icache[$key]) && in_array($folder, $this->icache[$key])) { + return true; + } + + if (!$this->check_connection()) { + return false; + } + + if ($subscription) { + $a_folders = $this->conn->listSubscribed('', $folder); + } + else { + $a_folders = $this->conn->listMailboxes('', $folder); + } + + if (is_array($a_folders) && in_array($folder, $a_folders)) { + $this->icache[$key][] = $folder; + return true; + } + + return false; + } + + + /** + * Returns the namespace where the folder is in + * + * @param string $folder Folder name + * + * @return string One of 'personal', 'other' or 'shared' + */ + public function folder_namespace($folder) + { + if ($folder == 'INBOX') { + return 'personal'; + } + + foreach ($this->namespace as $type => $namespace) { + if (is_array($namespace)) { + foreach ($namespace as $ns) { + if ($len = strlen($ns[0])) { + if (($len > 1 && $folder == substr($ns[0], 0, -1)) + || strpos($folder, $ns[0]) === 0 + ) { + return $type; + } + } + } + } + } + + return 'personal'; + } + + + /** + * Modify folder name according to namespace. + * For output it removes prefix of the personal namespace if it's possible. + * For input it adds the prefix. Use it before creating a folder in root + * of the folders tree. + * + * @param string $folder Folder name + * @param string $mode Mode name (out/in) + * + * @return string Folder name + */ + public function mod_folder($folder, $mode = 'out') + { + if (!strlen($folder)) { + return $folder; + } + + $prefix = $this->namespace['prefix']; // see set_env() + $prefix_len = strlen($prefix); + + if (!$prefix_len) { + return $folder; + } + + // remove prefix for output + if ($mode == 'out') { + if (substr($folder, 0, $prefix_len) === $prefix) { + return substr($folder, $prefix_len); + } + } + // add prefix for input (e.g. folder creation) + else { + return $prefix . $folder; + } + + return $folder; + } + + + /** + * Gets folder attributes from LIST response, e.g. \Noselect, \Noinferiors + * + * @param string $folder Folder name + * @param bool $force Set to True if attributes should be refreshed + * + * @return array Options list + */ + public function folder_attributes($folder, $force=false) + { + // get attributes directly from LIST command + if (!empty($this->conn->data['LIST']) && is_array($this->conn->data['LIST'][$folder])) { + $opts = $this->conn->data['LIST'][$folder]; + } + // get cached folder attributes + else if (!$force) { + $opts = $this->get_cache('mailboxes.attributes'); + $opts = $opts[$folder]; + } + + if (!is_array($opts)) { + if (!$this->check_connection()) { + return array(); + } + + $this->conn->listMailboxes('', $folder); + $opts = $this->conn->data['LIST'][$folder]; + } + + return is_array($opts) ? $opts : array(); + } + + + /** + * Gets connection (and current folder) data: UIDVALIDITY, EXISTS, RECENT, + * PERMANENTFLAGS, UIDNEXT, UNSEEN + * + * @param string $folder Folder name + * + * @return array Data + */ + public function folder_data($folder) + { + if (!strlen($folder)) { + $folder = $this->folder !== null ? $this->folder : 'INBOX'; + } + + if ($this->conn->selected != $folder) { + if (!$this->check_connection()) { + return array(); + } + + if ($this->conn->select($folder)) { + $this->folder = $folder; + } + else { + return null; + } + } + + $data = $this->conn->data; + + // add (E)SEARCH result for ALL UNDELETED query + if (!empty($this->icache['undeleted_idx']) + && $this->icache['undeleted_idx']->get_parameters('MAILBOX') == $folder + ) { + $data['UNDELETED'] = $this->icache['undeleted_idx']; + } + + return $data; + } + + + /** + * Returns extended information about the folder + * + * @param string $folder Folder name + * + * @return array Data + */ + public function folder_info($folder) + { + if ($this->icache['options'] && $this->icache['options']['name'] == $folder) { + return $this->icache['options']; + } + + // get cached metadata + $cache_key = 'mailboxes.folder-info.' . $folder; + $cached = $this->get_cache($cache_key); + + if (is_array($cached)) { + return $cached; + } + + $acl = $this->get_capability('ACL'); + $namespace = $this->get_namespace(); + $options = array(); + + // check if the folder is a namespace prefix + if (!empty($namespace)) { + $mbox = $folder . $this->delimiter; + foreach ($namespace as $ns) { + if (!empty($ns)) { + foreach ($ns as $item) { + if ($item[0] === $mbox) { + $options['is_root'] = true; + break 2; + } + } + } + } + } + // check if the folder is other user virtual-root + if (!$options['is_root'] && !empty($namespace) && !empty($namespace['other'])) { + $parts = explode($this->delimiter, $folder); + if (count($parts) == 2) { + $mbox = $parts[0] . $this->delimiter; + foreach ($namespace['other'] as $item) { + if ($item[0] === $mbox) { + $options['is_root'] = true; + break; + } + } + } + } + + $options['name'] = $folder; + $options['attributes'] = $this->folder_attributes($folder, true); + $options['namespace'] = $this->folder_namespace($folder); + $options['special'] = in_array($folder, $this->default_folders); + + // Set 'noselect' flag + if (is_array($options['attributes'])) { + foreach ($options['attributes'] as $attrib) { + $attrib = strtolower($attrib); + if ($attrib == '\noselect' || $attrib == '\nonexistent') { + $options['noselect'] = true; + } + } + } + else { + $options['noselect'] = true; + } + + // Get folder rights (MYRIGHTS) + if ($acl && ($rights = $this->my_rights($folder))) { + $options['rights'] = $rights; + } + + // Set 'norename' flag + if (!empty($options['rights'])) { + $options['norename'] = !in_array('x', $options['rights']) && !in_array('d', $options['rights']); + + if (!$options['noselect']) { + $options['noselect'] = !in_array('r', $options['rights']); + } + } + else { + $options['norename'] = $options['is_root'] || $options['namespace'] != 'personal'; + } + + // update caches + $this->icache['options'] = $options; + $this->update_cache($cache_key, $options); + + return $options; + } + + + /** + * Synchronizes messages cache. + * + * @param string $folder Folder name + */ + public function folder_sync($folder) + { + if ($mcache = $this->get_mcache_engine()) { + $mcache->synchronize($folder); + } + } + + + /** + * Get message header names for rcube_imap_generic::fetchHeader(s) + * + * @return string Space-separated list of header names + */ + protected function get_fetch_headers() + { + if (!empty($this->options['fetch_headers'])) { + $headers = explode(' ', $this->options['fetch_headers']); + $headers = array_map('strtoupper', $headers); + } + else { + $headers = array(); + } + + if ($this->messages_caching || $this->options['all_headers']) { + $headers = array_merge($headers, $this->all_headers); + } + + return implode(' ', array_unique($headers)); + } + + + /* ----------------------------------------- + * ACL and METADATA/ANNOTATEMORE methods + * ----------------------------------------*/ + + /** + * Changes the ACL on the specified folder (SETACL) + * + * @param string $folder Folder name + * @param string $user User name + * @param string $acl ACL string + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + public function set_acl($folder, $user, $acl) + { + if (!$this->get_capability('ACL')) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + $this->clear_cache('mailboxes.folder-info.' . $folder); + + return $this->conn->setACL($folder, $user, $acl); + } + + + /** + * Removes any <identifier,rights> pair for the + * specified user from the ACL for the specified + * folder (DELETEACL) + * + * @param string $folder Folder name + * @param string $user User name + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + public function delete_acl($folder, $user) + { + if (!$this->get_capability('ACL')) { + return false; + } + + if (!$this->check_connection()) { + return false; + } + + return $this->conn->deleteACL($folder, $user); + } + + + /** + * Returns the access control list for folder (GETACL) + * + * @param string $folder Folder name + * + * @return array User-rights array on success, NULL on error + * @since 0.5-beta + */ + public function get_acl($folder) + { + if (!$this->get_capability('ACL')) { + return null; + } + + if (!$this->check_connection()) { + return null; + } + + return $this->conn->getACL($folder); + } + + + /** + * Returns information about what rights can be granted to the + * user (identifier) in the ACL for the folder (LISTRIGHTS) + * + * @param string $folder Folder name + * @param string $user User name + * + * @return array List of user rights + * @since 0.5-beta + */ + public function list_rights($folder, $user) + { + if (!$this->get_capability('ACL')) { + return null; + } + + if (!$this->check_connection()) { + return null; + } + + return $this->conn->listRights($folder, $user); + } + + + /** + * Returns the set of rights that the current user has to + * folder (MYRIGHTS) + * + * @param string $folder Folder name + * + * @return array MYRIGHTS response on success, NULL on error + * @since 0.5-beta + */ + public function my_rights($folder) + { + if (!$this->get_capability('ACL')) { + return null; + } + + if (!$this->check_connection()) { + return null; + } + + return $this->conn->myRights($folder); + } + + + /** + * Sets IMAP metadata/annotations (SETMETADATA/SETANNOTATION) + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entry-value array (use NULL value as NIL) + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + public function set_metadata($folder, $entries) + { + if (!$this->check_connection()) { + return false; + } + + $this->clear_cache('mailboxes.metadata.', true); + + if ($this->get_capability('METADATA') || + (!strlen($folder) && $this->get_capability('METADATA-SERVER')) + ) { + return $this->conn->setMetadata($folder, $entries); + } + else if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) { + foreach ((array)$entries as $entry => $value) { + list($ent, $attr) = $this->md2annotate($entry); + $entries[$entry] = array($ent, $attr, $value); + } + return $this->conn->setAnnotation($folder, $entries); + } + + return false; + } + + + /** + * Unsets IMAP metadata/annotations (SETMETADATA/SETANNOTATION) + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entry names array + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + public function delete_metadata($folder, $entries) + { + if (!$this->check_connection()) { + return false; + } + + $this->clear_cache('mailboxes.metadata.', true); + + if ($this->get_capability('METADATA') || + (!strlen($folder) && $this->get_capability('METADATA-SERVER')) + ) { + return $this->conn->deleteMetadata($folder, $entries); + } + else if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) { + foreach ((array)$entries as $idx => $entry) { + list($ent, $attr) = $this->md2annotate($entry); + $entries[$idx] = array($ent, $attr, NULL); + } + return $this->conn->setAnnotation($folder, $entries); + } + + return false; + } + + + /** + * Returns IMAP metadata/annotations (GETMETADATA/GETANNOTATION) + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entries + * @param array $options Command options (with MAXSIZE and DEPTH keys) + * + * @return array Metadata entry-value hash array on success, NULL on error + * @since 0.5-beta + */ + public function get_metadata($folder, $entries, $options=array()) + { + $entries = (array)$entries; + + // create cache key + // @TODO: this is the simplest solution, but we do the same with folders list + // maybe we should store data per-entry and merge on request + sort($options); + sort($entries); + $cache_key = 'mailboxes.metadata.' . $folder; + $cache_key .= '.' . md5(serialize($options).serialize($entries)); + + // get cached data + $cached_data = $this->get_cache($cache_key); + + if (is_array($cached_data)) { + return $cached_data; + } + + if (!$this->check_connection()) { + return null; + } + + if ($this->get_capability('METADATA') || + (!strlen($folder) && $this->get_capability('METADATA-SERVER')) + ) { + $res = $this->conn->getMetadata($folder, $entries, $options); + } + else if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) { + $queries = array(); + $res = array(); + + // Convert entry names + foreach ($entries as $entry) { + list($ent, $attr) = $this->md2annotate($entry); + $queries[$attr][] = $ent; + } + + // @TODO: Honor MAXSIZE and DEPTH options + foreach ($queries as $attrib => $entry) { + if ($result = $this->conn->getAnnotation($folder, $entry, $attrib)) { + $res = array_merge_recursive($res, $result); + } + } + } + + if (isset($res)) { + $this->update_cache($cache_key, $res); + return $res; + } + + return null; + } + + + /** + * Converts the METADATA extension entry name into the correct + * entry-attrib names for older ANNOTATEMORE version. + * + * @param string $entry Entry name + * + * @return array Entry-attribute list, NULL if not supported (?) + */ + protected function md2annotate($entry) + { + if (substr($entry, 0, 7) == '/shared') { + return array(substr($entry, 7), 'value.shared'); + } + else if (substr($entry, 0, 8) == '/private') { + return array(substr($entry, 8), 'value.priv'); + } + + // @TODO: log error + return null; + } + + + /* -------------------------------- + * internal caching methods + * --------------------------------*/ + + /** + * Enable or disable indexes caching + * + * @param string $type Cache type (@see rcube::get_cache) + */ + public function set_caching($type) + { + if ($type) { + $this->caching = $type; + } + else { + if ($this->cache) { + $this->cache->close(); + } + $this->cache = null; + $this->caching = false; + } + } + + /** + * Getter for IMAP cache object + */ + protected function get_cache_engine() + { + if ($this->caching && !$this->cache) { + $rcube = rcube::get_instance(); + $ttl = $rcube->config->get('message_cache_lifetime', '10d'); + $this->cache = $rcube->get_cache('IMAP', $this->caching, $ttl); + } + + return $this->cache; + } + + /** + * Returns cached value + * + * @param string $key Cache key + * + * @return mixed + */ + public function get_cache($key) + { + if ($cache = $this->get_cache_engine()) { + return $cache->get($key); + } + } + + /** + * Update cache + * + * @param string $key Cache key + * @param mixed $data Data + */ + public function update_cache($key, $data) + { + if ($cache = $this->get_cache_engine()) { + $cache->set($key, $data); + } + } + + /** + * Clears the cache. + * + * @param string $key Cache key name or pattern + * @param boolean $prefix_mode Enable it to clear all keys starting + * with prefix specified in $key + */ + public function clear_cache($key = null, $prefix_mode = false) + { + if ($cache = $this->get_cache_engine()) { + $cache->remove($key, $prefix_mode); + } + } + + /** + * Delete outdated cache entries + */ + public function expunge_cache() + { + if ($this->mcache) { + $ttl = rcube::get_instance()->config->get('message_cache_lifetime', '10d'); + $this->mcache->expunge($ttl); + } + + if ($this->cache) { + $this->cache->expunge(); + } + } + + + /* -------------------------------- + * message caching methods + * --------------------------------*/ + + /** + * Enable or disable messages caching + * + * @param boolean $set Flag + */ + public function set_messages_caching($set) + { + if ($set) { + $this->messages_caching = true; + } + else { + if ($this->mcache) { + $this->mcache->close(); + } + $this->mcache = null; + $this->messages_caching = false; + } + } + + + /** + * Getter for messages cache object + */ + protected function get_mcache_engine() + { + if ($this->messages_caching && !$this->mcache) { + $rcube = rcube::get_instance(); + if (($dbh = $rcube->get_dbh()) && ($userid = $rcube->get_user_id())) { + $this->mcache = new rcube_imap_cache( + $dbh, $this, $userid, $this->options['skip_deleted']); + } + } + + return $this->mcache; + } + + + /** + * Clears the messages cache. + * + * @param string $folder Folder name + * @param array $uids Optional message UIDs to remove from cache + */ + protected function clear_message_cache($folder = null, $uids = null) + { + if ($mcache = $this->get_mcache_engine()) { + $mcache->clear($folder, $uids); + } + } + + + /* -------------------------------- + * protected methods + * --------------------------------*/ + + /** + * Validate the given input and save to local properties + * + * @param string $sort_field Sort column + * @param string $sort_order Sort order + */ + protected function set_sort_order($sort_field, $sort_order) + { + if ($sort_field != null) { + $this->sort_field = asciiwords($sort_field); + } + if ($sort_order != null) { + $this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC'; + } + } + + + /** + * Sort folders first by default folders and then in alphabethical order + * + * @param array $a_folders Folders list + */ + protected function sort_folder_list($a_folders) + { + $a_out = $a_defaults = $folders = array(); + + $delimiter = $this->get_hierarchy_delimiter(); + + // find default folders and skip folders starting with '.' + foreach ($a_folders as $i => $folder) { + if ($folder[0] == '.') { + continue; + } + + if (($p = array_search($folder, $this->default_folders)) !== false && !$a_defaults[$p]) { + $a_defaults[$p] = $folder; + } + else { + $folders[$folder] = rcube_charset::convert($folder, 'UTF7-IMAP'); + } + } + + // sort folders and place defaults on the top + asort($folders, SORT_LOCALE_STRING); + ksort($a_defaults); + $folders = array_merge($a_defaults, array_keys($folders)); + + // finally we must rebuild the list to move + // subfolders of default folders to their place... + // ...also do this for the rest of folders because + // asort() is not properly sorting case sensitive names + while (list($key, $folder) = each($folders)) { + // set the type of folder name variable (#1485527) + $a_out[] = (string) $folder; + unset($folders[$key]); + $this->rsort($folder, $delimiter, $folders, $a_out); + } + + return $a_out; + } + + + /** + * Recursive method for sorting folders + */ + protected function rsort($folder, $delimiter, &$list, &$out) + { + while (list($key, $name) = each($list)) { + if (strpos($name, $folder.$delimiter) === 0) { + // set the type of folder name variable (#1485527) + $out[] = (string) $name; + unset($list[$key]); + $this->rsort($name, $delimiter, $list, $out); + } + } + reset($list); + } + + + /** + * Find UID of the specified message sequence ID + * + * @param int $id Message (sequence) ID + * @param string $folder Folder name + * + * @return int Message UID + */ + public function id2uid($id, $folder = null) + { + if (!strlen($folder)) { + $folder = $this->folder; + } + + if ($uid = array_search($id, (array)$this->uid_id_map[$folder])) { + return $uid; + } + + if (!$this->check_connection()) { + return null; + } + + $uid = $this->conn->ID2UID($folder, $id); + + $this->uid_id_map[$folder][$uid] = $id; + + return $uid; + } + + + /** + * Subscribe/unsubscribe a list of folders and update local cache + */ + protected function change_subscription($folders, $mode) + { + $updated = false; + + if (!empty($folders)) { + if (!$this->check_connection()) { + return false; + } + + foreach ((array)$folders as $i => $folder) { + $folders[$i] = $folder; + + if ($mode == 'subscribe') { + $updated = $this->conn->subscribe($folder); + } + else if ($mode == 'unsubscribe') { + $updated = $this->conn->unsubscribe($folder); + } + } + } + + // clear cached folders list(s) + if ($updated) { + $this->clear_cache('mailboxes', true); + } + + return $updated; + } + + + /** + * Increde/decrese messagecount for a specific folder + */ + protected function set_messagecount($folder, $mode, $increment) + { + if (!is_numeric($increment)) { + return false; + } + + $mode = strtoupper($mode); + $a_folder_cache = $this->get_cache('messagecount'); + + if (!is_array($a_folder_cache[$folder]) || !isset($a_folder_cache[$folder][$mode])) { + return false; + } + + // add incremental value to messagecount + $a_folder_cache[$folder][$mode] += $increment; + + // there's something wrong, delete from cache + if ($a_folder_cache[$folder][$mode] < 0) { + unset($a_folder_cache[$folder][$mode]); + } + + // write back to cache + $this->update_cache('messagecount', $a_folder_cache); + + return true; + } + + + /** + * Remove messagecount of a specific folder from cache + */ + protected function clear_messagecount($folder, $mode=null) + { + $a_folder_cache = $this->get_cache('messagecount'); + + if (is_array($a_folder_cache[$folder])) { + if ($mode) { + unset($a_folder_cache[$folder][$mode]); + } + else { + unset($a_folder_cache[$folder]); + } + $this->update_cache('messagecount', $a_folder_cache); + } + } + + + /** + * Converts date string/object into IMAP date/time format + */ + protected function date_format($date) + { + if (empty($date)) { + return null; + } + + if (!is_object($date) || !is_a($date, 'DateTime')) { + try { + $timestamp = rcube_utils::strtotime($date); + $date = new DateTime("@".$timestamp); + } + catch (Exception $e) { + return null; + } + } + + return $date->format('d-M-Y H:i:s O'); + } + + + /** + * This is our own debug handler for the IMAP connection + * @access public + */ + public function debug_handler(&$imap, $message) + { + rcube::write_log('imap', $message); + } + + + /** + * Deprecated methods (to be removed) + */ + + public function decode_address_list($input, $max = null, $decode = true, $fallback = null) + { + return rcube_mime::decode_address_list($input, $max, $decode, $fallback); + } + + public function decode_header($input, $fallback = null) + { + return rcube_mime::decode_mime_string((string)$input, $fallback); + } + + public static function decode_mime_string($input, $fallback = null) + { + return rcube_mime::decode_mime_string($input, $fallback); + } + + public function mime_decode($input, $encoding = '7bit') + { + return rcube_mime::decode($input, $encoding); + } + + public static function explode_header_string($separator, $str, $remove_comments = false) + { + return rcube_mime::explode_header_string($separator, $str, $remove_comments); + } + + public function select_mailbox($mailbox) + { + // do nothing + } + + public function set_mailbox($folder) + { + $this->set_folder($folder); + } + + public function get_mailbox_name() + { + return $this->get_folder(); + } + + public function list_headers($folder='', $page=NULL, $sort_field=NULL, $sort_order=NULL, $slice=0) + { + return $this->list_messages($folder, $page, $sort_field, $sort_order, $slice); + } + + public function get_headers($uid, $folder = null, $force = false) + { + return $this->get_message_headers($uid, $folder, $force); + } + + public function mailbox_status($folder = null) + { + return $this->folder_status($folder); + } + + public function message_index($folder = '', $sort_field = NULL, $sort_order = NULL) + { + return $this->index($folder, $sort_field, $sort_order); + } + + public function message_index_direct($folder, $sort_field = null, $sort_order = null, $skip_cache = true) + { + return $this->index_direct($folder, $sort_field, $sort_order, $skip_cache); + } + + public function list_mailboxes($root='', $name='*', $filter=null, $rights=null, $skip_sort=false) + { + return $this->list_folders_subscribed($root, $name, $filter, $rights, $skip_sort); + } + + public function list_unsubscribed($root='', $name='*', $filter=null, $rights=null, $skip_sort=false) + { + return $this->list_folders($root, $name, $filter, $rights, $skip_sort); + } + + public function get_mailbox_size($folder) + { + return $this->folder_size($folder); + } + + public function create_mailbox($folder, $subscribe=false) + { + return $this->create_folder($folder, $subscribe); + } + + public function rename_mailbox($folder, $new_name) + { + return $this->rename_folder($folder, $new_name); + } + + function delete_mailbox($folder) + { + return $this->delete_folder($folder); + } + + function clear_mailbox($folder = null) + { + return $this->clear_folder($folder); + } + + public function mailbox_exists($folder, $subscription=false) + { + return $this->folder_exists($folder, $subscription); + } + + public function mailbox_namespace($folder) + { + return $this->folder_namespace($folder); + } + + public function mod_mailbox($folder, $mode = 'out') + { + return $this->mod_folder($folder, $mode); + } + + public function mailbox_attributes($folder, $force=false) + { + return $this->folder_attributes($folder, $force); + } + + public function mailbox_data($folder) + { + return $this->folder_data($folder); + } + + public function mailbox_info($folder) + { + return $this->folder_info($folder); + } + + public function mailbox_sync($folder) + { + return $this->folder_sync($folder); + } + + public function expunge($folder='', $clear_cache=true) + { + return $this->expunge_folder($folder, $clear_cache); + } + +} diff --git a/program/lib/Roundcube/rcube_imap_cache.php b/program/lib/Roundcube/rcube_imap_cache.php new file mode 100644 index 000000000..31214cfbf --- /dev/null +++ b/program/lib/Roundcube/rcube_imap_cache.php @@ -0,0 +1,1160 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_imap_cache.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Caching of IMAP folder contents (messages and index) | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Interface class for accessing Roundcube messages cache + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_imap_cache +{ + /** + * Instance of rcube_imap + * + * @var rcube_imap + */ + private $imap; + + /** + * Instance of rcube_db + * + * @var rcube_db + */ + private $db; + + /** + * User ID + * + * @var int + */ + private $userid; + + /** + * Internal (in-memory) cache + * + * @var array + */ + private $icache = array(); + + private $skip_deleted = false; + + /** + * List of known flags. Thanks to this we can handle flag changes + * with good performance. Bad thing is we need to know used flags. + */ + public $flags = array( + 1 => 'SEEN', // RFC3501 + 2 => 'DELETED', // RFC3501 + 4 => 'ANSWERED', // RFC3501 + 8 => 'FLAGGED', // RFC3501 + 16 => 'DRAFT', // RFC3501 + 32 => 'MDNSENT', // RFC3503 + 64 => 'FORWARDED', // RFC5550 + 128 => 'SUBMITPENDING', // RFC5550 + 256 => 'SUBMITTED', // RFC5550 + 512 => 'JUNK', + 1024 => 'NONJUNK', + 2048 => 'LABEL1', + 4096 => 'LABEL2', + 8192 => 'LABEL3', + 16384 => 'LABEL4', + 32768 => 'LABEL5', + ); + + + /** + * Object constructor. + */ + function __construct($db, $imap, $userid, $skip_deleted) + { + $this->db = $db; + $this->imap = $imap; + $this->userid = $userid; + $this->skip_deleted = $skip_deleted; + } + + + /** + * Cleanup actions (on shutdown). + */ + public function close() + { + $this->save_icache(); + $this->icache = null; + } + + + /** + * Return (sorted) messages index (UIDs). + * If index doesn't exist or is invalid, will be updated. + * + * @param string $mailbox Folder name + * @param string $sort_field Sorting column + * @param string $sort_order Sorting order (ASC|DESC) + * @param bool $exiting Skip index initialization if it doesn't exist in DB + * + * @return array Messages index + */ + function get_index($mailbox, $sort_field = null, $sort_order = null, $existing = false) + { + if (empty($this->icache[$mailbox])) { + $this->icache[$mailbox] = array(); + } + + $sort_order = strtoupper($sort_order) == 'ASC' ? 'ASC' : 'DESC'; + + // Seek in internal cache + if (array_key_exists('index', $this->icache[$mailbox])) { + // The index was fetched from database already, but not validated yet + if (!array_key_exists('object', $this->icache[$mailbox]['index'])) { + $index = $this->icache[$mailbox]['index']; + } + // We've got a valid index + else if ($sort_field == 'ANY' || $this->icache[$mailbox]['index']['sort_field'] == $sort_field) { + $result = $this->icache[$mailbox]['index']['object']; + if ($result->get_parameters('ORDER') != $sort_order) { + $result->revert(); + } + return $result; + } + } + + // Get index from DB (if DB wasn't already queried) + if (empty($index) && empty($this->icache[$mailbox]['index_queried'])) { + $index = $this->get_index_row($mailbox); + + // set the flag that DB was already queried for index + // this way we'll be able to skip one SELECT, when + // get_index() is called more than once + $this->icache[$mailbox]['index_queried'] = true; + } + + $data = null; + + // @TODO: Think about skipping validation checks. + // If we could check only every 10 minutes, we would be able to skip + // expensive checks, mailbox selection or even IMAP connection, this would require + // additional logic to force cache invalidation in some cases + // and many rcube_imap changes to connect when needed + + // Entry exists, check cache status + if (!empty($index)) { + $exists = true; + + if ($sort_field == 'ANY') { + $sort_field = $index['sort_field']; + } + + if ($sort_field != $index['sort_field']) { + $is_valid = false; + } + else { + $is_valid = $this->validate($mailbox, $index, $exists); + } + + if ($is_valid) { + $data = $index['object']; + // revert the order if needed + if ($data->get_parameters('ORDER') != $sort_order) { + $data->revert(); + } + } + } + else { + if ($existing) { + return null; + } + else if ($sort_field == 'ANY') { + $sort_field = ''; + } + + // Got it in internal cache, so the row already exist + $exists = array_key_exists('index', $this->icache[$mailbox]); + } + + // Index not found, not valid or sort field changed, get index from IMAP server + if ($data === null) { + // Get mailbox data (UIDVALIDITY, counters, etc.) for status check + $mbox_data = $this->imap->folder_data($mailbox); + $data = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data); + + // insert/update + $this->add_index_row($mailbox, $sort_field, $data, $mbox_data, $exists, $index['modseq']); + } + + $this->icache[$mailbox]['index'] = array( + 'object' => $data, + 'sort_field' => $sort_field, + 'modseq' => !empty($index['modseq']) ? $index['modseq'] : $mbox_data['HIGHESTMODSEQ'] + ); + + return $data; + } + + + /** + * Return messages thread. + * If threaded index doesn't exist or is invalid, will be updated. + * + * @param string $mailbox Folder name + * @param string $sort_field Sorting column + * @param string $sort_order Sorting order (ASC|DESC) + * + * @return array Messages threaded index + */ + function get_thread($mailbox) + { + if (empty($this->icache[$mailbox])) { + $this->icache[$mailbox] = array(); + } + + // Seek in internal cache + if (array_key_exists('thread', $this->icache[$mailbox])) { + return $this->icache[$mailbox]['thread']['object']; + } + + // Get thread from DB (if DB wasn't already queried) + if (empty($this->icache[$mailbox]['thread_queried'])) { + $index = $this->get_thread_row($mailbox); + + // set the flag that DB was already queried for thread + // this way we'll be able to skip one SELECT, when + // get_thread() is called more than once or after clear() + $this->icache[$mailbox]['thread_queried'] = true; + } + + // Entry exist, check cache status + if (!empty($index)) { + $exists = true; + $is_valid = $this->validate($mailbox, $index, $exists); + + if (!$is_valid) { + $index = null; + } + } + + // Index not found or not valid, get index from IMAP server + if ($index === null) { + // Get mailbox data (UIDVALIDITY, counters, etc.) for status check + $mbox_data = $this->imap->folder_data($mailbox); + + if ($mbox_data['EXISTS']) { + // get all threads (default sort order) + $threads = $this->imap->fetch_threads($mailbox, true); + } + else { + $threads = new rcube_result_thread($mailbox, '* THREAD'); + } + + $index['object'] = $threads; + + // insert/update + $this->add_thread_row($mailbox, $threads, $mbox_data, $exists); + } + + $this->icache[$mailbox]['thread'] = $index; + + return $index['object']; + } + + + /** + * Returns list of messages (headers). See rcube_imap::fetch_headers(). + * + * @param string $mailbox Folder name + * @param array $msgs Message UIDs + * + * @return array The list of messages (rcube_message_header) indexed by UID + */ + function get_messages($mailbox, $msgs = array()) + { + if (empty($msgs)) { + return array(); + } + + // Fetch messages from cache + $sql_result = $this->db->query( + "SELECT uid, data, flags" + ." FROM ".$this->db->table_name('cache_messages') + ." WHERE user_id = ?" + ." AND mailbox = ?" + ." AND uid IN (".$this->db->array2list($msgs, 'integer').")", + $this->userid, $mailbox); + + $msgs = array_flip($msgs); + $result = array(); + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $uid = intval($sql_arr['uid']); + $result[$uid] = $this->build_message($sql_arr); + + if (!empty($result[$uid])) { + // save memory, we don't need message body here (?) + $result[$uid]->body = null; + + unset($msgs[$uid]); + } + } + + // Fetch not found messages from IMAP server + if (!empty($msgs)) { + $messages = $this->imap->fetch_headers($mailbox, array_keys($msgs), false, true); + + // Insert to DB and add to result list + if (!empty($messages)) { + foreach ($messages as $msg) { + $this->add_message($mailbox, $msg, !array_key_exists($msg->uid, $result)); + $result[$msg->uid] = $msg; + } + } + } + + return $result; + } + + + /** + * Returns message data. + * + * @param string $mailbox Folder name + * @param int $uid Message UID + * @param bool $update If message doesn't exists in cache it will be fetched + * from IMAP server + * @param bool $no_cache Enables internal cache usage + * + * @return rcube_message_header Message data + */ + function get_message($mailbox, $uid, $update = true, $cache = true) + { + // Check internal cache + if ($this->icache['__message'] + && $this->icache['__message']['mailbox'] == $mailbox + && $this->icache['__message']['object']->uid == $uid + ) { + return $this->icache['__message']['object']; + } + + $sql_result = $this->db->query( + "SELECT flags, data" + ." FROM ".$this->db->table_name('cache_messages') + ." WHERE user_id = ?" + ." AND mailbox = ?" + ." AND uid = ?", + $this->userid, $mailbox, (int)$uid); + + if ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $message = $this->build_message($sql_arr); + $found = true; + } + + // Get the message from IMAP server + if (empty($message) && $update) { + $message = $this->imap->get_message_headers($uid, $mailbox, true); + // cache will be updated in close(), see below + } + + // Save the message in internal cache, will be written to DB in close() + // Common scenario: user opens unseen message + // - get message (SELECT) + // - set message headers/structure (INSERT or UPDATE) + // - set \Seen flag (UPDATE) + // This way we can skip one UPDATE + if (!empty($message) && $cache) { + // Save current message from internal cache + $this->save_icache(); + + $this->icache['__message'] = array( + 'object' => $message, + 'mailbox' => $mailbox, + 'exists' => $found, + 'md5sum' => md5(serialize($message)), + ); + } + + return $message; + } + + + /** + * Saves the message in cache. + * + * @param string $mailbox Folder name + * @param rcube_message_header $message Message data + * @param bool $force Skips message in-cache existance check + */ + function add_message($mailbox, $message, $force = false) + { + if (!is_object($message) || empty($message->uid)) { + return; + } + + $msg = serialize($this->db->encode(clone $message)); + $flags = 0; + + if (!empty($message->flags)) { + foreach ($this->flags as $idx => $flag) { + if (!empty($message->flags[$flag])) { + $flags += $idx; + } + } + } + unset($msg->flags); + + // update cache record (even if it exists, the update + // here will work as select, assume row exist if affected_rows=0) + if (!$force) { + $res = $this->db->query( + "UPDATE ".$this->db->table_name('cache_messages') + ." SET flags = ?, data = ?, changed = ".$this->db->now() + ." WHERE user_id = ?" + ." AND mailbox = ?" + ." AND uid = ?", + $flags, $msg, $this->userid, $mailbox, (int) $message->uid); + + if ($this->db->affected_rows()) { + return; + } + } + + // insert new record + $this->db->query( + "INSERT INTO ".$this->db->table_name('cache_messages') + ." (user_id, mailbox, uid, flags, changed, data)" + ." VALUES (?, ?, ?, ?, ".$this->db->now().", ?)", + $this->userid, $mailbox, (int) $message->uid, $flags, $msg); + } + + + /** + * Sets the flag for specified message. + * + * @param string $mailbox Folder name + * @param array $uids Message UIDs or null to change flag + * of all messages in a folder + * @param string $flag The name of the flag + * @param bool $enabled Flag state + */ + function change_flag($mailbox, $uids, $flag, $enabled = false) + { + if (empty($uids)) { + return; + } + + $flag = strtoupper($flag); + $idx = (int) array_search($flag, $this->flags); + $uids = (array) $uids; + + if (!$idx) { + return; + } + + // Internal cache update + if (($message = $this->icache['__message']) + && $message['mailbox'] === $mailbox + && in_array($message['object']->uid, $uids) + ) { + $message['object']->flags[$flag] = $enabled; + + if (count($uids) == 1) { + return; + } + } + + $this->db->query( + "UPDATE ".$this->db->table_name('cache_messages') + ." SET changed = ".$this->db->now() + .", flags = flags ".($enabled ? "+ $idx" : "- $idx") + ." WHERE user_id = ?" + ." AND mailbox = ?" + .($uids !== null ? " AND uid IN (".$this->db->array2list($uids, 'integer').")" : "") + ." AND (flags & $idx) ".($enabled ? "= 0" : "= $idx"), + $this->userid, $mailbox); + } + + + /** + * Removes message(s) from cache. + * + * @param string $mailbox Folder name + * @param array $uids Message UIDs, NULL removes all messages + */ + function remove_message($mailbox = null, $uids = null) + { + if (!strlen($mailbox)) { + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache_messages') + ." WHERE user_id = ?", + $this->userid); + } + else { + // Remove the message from internal cache + if (!empty($uids) && ($message = $this->icache['__message']) + && $message['mailbox'] === $mailbox + && in_array($message['object']->uid, (array)$uids) + ) { + $this->icache['__message'] = null; + } + + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache_messages') + ." WHERE user_id = ?" + ." AND mailbox = ?" + .($uids !== null ? " AND uid IN (".$this->db->array2list((array)$uids, 'integer').")" : ""), + $this->userid, $mailbox); + } + } + + + /** + * Clears index cache. + * + * @param string $mailbox Folder name + * @param bool $remove Enable to remove the DB row + */ + function remove_index($mailbox = null, $remove = false) + { + // The index should be only removed from database when + // UIDVALIDITY was detected or the mailbox is empty + // otherwise use 'valid' flag to not loose HIGHESTMODSEQ value + if ($remove) { + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache_index') + ." WHERE user_id = ?" + .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : ""), + $this->userid + ); + } + else { + $this->db->query( + "UPDATE ".$this->db->table_name('cache_index') + ." SET valid = 0" + ." WHERE user_id = ?" + .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : ""), + $this->userid + ); + } + + if (strlen($mailbox)) { + unset($this->icache[$mailbox]['index']); + // Index removed, set flag to skip SELECT query in get_index() + $this->icache[$mailbox]['index_queried'] = true; + } + else { + $this->icache = array(); + } + } + + + /** + * Clears thread cache. + * + * @param string $mailbox Folder name + */ + function remove_thread($mailbox = null) + { + $this->db->query( + "DELETE FROM ".$this->db->table_name('cache_thread') + ." WHERE user_id = ?" + .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : ""), + $this->userid + ); + + if (strlen($mailbox)) { + unset($this->icache[$mailbox]['thread']); + // Thread data removed, set flag to skip SELECT query in get_thread() + $this->icache[$mailbox]['thread_queried'] = true; + } + else { + $this->icache = array(); + } + } + + + /** + * Clears the cache. + * + * @param string $mailbox Folder name + * @param array $uids Message UIDs, NULL removes all messages in a folder + */ + function clear($mailbox = null, $uids = null) + { + $this->remove_index($mailbox, true); + $this->remove_thread($mailbox); + $this->remove_message($mailbox, $uids); + } + + + /** + * Delete cache entries older than TTL + * + * @param string $ttl Lifetime of message cache entries + */ + function expunge($ttl) + { + // get expiration timestamp + $ts = get_offset_time($ttl, -1); + + $this->db->query("DELETE FROM ".$this->db->table_name('cache_messages') + ." WHERE changed < " . $this->db->fromunixtime($ts)); + + $this->db->query("DELETE FROM ".$this->db->table_name('cache_index') + ." WHERE changed < " . $this->db->fromunixtime($ts)); + + $this->db->query("DELETE FROM ".$this->db->table_name('cache_thread') + ." WHERE changed < " . $this->db->fromunixtime($ts)); + } + + + /** + * Fetches index data from database + */ + private function get_index_row($mailbox) + { + // Get index from DB + $sql_result = $this->db->query( + "SELECT data, valid" + ." FROM ".$this->db->table_name('cache_index') + ." WHERE user_id = ?" + ." AND mailbox = ?", + $this->userid, $mailbox); + + if ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $data = explode('@', $sql_arr['data']); + $index = @unserialize($data[0]); + unset($data[0]); + + if (empty($index)) { + $index = new rcube_result_index($mailbox); + } + + return array( + 'valid' => $sql_arr['valid'], + 'object' => $index, + 'sort_field' => $data[1], + 'deleted' => $data[2], + 'validity' => $data[3], + 'uidnext' => $data[4], + 'modseq' => $data[5], + ); + } + + return null; + } + + + /** + * Fetches thread data from database + */ + private function get_thread_row($mailbox) + { + // Get thread from DB + $sql_result = $this->db->query( + "SELECT data" + ." FROM ".$this->db->table_name('cache_thread') + ." WHERE user_id = ?" + ." AND mailbox = ?", + $this->userid, $mailbox); + + if ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $data = explode('@', $sql_arr['data']); + $thread = @unserialize($data[0]); + unset($data[0]); + + if (empty($thread)) { + $thread = new rcube_result_thread($mailbox); + } + + return array( + 'object' => $thread, + 'deleted' => $data[1], + 'validity' => $data[2], + 'uidnext' => $data[3], + ); + } + + return null; + } + + + /** + * Saves index data into database + */ + private function add_index_row($mailbox, $sort_field, + $data, $mbox_data = array(), $exists = false, $modseq = null) + { + $data = array( + serialize($data), + $sort_field, + (int) $this->skip_deleted, + (int) $mbox_data['UIDVALIDITY'], + (int) $mbox_data['UIDNEXT'], + $modseq ? $modseq : $mbox_data['HIGHESTMODSEQ'], + ); + $data = implode('@', $data); + + if ($exists) { + $sql_result = $this->db->query( + "UPDATE ".$this->db->table_name('cache_index') + ." SET data = ?, valid = 1, changed = ".$this->db->now() + ." WHERE user_id = ?" + ." AND mailbox = ?", + $data, $this->userid, $mailbox); + } + else { + $sql_result = $this->db->query( + "INSERT INTO ".$this->db->table_name('cache_index') + ." (user_id, mailbox, data, valid, changed)" + ." VALUES (?, ?, ?, 1, ".$this->db->now().")", + $this->userid, $mailbox, $data); + } + } + + + /** + * Saves thread data into database + */ + private function add_thread_row($mailbox, $data, $mbox_data = array(), $exists = false) + { + $data = array( + serialize($data), + (int) $this->skip_deleted, + (int) $mbox_data['UIDVALIDITY'], + (int) $mbox_data['UIDNEXT'], + ); + $data = implode('@', $data); + + if ($exists) { + $sql_result = $this->db->query( + "UPDATE ".$this->db->table_name('cache_thread') + ." SET data = ?, changed = ".$this->db->now() + ." WHERE user_id = ?" + ." AND mailbox = ?", + $data, $this->userid, $mailbox); + } + else { + $sql_result = $this->db->query( + "INSERT INTO ".$this->db->table_name('cache_thread') + ." (user_id, mailbox, data, changed)" + ." VALUES (?, ?, ?, ".$this->db->now().")", + $this->userid, $mailbox, $data); + } + } + + + /** + * Checks index/thread validity + */ + private function validate($mailbox, $index, &$exists = true) + { + $object = $index['object']; + $is_thread = is_a($object, 'rcube_result_thread'); + + // sanity check + if (empty($object)) { + return false; + } + + // Get mailbox data (UIDVALIDITY, counters, etc.) for status check + $mbox_data = $this->imap->folder_data($mailbox); + + // @TODO: Think about skipping validation checks. + // If we could check only every 10 minutes, we would be able to skip + // expensive checks, mailbox selection or even IMAP connection, this would require + // additional logic to force cache invalidation in some cases + // and many rcube_imap changes to connect when needed + + // Check UIDVALIDITY + if ($index['validity'] != $mbox_data['UIDVALIDITY']) { + $this->clear($mailbox); + $exists = false; + return false; + } + + // Folder is empty but cache isn't + if (empty($mbox_data['EXISTS'])) { + if (!$object->is_empty()) { + $this->clear($mailbox); + $exists = false; + return false; + } + } + // Folder is not empty but cache is + else if ($object->is_empty()) { + unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']); + return false; + } + + // Validation flag + if (!$is_thread && empty($index['valid'])) { + unset($this->icache[$mailbox]['index']); + return false; + } + + // Index was created with different skip_deleted setting + if ($this->skip_deleted != $index['deleted']) { + return false; + } + + // Check HIGHESTMODSEQ + if (!empty($index['modseq']) && !empty($mbox_data['HIGHESTMODSEQ']) + && $index['modseq'] == $mbox_data['HIGHESTMODSEQ'] + ) { + return true; + } + + // Check UIDNEXT + if ($index['uidnext'] != $mbox_data['UIDNEXT']) { + unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']); + return false; + } + + // @TODO: find better validity check for threaded index + if ($is_thread) { + // check messages number... + if (!$this->skip_deleted && $mbox_data['EXISTS'] != $object->count_messages()) { + return false; + } + return true; + } + + // The rest of checks, more expensive + if (!empty($this->skip_deleted)) { + // compare counts if available + if (!empty($mbox_data['UNDELETED']) + && $mbox_data['UNDELETED']->count() != $object->count() + ) { + return false; + } + // compare UID sets + if (!empty($mbox_data['UNDELETED'])) { + $uids_new = $mbox_data['UNDELETED']->get(); + $uids_old = $object->get(); + + if (count($uids_new) != count($uids_old)) { + return false; + } + + sort($uids_new, SORT_NUMERIC); + sort($uids_old, SORT_NUMERIC); + + if ($uids_old != $uids_new) + return false; + } + else { + // get all undeleted messages excluding cached UIDs + $ids = $this->imap->search_once($mailbox, 'ALL UNDELETED NOT UID '. + rcube_imap_generic::compressMessageSet($object->get())); + + if (!$ids->is_empty()) { + return false; + } + } + } + else { + // check messages number... + if ($mbox_data['EXISTS'] != $object->count()) { + return false; + } + // ... and max UID + if ($object->max() != $this->imap->id2uid($mbox_data['EXISTS'], $mailbox, true)) { + return false; + } + } + + return true; + } + + + /** + * Synchronizes the mailbox. + * + * @param string $mailbox Folder name + */ + function synchronize($mailbox) + { + // RFC4549: Synchronization Operations for Disconnected IMAP4 Clients + // RFC4551: IMAP Extension for Conditional STORE Operation + // or Quick Flag Changes Resynchronization + // RFC5162: IMAP Extensions for Quick Mailbox Resynchronization + + // @TODO: synchronize with other methods? + $qresync = $this->imap->get_capability('QRESYNC'); + $condstore = $qresync ? true : $this->imap->get_capability('CONDSTORE'); + + if (!$qresync && !$condstore) { + return; + } + + // Get stored index + $index = $this->get_index_row($mailbox); + + // database is empty + if (empty($index)) { + // set the flag that DB was already queried for index + // this way we'll be able to skip one SELECT in get_index() + $this->icache[$mailbox]['index_queried'] = true; + return; + } + + $this->icache[$mailbox]['index'] = $index; + + // no last HIGHESTMODSEQ value + if (empty($index['modseq'])) { + return; + } + + if (!$this->imap->check_connection()) { + return; + } + + // Enable QRESYNC + $res = $this->imap->conn->enable($qresync ? 'QRESYNC' : 'CONDSTORE'); + if ($res === false) { + return; + } + + // Close mailbox if already selected to get most recent data + if ($this->imap->conn->selected == $mailbox) { + $this->imap->conn->close(); + } + + // Get mailbox data (UIDVALIDITY, HIGHESTMODSEQ, counters, etc.) + $mbox_data = $this->imap->folder_data($mailbox); + + if (empty($mbox_data)) { + return; + } + + // Check UIDVALIDITY + if ($index['validity'] != $mbox_data['UIDVALIDITY']) { + $this->clear($mailbox); + return; + } + + // QRESYNC not supported on specified mailbox + if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) { + return; + } + + // Nothing new + if ($mbox_data['HIGHESTMODSEQ'] == $index['modseq']) { + return; + } + + $uids = array(); + $removed = array(); + + // Get known UIDs + $sql_result = $this->db->query( + "SELECT uid" + ." FROM ".$this->db->table_name('cache_messages') + ." WHERE user_id = ?" + ." AND mailbox = ?", + $this->userid, $mailbox); + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $uids[] = $sql_arr['uid']; + } + + // Synchronize messages data + if (!empty($uids)) { + // Get modified flags and vanished messages + // UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED) + $result = $this->imap->conn->fetch($mailbox, + $uids, true, array('FLAGS'), $index['modseq'], $qresync); + + if (!empty($result)) { + foreach ($result as $id => $msg) { + $uid = $msg->uid; + // Remove deleted message + if ($this->skip_deleted && !empty($msg->flags['DELETED'])) { + $removed[] = $uid; + // Invalidate index + $index['valid'] = false; + continue; + } + + $flags = 0; + if (!empty($msg->flags)) { + foreach ($this->flags as $idx => $flag) { + if (!empty($msg->flags[$flag])) { + $flags += $idx; + } + } + } + + $this->db->query( + "UPDATE ".$this->db->table_name('cache_messages') + ." SET flags = ?, changed = ".$this->db->now() + ." WHERE user_id = ?" + ." AND mailbox = ?" + ." AND uid = ?" + ." AND flags <> ?", + $flags, $this->userid, $mailbox, $uid, $flags); + } + } + + // VANISHED found? + if ($qresync) { + $mbox_data = $this->imap->folder_data($mailbox); + + // Removed messages found + $uids = rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']); + if (!empty($uids)) { + $removed = array_merge($removed, $uids); + // Invalidate index + $index['valid'] = false; + } + } + + // remove messages from database + if (!empty($removed)) { + $this->remove_message($mailbox, $removed); + } + } + + // Invalidate thread index (?) + if (!$index['valid']) { + $this->remove_thread($mailbox); + } + + $sort_field = $index['sort_field']; + $sort_order = $index['object']->get_parameters('ORDER'); + $exists = true; + + // Validate index + if (!$this->validate($mailbox, $index, $exists)) { + // Update index + $data = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data); + } + else { + $data = $index['object']; + } + + // update index and/or HIGHESTMODSEQ value + $this->add_index_row($mailbox, $sort_field, $data, $mbox_data, $exists); + + // update internal cache for get_index() + $this->icache[$mailbox]['index']['object'] = $data; + } + + + /** + * Converts cache row into message object. + * + * @param array $sql_arr Message row data + * + * @return rcube_message_header Message object + */ + private function build_message($sql_arr) + { + $message = $this->db->decode(unserialize($sql_arr['data'])); + + if ($message) { + $message->flags = array(); + foreach ($this->flags as $idx => $flag) { + if (($sql_arr['flags'] & $idx) == $idx) { + $message->flags[$flag] = true; + } + } + } + + return $message; + } + + + /** + * Saves message stored in internal cache + */ + private function save_icache() + { + // Save current message from internal cache + if ($message = $this->icache['__message']) { + // clean up some object's data + $object = $this->message_object_prepare($message['object']); + + // calculate current md5 sum + $md5sum = md5(serialize($object)); + + if ($message['md5sum'] != $md5sum) { + $this->add_message($message['mailbox'], $object, !$message['exists']); + } + + $this->icache['__message']['md5sum'] = $md5sum; + } + } + + + /** + * Prepares message object to be stored in database. + */ + private function message_object_prepare($msg) + { + // Remove body too big (>25kB) + if ($msg->body && strlen($msg->body) > 25 * 1024) { + unset($msg->body); + } + + // Fix mimetype which might be broken by some code when message is displayed + // Another solution would be to use object's copy in rcube_message class + // to prevent related issues, however I'm not sure which is better + if ($msg->mimetype) { + list($msg->ctype_primary, $msg->ctype_secondary) = explode('/', $msg->mimetype); + } + + if (is_array($msg->structure->parts)) { + foreach ($msg->structure->parts as $idx => $part) { + $msg->structure->parts[$idx] = $this->message_object_prepare($part); + } + } + + return $msg; + } + + + /** + * Fetches index data from IMAP server + */ + private function get_index_data($mailbox, $sort_field, $sort_order, $mbox_data = array()) + { + if (empty($mbox_data)) { + $mbox_data = $this->imap->folder_data($mailbox); + } + + if ($mbox_data['EXISTS']) { + // fetch sorted sequence numbers + $index = $this->imap->index_direct($mailbox, $sort_field, $sort_order); + } + else { + $index = new rcube_result_index($mailbox, '* SORT'); + } + + return $index; + } +} + +// for backward compat. +class rcube_mail_header extends rcube_message_header { } diff --git a/program/lib/Roundcube/rcube_imap_generic.php b/program/lib/Roundcube/rcube_imap_generic.php new file mode 100644 index 000000000..ae0bfdd6c --- /dev/null +++ b/program/lib/Roundcube/rcube_imap_generic.php @@ -0,0 +1,3699 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_imap_generic.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Provide alternative IMAP library that doesn't rely on the standard | + | C-Client based version. This allows to function regardless | + | of whether or not the PHP build it's running on has IMAP | + | functionality built-in. | + | | + | Based on Iloha IMAP Library. See http://ilohamail.org/ for details | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + | Author: Ryo Chijiiwa <Ryo@IlohaMail.org> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * PHP based wrapper class to connect to an IMAP server + * + * @package Framework + * @subpackage Storage + */ +class rcube_imap_generic +{ + public $error; + public $errornum; + public $result; + public $resultcode; + public $selected; + public $data = array(); + public $flags = array( + 'SEEN' => '\\Seen', + 'DELETED' => '\\Deleted', + 'ANSWERED' => '\\Answered', + 'DRAFT' => '\\Draft', + 'FLAGGED' => '\\Flagged', + 'FORWARDED' => '$Forwarded', + 'MDNSENT' => '$MDNSent', + '*' => '\\*', + ); + + private $fp; + private $host; + private $logged = false; + private $capability = array(); + private $capability_readed = false; + private $prefs; + private $cmd_tag; + private $cmd_num = 0; + private $resourceid; + private $_debug = false; + private $_debug_handler = false; + + const ERROR_OK = 0; + const ERROR_NO = -1; + const ERROR_BAD = -2; + const ERROR_BYE = -3; + const ERROR_UNKNOWN = -4; + const ERROR_COMMAND = -5; + const ERROR_READONLY = -6; + + const COMMAND_NORESPONSE = 1; + const COMMAND_CAPABILITY = 2; + const COMMAND_LASTLINE = 4; + + /** + * Object constructor + */ + function __construct() + { + } + + /** + * Send simple (one line) command to the connection stream + * + * @param string $string Command string + * @param bool $endln True if CRLF need to be added at the end of command + * + * @param int Number of bytes sent, False on error + */ + function putLine($string, $endln=true) + { + if (!$this->fp) + return false; + + if ($this->_debug) { + $this->debug('C: '. rtrim($string)); + } + + $res = fwrite($this->fp, $string . ($endln ? "\r\n" : '')); + + if ($res === false) { + @fclose($this->fp); + $this->fp = null; + } + + return $res; + } + + /** + * Send command to the connection stream with Command Continuation + * Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support + * + * @param string $string Command string + * @param bool $endln True if CRLF need to be added at the end of command + * + * @return int|bool Number of bytes sent, False on error + */ + function putLineC($string, $endln=true) + { + if (!$this->fp) { + return false; + } + + if ($endln) { + $string .= "\r\n"; + } + + $res = 0; + if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) { + for ($i=0, $cnt=count($parts); $i<$cnt; $i++) { + if (preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) { + // LITERAL+ support + if ($this->prefs['literal+']) { + $parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]); + } + + $bytes = $this->putLine($parts[$i].$parts[$i+1], false); + if ($bytes === false) + return false; + $res += $bytes; + + // don't wait if server supports LITERAL+ capability + if (!$this->prefs['literal+']) { + $line = $this->readLine(1000); + // handle error in command + if ($line[0] != '+') + return false; + } + $i++; + } + else { + $bytes = $this->putLine($parts[$i], false); + if ($bytes === false) + return false; + $res += $bytes; + } + } + } + return $res; + } + + /** + * Reads line from the connection stream + * + * @param int $size Buffer size + * + * @return string Line of text response + */ + function readLine($size=1024) + { + $line = ''; + + if (!$size) { + $size = 1024; + } + + do { + if ($this->eof()) { + return $line ? $line : NULL; + } + + $buffer = fgets($this->fp, $size); + + if ($buffer === false) { + $this->closeSocket(); + break; + } + if ($this->_debug) { + $this->debug('S: '. rtrim($buffer)); + } + $line .= $buffer; + } while (substr($buffer, -1) != "\n"); + + return $line; + } + + /** + * Reads more data from the connection stream when provided + * data contain string literal + * + * @param string $line Response text + * @param bool $escape Enables escaping + * + * @return string Line of text response + */ + function multLine($line, $escape = false) + { + $line = rtrim($line); + if (preg_match('/\{([0-9]+)\}$/', $line, $m)) { + $out = ''; + $str = substr($line, 0, -strlen($m[0])); + $bytes = $m[1]; + + while (strlen($out) < $bytes) { + $line = $this->readBytes($bytes); + if ($line === NULL) + break; + $out .= $line; + } + + $line = $str . ($escape ? $this->escape($out) : $out); + } + + return $line; + } + + /** + * Reads specified number of bytes from the connection stream + * + * @param int $bytes Number of bytes to get + * + * @return string Response text + */ + function readBytes($bytes) + { + $data = ''; + $len = 0; + while ($len < $bytes && !$this->eof()) + { + $d = fread($this->fp, $bytes-$len); + if ($this->_debug) { + $this->debug('S: '. $d); + } + $data .= $d; + $data_len = strlen($data); + if ($len == $data_len) { + break; // nothing was read -> exit to avoid apache lockups + } + $len = $data_len; + } + + return $data; + } + + /** + * Reads complete response to the IMAP command + * + * @param array $untagged Will be filled with untagged response lines + * + * @return string Response text + */ + function readReply(&$untagged=null) + { + do { + $line = trim($this->readLine(1024)); + // store untagged response lines + if ($line[0] == '*') + $untagged[] = $line; + } while ($line[0] == '*'); + + if ($untagged) + $untagged = join("\n", $untagged); + + return $line; + } + + /** + * Response parser. + * + * @param string $string Response text + * @param string $err_prefix Error message prefix + * + * @return int Response status + */ + function parseResult($string, $err_prefix='') + { + if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) { + $res = strtoupper($matches[1]); + $str = trim($matches[2]); + + if ($res == 'OK') { + $this->errornum = self::ERROR_OK; + } else if ($res == 'NO') { + $this->errornum = self::ERROR_NO; + } else if ($res == 'BAD') { + $this->errornum = self::ERROR_BAD; + } else if ($res == 'BYE') { + $this->closeSocket(); + $this->errornum = self::ERROR_BYE; + } + + if ($str) { + $str = trim($str); + // get response string and code (RFC5530) + if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) { + $this->resultcode = strtoupper($m[1]); + $str = trim(substr($str, strlen($m[1]) + 2)); + } + else { + $this->resultcode = null; + // parse response for [APPENDUID 1204196876 3456] + if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) { + $this->data['APPENDUID'] = $m[1]; + } + // parse response for [COPYUID 1204196876 3456:3457 123:124] + else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) { + $this->data['COPYUID'] = array($m[1], $m[2]); + } + } + $this->result = $str; + + if ($this->errornum != self::ERROR_OK) { + $this->error = $err_prefix ? $err_prefix.$str : $str; + } + } + + return $this->errornum; + } + return self::ERROR_UNKNOWN; + } + + /** + * Checks connection stream state. + * + * @return bool True if connection is closed + */ + private function eof() + { + if (!is_resource($this->fp)) { + return true; + } + + // If a connection opened by fsockopen() wasn't closed + // by the server, feof() will hang. + $start = microtime(true); + + if (feof($this->fp) || + ($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout'])) + ) { + $this->closeSocket(); + return true; + } + + return false; + } + + /** + * Closes connection stream. + */ + private function closeSocket() + { + @fclose($this->fp); + $this->fp = null; + } + + /** + * Error code/message setter. + */ + function setError($code, $msg='') + { + $this->errornum = $code; + $this->error = $msg; + } + + /** + * Checks response status. + * Checks if command response line starts with specified prefix (or * BYE/BAD) + * + * @param string $string Response text + * @param string $match Prefix to match with (case-sensitive) + * @param bool $error Enables BYE/BAD checking + * @param bool $nonempty Enables empty response checking + * + * @return bool True any check is true or connection is closed. + */ + function startsWith($string, $match, $error=false, $nonempty=false) + { + if (!$this->fp) { + return true; + } + if (strncmp($string, $match, strlen($match)) == 0) { + return true; + } + if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) { + if (strtoupper($m[1]) == 'BYE') { + $this->closeSocket(); + } + return true; + } + if ($nonempty && !strlen($string)) { + return true; + } + return false; + } + + private function hasCapability($name) + { + if (empty($this->capability) || $name == '') { + return false; + } + + if (in_array($name, $this->capability)) { + return true; + } + else if (strpos($name, '=')) { + return false; + } + + $result = array(); + foreach ($this->capability as $cap) { + $entry = explode('=', $cap); + if ($entry[0] == $name) { + $result[] = $entry[1]; + } + } + + return !empty($result) ? $result : false; + } + + /** + * Capabilities checker + * + * @param string $name Capability name + * + * @return mixed Capability values array for key=value pairs, true/false for others + */ + function getCapability($name) + { + $result = $this->hasCapability($name); + + if (!empty($result)) { + return $result; + } + else if ($this->capability_readed) { + return false; + } + + // get capabilities (only once) because initial + // optional CAPABILITY response may differ + $result = $this->execute('CAPABILITY'); + + if ($result[0] == self::ERROR_OK) { + $this->parseCapability($result[1]); + } + + $this->capability_readed = true; + + return $this->hasCapability($name); + } + + function clearCapability() + { + $this->capability = array(); + $this->capability_readed = false; + } + + /** + * DIGEST-MD5/CRAM-MD5/PLAIN Authentication + * + * @param string $user + * @param string $pass + * @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5) + * + * @return resource Connection resourse on success, error code on error + */ + function authenticate($user, $pass, $type='PLAIN') + { + if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') { + if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) { + $this->setError(self::ERROR_BYE, + "The Auth_SASL package is required for DIGEST-MD5 authentication"); + return self::ERROR_BAD; + } + + $this->putLine($this->nextTag() . " AUTHENTICATE $type"); + $line = trim($this->readReply()); + + if ($line[0] == '+') { + $challenge = substr($line, 2); + } + else { + return $this->parseResult($line); + } + + if ($type == 'CRAM-MD5') { + // RFC2195: CRAM-MD5 + $ipad = ''; + $opad = ''; + + // initialize ipad, opad + for ($i=0; $i<64; $i++) { + $ipad .= chr(0x36); + $opad .= chr(0x5C); + } + + // pad $pass so it's 64 bytes + $padLen = 64 - strlen($pass); + for ($i=0; $i<$padLen; $i++) { + $pass .= chr(0); + } + + // generate hash + $hash = md5($this->_xor($pass, $opad) . pack("H*", + md5($this->_xor($pass, $ipad) . base64_decode($challenge)))); + $reply = base64_encode($user . ' ' . $hash); + + // send result + $this->putLine($reply); + } + else { + // RFC2831: DIGEST-MD5 + // proxy authorization + if (!empty($this->prefs['auth_cid'])) { + $authc = $this->prefs['auth_cid']; + $pass = $this->prefs['auth_pw']; + } + else { + $authc = $user; + $user = ''; + } + $auth_sasl = Auth_SASL::factory('digestmd5'); + $reply = base64_encode($auth_sasl->getResponse($authc, $pass, + base64_decode($challenge), $this->host, 'imap', $user)); + + // send result + $this->putLine($reply); + $line = trim($this->readReply()); + + if ($line[0] == '+') { + $challenge = substr($line, 2); + } + else { + return $this->parseResult($line); + } + + // check response + $challenge = base64_decode($challenge); + if (strpos($challenge, 'rspauth=') === false) { + $this->setError(self::ERROR_BAD, + "Unexpected response from server to DIGEST-MD5 response"); + return self::ERROR_BAD; + } + + $this->putLine(''); + } + + $line = $this->readReply(); + $result = $this->parseResult($line); + } + else { // PLAIN + // proxy authorization + if (!empty($this->prefs['auth_cid'])) { + $authc = $this->prefs['auth_cid']; + $pass = $this->prefs['auth_pw']; + } + else { + $authc = $user; + $user = ''; + } + + $reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass); + + // RFC 4959 (SASL-IR): save one round trip + if ($this->getCapability('SASL-IR')) { + list($result, $line) = $this->execute("AUTHENTICATE PLAIN", array($reply), + self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY); + } + else { + $this->putLine($this->nextTag() . " AUTHENTICATE PLAIN"); + $line = trim($this->readReply()); + + if ($line[0] != '+') { + return $this->parseResult($line); + } + + // send result, get reply and process it + $this->putLine($reply); + $line = $this->readReply(); + $result = $this->parseResult($line); + } + } + + if ($result == self::ERROR_OK) { + // optional CAPABILITY response + if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { + $this->parseCapability($matches[1], true); + } + return $this->fp; + } + else { + $this->setError($result, "AUTHENTICATE $type: $line"); + } + + return $result; + } + + /** + * LOGIN Authentication + * + * @param string $user + * @param string $pass + * + * @return resource Connection resourse on success, error code on error + */ + function login($user, $password) + { + list($code, $response) = $this->execute('LOGIN', array( + $this->escape($user), $this->escape($password)), self::COMMAND_CAPABILITY); + + // re-set capabilities list if untagged CAPABILITY response provided + if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) { + $this->parseCapability($matches[1], true); + } + + if ($code == self::ERROR_OK) { + return $this->fp; + } + + return $code; + } + + /** + * Detects hierarchy delimiter + * + * @return string The delimiter + */ + function getHierarchyDelimiter() + { + if ($this->prefs['delimiter']) { + return $this->prefs['delimiter']; + } + + // try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8) + list($code, $response) = $this->execute('LIST', + array($this->escape(''), $this->escape(''))); + + if ($code == self::ERROR_OK) { + $args = $this->tokenizeResponse($response, 4); + $delimiter = $args[3]; + + if (strlen($delimiter) > 0) { + return ($this->prefs['delimiter'] = $delimiter); + } + } + + return NULL; + } + + /** + * NAMESPACE handler (RFC 2342) + * + * @return array Namespace data hash (personal, other, shared) + */ + function getNamespace() + { + if (array_key_exists('namespace', $this->prefs)) { + return $this->prefs['namespace']; + } + + if (!$this->getCapability('NAMESPACE')) { + return self::ERROR_BAD; + } + + list($code, $response) = $this->execute('NAMESPACE'); + + if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) { + $data = $this->tokenizeResponse(substr($response, 11)); + } + + if (!is_array($data)) { + return $code; + } + + $this->prefs['namespace'] = array( + 'personal' => $data[0], + 'other' => $data[1], + 'shared' => $data[2], + ); + + return $this->prefs['namespace']; + } + + /** + * Connects to IMAP server and authenticates. + * + * @param string $host Server hostname or IP + * @param string $user User name + * @param string $password Password + * @param array $options Connection and class options + * + * @return bool True on success, False on failure + */ + function connect($host, $user, $password, $options=null) + { + // set options + if (is_array($options)) { + $this->prefs = $options; + } + // set auth method + if (!empty($this->prefs['auth_type'])) { + $auth_method = strtoupper($this->prefs['auth_type']); + } else { + $auth_method = 'CHECK'; + } + + $result = false; + + // initialize connection + $this->error = ''; + $this->errornum = self::ERROR_OK; + $this->selected = null; + $this->user = $user; + $this->host = $host; + $this->logged = false; + + // check input + if (empty($host)) { + $this->setError(self::ERROR_BAD, "Empty host"); + return false; + } + if (empty($user)) { + $this->setError(self::ERROR_NO, "Empty user"); + return false; + } + if (empty($password)) { + $this->setError(self::ERROR_NO, "Empty password"); + return false; + } + + if (!$this->prefs['port']) { + $this->prefs['port'] = 143; + } + // check for SSL + if ($this->prefs['ssl_mode'] && $this->prefs['ssl_mode'] != 'tls') { + $host = $this->prefs['ssl_mode'] . '://' . $host; + } + + if ($this->prefs['timeout'] <= 0) { + $this->prefs['timeout'] = ini_get('default_socket_timeout'); + } + + // Connect + $this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']); + + if (!$this->fp) { + $this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s", $host, $this->prefs['port'], $errstr)); + return false; + } + + if ($this->prefs['timeout'] > 0) + stream_set_timeout($this->fp, $this->prefs['timeout']); + + $line = trim(fgets($this->fp, 8192)); + + if ($this->_debug) { + // set connection identifier for debug output + preg_match('/#([0-9]+)/', (string)$this->fp, $m); + $this->resourceid = strtoupper(substr(md5($m[1].$this->user.microtime()), 0, 4)); + + if ($line) + $this->debug('S: '. $line); + } + + // Connected to wrong port or connection error? + if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) { + if ($line) + $error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line); + else + $error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']); + + $this->setError(self::ERROR_BAD, $error); + $this->closeConnection(); + return false; + } + + // RFC3501 [7.1] optional CAPABILITY response + if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { + $this->parseCapability($matches[1], true); + } + + // TLS connection + if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) { + if (version_compare(PHP_VERSION, '5.1.0', '>=')) { + $res = $this->execute('STARTTLS'); + + if ($res[0] != self::ERROR_OK) { + $this->closeConnection(); + return false; + } + + if (!stream_socket_enable_crypto($this->fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $this->setError(self::ERROR_BAD, "Unable to negotiate TLS"); + $this->closeConnection(); + return false; + } + + // Now we're secure, capabilities need to be reread + $this->clearCapability(); + } + } + + // Send ID info + if (!empty($this->prefs['ident']) && $this->getCapability('ID')) { + $this->id($this->prefs['ident']); + } + + $auth_methods = array(); + $result = null; + + // check for supported auth methods + if ($auth_method == 'CHECK') { + if ($auth_caps = $this->getCapability('AUTH')) { + $auth_methods = $auth_caps; + } + // RFC 2595 (LOGINDISABLED) LOGIN disabled when connection is not secure + $login_disabled = $this->getCapability('LOGINDISABLED'); + if (($key = array_search('LOGIN', $auth_methods)) !== false) { + if ($login_disabled) { + unset($auth_methods[$key]); + } + } + else if (!$login_disabled) { + $auth_methods[] = 'LOGIN'; + } + + // Use best (for security) supported authentication method + foreach (array('DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN') as $auth_method) { + if (in_array($auth_method, $auth_methods)) { + break; + } + } + } + else { + // Prevent from sending credentials in plain text when connection is not secure + if ($auth_method == 'LOGIN' && $this->getCapability('LOGINDISABLED')) { + $this->setError(self::ERROR_BAD, "Login disabled by IMAP server"); + $this->closeConnection(); + return false; + } + // replace AUTH with CRAM-MD5 for backward compat. + if ($auth_method == 'AUTH') { + $auth_method = 'CRAM-MD5'; + } + } + + // pre-login capabilities can be not complete + $this->capability_readed = false; + + // Authenticate + switch ($auth_method) { + case 'CRAM_MD5': + $auth_method = 'CRAM-MD5'; + case 'CRAM-MD5': + case 'DIGEST-MD5': + case 'PLAIN': + $result = $this->authenticate($user, $password, $auth_method); + break; + case 'LOGIN': + $result = $this->login($user, $password); + break; + default: + $this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method"); + } + + // Connected and authenticated + if (is_resource($result)) { + if ($this->prefs['force_caps']) { + $this->clearCapability(); + } + $this->logged = true; + + return true; + } + + $this->closeConnection(); + + return false; + } + + /** + * Checks connection status + * + * @return bool True if connection is active and user is logged in, False otherwise. + */ + function connected() + { + return ($this->fp && $this->logged) ? true : false; + } + + /** + * Closes connection with logout. + */ + function closeConnection() + { + if ($this->putLine($this->nextTag() . ' LOGOUT')) { + $this->readReply(); + } + + $this->closeSocket(); + } + + /** + * Executes SELECT command (if mailbox is already not in selected state) + * + * @param string $mailbox Mailbox name + * @param array $qresync_data QRESYNC data (RFC5162) + * + * @return boolean True on success, false on error + */ + function select($mailbox, $qresync_data = null) + { + if (!strlen($mailbox)) { + return false; + } + + if ($this->selected === $mailbox) { + return true; + } +/* + Temporary commented out because Courier returns \Noselect for INBOX + Requires more investigation + + if (is_array($this->data['LIST']) && is_array($opts = $this->data['LIST'][$mailbox])) { + if (in_array('\\Noselect', $opts)) { + return false; + } + } +*/ + $params = array($this->escape($mailbox)); + + // QRESYNC data items + // 0. the last known UIDVALIDITY, + // 1. the last known modification sequence, + // 2. the optional set of known UIDs, and + // 3. an optional parenthesized list of known sequence ranges and their + // corresponding UIDs. + if (!empty($qresync_data)) { + if (!empty($qresync_data[2])) + $qresync_data[2] = self::compressMessageSet($qresync_data[2]); + $params[] = array('QRESYNC', $qresync_data); + } + + list($code, $response) = $this->execute('SELECT', $params); + + if ($code == self::ERROR_OK) { + $response = explode("\r\n", $response); + foreach ($response as $line) { + if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT)$/i', $line, $m)) { + $this->data[strtoupper($m[2])] = (int) $m[1]; + } + else if (preg_match('/^\* OK \[/i', $line, $match)) { + $line = substr($line, 6); + if (preg_match('/^(UIDNEXT|UIDVALIDITY|UNSEEN) ([0-9]+)/i', $line, $match)) { + $this->data[strtoupper($match[1])] = (int) $match[2]; + } + else if (preg_match('/^(HIGHESTMODSEQ) ([0-9]+)/i', $line, $match)) { + $this->data[strtoupper($match[1])] = (string) $match[2]; + } + else if (preg_match('/^(NOMODSEQ)/i', $line, $match)) { + $this->data[strtoupper($match[1])] = true; + } + else if (preg_match('/^PERMANENTFLAGS \(([^\)]+)\)/iU', $line, $match)) { + $this->data['PERMANENTFLAGS'] = explode(' ', $match[1]); + } + } + // QRESYNC FETCH response (RFC5162) + else if (preg_match('/^\* ([0-9+]) FETCH/i', $line, $match)) { + $line = substr($line, strlen($match[0])); + $fetch_data = $this->tokenizeResponse($line, 1); + $data = array('id' => $match[1]); + + for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) { + $data[strtolower($fetch_data[$i])] = $fetch_data[$i+1]; + } + + $this->data['QRESYNC'][$data['uid']] = $data; + } + // QRESYNC VANISHED response (RFC5162) + else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { + $line = substr($line, strlen($match[0])); + $v_data = $this->tokenizeResponse($line, 1); + + $this->data['VANISHED'] = $v_data; + } + } + + $this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY'; + + $this->selected = $mailbox; + return true; + } + + return false; + } + + /** + * Executes STATUS command + * + * @param string $mailbox Mailbox name + * @param array $items Additional requested item names. By default + * MESSAGES and UNSEEN are requested. Other defined + * in RFC3501: UIDNEXT, UIDVALIDITY, RECENT + * + * @return array Status item-value hash + * @since 0.5-beta + */ + function status($mailbox, $items=array()) + { + if (!strlen($mailbox)) { + return false; + } + + if (!in_array('MESSAGES', $items)) { + $items[] = 'MESSAGES'; + } + if (!in_array('UNSEEN', $items)) { + $items[] = 'UNSEEN'; + } + + list($code, $response) = $this->execute('STATUS', array($this->escape($mailbox), + '(' . implode(' ', (array) $items) . ')')); + + if ($code == self::ERROR_OK && preg_match('/\* STATUS /i', $response)) { + $result = array(); + $response = substr($response, 9); // remove prefix "* STATUS " + + list($mbox, $items) = $this->tokenizeResponse($response, 2); + + // Fix for #1487859. Some buggy server returns not quoted + // folder name with spaces. Let's try to handle this situation + if (!is_array($items) && ($pos = strpos($response, '(')) !== false) { + $response = substr($response, $pos); + $items = $this->tokenizeResponse($response, 1); + if (!is_array($items)) { + return $result; + } + } + + for ($i=0, $len=count($items); $i<$len; $i += 2) { + $result[$items[$i]] = $items[$i+1]; + } + + $this->data['STATUS:'.$mailbox] = $result; + + return $result; + } + + return false; + } + + /** + * Executes EXPUNGE command + * + * @param string $mailbox Mailbox name + * @param string $messages Message UIDs to expunge + * + * @return boolean True on success, False on error + */ + function expunge($mailbox, $messages=NULL) + { + if (!$this->select($mailbox)) { + return false; + } + + if (!$this->data['READ-WRITE']) { + $this->setError(self::ERROR_READONLY, "Mailbox is read-only", 'EXPUNGE'); + return false; + } + + // Clear internal status cache + unset($this->data['STATUS:'.$mailbox]); + + if ($messages) + $result = $this->execute('UID EXPUNGE', array($messages), self::COMMAND_NORESPONSE); + else + $result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE); + + if ($result == self::ERROR_OK) { + $this->selected = null; // state has changed, need to reselect + return true; + } + + return false; + } + + /** + * Executes CLOSE command + * + * @return boolean True on success, False on error + * @since 0.5 + */ + function close() + { + $result = $this->execute('CLOSE', NULL, self::COMMAND_NORESPONSE); + + if ($result == self::ERROR_OK) { + $this->selected = null; + return true; + } + + return false; + } + + /** + * Folder subscription (SUBSCRIBE) + * + * @param string $mailbox Mailbox name + * + * @return boolean True on success, False on error + */ + function subscribe($mailbox) + { + $result = $this->execute('SUBSCRIBE', array($this->escape($mailbox)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Folder unsubscription (UNSUBSCRIBE) + * + * @param string $mailbox Mailbox name + * + * @return boolean True on success, False on error + */ + function unsubscribe($mailbox) + { + $result = $this->execute('UNSUBSCRIBE', array($this->escape($mailbox)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Folder creation (CREATE) + * + * @param string $mailbox Mailbox name + * + * @return bool True on success, False on error + */ + function createFolder($mailbox) + { + $result = $this->execute('CREATE', array($this->escape($mailbox)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Folder renaming (RENAME) + * + * @param string $mailbox Mailbox name + * + * @return bool True on success, False on error + */ + function renameFolder($from, $to) + { + $result = $this->execute('RENAME', array($this->escape($from), $this->escape($to)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Executes DELETE command + * + * @param string $mailbox Mailbox name + * + * @return boolean True on success, False on error + */ + function deleteFolder($mailbox) + { + $result = $this->execute('DELETE', array($this->escape($mailbox)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Removes all messages in a folder + * + * @param string $mailbox Mailbox name + * + * @return boolean True on success, False on error + */ + function clearFolder($mailbox) + { + $num_in_trash = $this->countMessages($mailbox); + if ($num_in_trash > 0) { + $res = $this->flag($mailbox, '1:*', 'DELETED'); + } + + if ($res) { + if ($this->selected === $mailbox) + $res = $this->close(); + else + $res = $this->expunge($mailbox); + } + + return $res; + } + + /** + * Returns list of mailboxes + * + * @param string $ref Reference name + * @param string $mailbox Mailbox name + * @param array $status_opts (see self::_listMailboxes) + * @param array $select_opts (see self::_listMailboxes) + * + * @return array List of mailboxes or hash of options if $status_opts argument + * is non-empty. + */ + function listMailboxes($ref, $mailbox, $status_opts=array(), $select_opts=array()) + { + return $this->_listMailboxes($ref, $mailbox, false, $status_opts, $select_opts); + } + + /** + * Returns list of subscribed mailboxes + * + * @param string $ref Reference name + * @param string $mailbox Mailbox name + * @param array $status_opts (see self::_listMailboxes) + * + * @return array List of mailboxes or hash of options if $status_opts argument + * is non-empty. + */ + function listSubscribed($ref, $mailbox, $status_opts=array()) + { + return $this->_listMailboxes($ref, $mailbox, true, $status_opts, NULL); + } + + /** + * IMAP LIST/LSUB command + * + * @param string $ref Reference name + * @param string $mailbox Mailbox name + * @param bool $subscribed Enables returning subscribed mailboxes only + * @param array $status_opts List of STATUS options (RFC5819: LIST-STATUS) + * Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN + * @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED) + * Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE + * + * @return array List of mailboxes or hash of options if $status_ops argument + * is non-empty. + */ + private function _listMailboxes($ref, $mailbox, $subscribed=false, + $status_opts=array(), $select_opts=array()) + { + if (!strlen($mailbox)) { + $mailbox = '*'; + } + + $args = array(); + + if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) { + $select_opts = (array) $select_opts; + + $args[] = '(' . implode(' ', $select_opts) . ')'; + } + + $args[] = $this->escape($ref); + $args[] = $this->escape($mailbox); + + if (!empty($status_opts) && $this->getCapability('LIST-STATUS')) { + $status_opts = (array) $status_opts; + $lstatus = true; + + $args[] = 'RETURN (STATUS (' . implode(' ', $status_opts) . '))'; + } + + list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args); + + if ($code == self::ERROR_OK) { + $folders = array(); + $last = 0; + $pos = 0; + $response .= "\r\n"; + + while ($pos = strpos($response, "\r\n", $pos+1)) { + // literal string, not real end-of-command-line + if ($response[$pos-1] == '}') { + continue; + } + + $line = substr($response, $last, $pos - $last); + $last = $pos + 2; + + if (!preg_match('/^\* (LIST|LSUB|STATUS) /i', $line, $m)) { + continue; + } + $cmd = strtoupper($m[1]); + $line = substr($line, strlen($m[0])); + + // * LIST (<options>) <delimiter> <mailbox> + if ($cmd == 'LIST' || $cmd == 'LSUB') { + list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3); + + // Add to result array + if (!$lstatus) { + $folders[] = $mailbox; + } + else { + $folders[$mailbox] = array(); + } + + // store LSUB options only if not empty, this way + // we can detect a situation when LIST doesn't return specified folder + if (!empty($opts) || $cmd == 'LIST') { + // Add to options array + if (empty($this->data['LIST'][$mailbox])) + $this->data['LIST'][$mailbox] = $opts; + else if (!empty($opts)) + $this->data['LIST'][$mailbox] = array_unique(array_merge( + $this->data['LIST'][$mailbox], $opts)); + } + } + // * STATUS <mailbox> (<result>) + else if ($cmd == 'STATUS') { + list($mailbox, $status) = $this->tokenizeResponse($line, 2); + + for ($i=0, $len=count($status); $i<$len; $i += 2) { + list($name, $value) = $this->tokenizeResponse($status, 2); + $folders[$mailbox][$name] = $value; + } + } + } + + return $folders; + } + + return false; + } + + /** + * Returns count of all messages in a folder + * + * @param string $mailbox Mailbox name + * + * @return int Number of messages, False on error + */ + function countMessages($mailbox, $refresh = false) + { + if ($refresh) { + $this->selected = null; + } + + if ($this->selected === $mailbox) { + return $this->data['EXISTS']; + } + + // Check internal cache + $cache = $this->data['STATUS:'.$mailbox]; + if (!empty($cache) && isset($cache['MESSAGES'])) { + return (int) $cache['MESSAGES']; + } + + // Try STATUS (should be faster than SELECT) + $counts = $this->status($mailbox); + if (is_array($counts)) { + return (int) $counts['MESSAGES']; + } + + return false; + } + + /** + * Returns count of messages with \Recent flag in a folder + * + * @param string $mailbox Mailbox name + * + * @return int Number of messages, False on error + */ + function countRecent($mailbox) + { + if (!strlen($mailbox)) { + $mailbox = 'INBOX'; + } + + $this->select($mailbox); + + if ($this->selected === $mailbox) { + return $this->data['RECENT']; + } + + return false; + } + + /** + * Returns count of messages without \Seen flag in a specified folder + * + * @param string $mailbox Mailbox name + * + * @return int Number of messages, False on error + */ + function countUnseen($mailbox) + { + // Check internal cache + $cache = $this->data['STATUS:'.$mailbox]; + if (!empty($cache) && isset($cache['UNSEEN'])) { + return (int) $cache['UNSEEN']; + } + + // Try STATUS (should be faster than SELECT+SEARCH) + $counts = $this->status($mailbox); + if (is_array($counts)) { + return (int) $counts['UNSEEN']; + } + + // Invoke SEARCH as a fallback + $index = $this->search($mailbox, 'ALL UNSEEN', false, array('COUNT')); + if (!$index->is_error()) { + return $index->count(); + } + + return false; + } + + /** + * Executes ID command (RFC2971) + * + * @param array $items Client identification information key/value hash + * + * @return array Server identification information key/value hash + * @since 0.6 + */ + function id($items=array()) + { + if (is_array($items) && !empty($items)) { + foreach ($items as $key => $value) { + $args[] = $this->escape($key, true); + $args[] = $this->escape($value, true); + } + } + + list($code, $response) = $this->execute('ID', array( + !empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null) + )); + + + if ($code == self::ERROR_OK && preg_match('/\* ID /i', $response)) { + $response = substr($response, 5); // remove prefix "* ID " + $items = $this->tokenizeResponse($response, 1); + $result = null; + + for ($i=0, $len=count($items); $i<$len; $i += 2) { + $result[$items[$i]] = $items[$i+1]; + } + + return $result; + } + + return false; + } + + /** + * Executes ENABLE command (RFC5161) + * + * @param mixed $extension Extension name to enable (or array of names) + * + * @return array|bool List of enabled extensions, False on error + * @since 0.6 + */ + function enable($extension) + { + if (empty($extension)) { + return false; + } + + if (!$this->hasCapability('ENABLE')) { + return false; + } + + if (!is_array($extension)) { + $extension = array($extension); + } + + if (!empty($this->extensions_enabled)) { + // check if all extensions are already enabled + $diff = array_diff($extension, $this->extensions_enabled); + + if (empty($diff)) { + return $extension; + } + + // Make sure the mailbox isn't selected, before enabling extension(s) + if ($this->selected !== null) { + $this->close(); + } + } + + list($code, $response) = $this->execute('ENABLE', $extension); + + if ($code == self::ERROR_OK && preg_match('/\* ENABLED /i', $response)) { + $response = substr($response, 10); // remove prefix "* ENABLED " + $result = (array) $this->tokenizeResponse($response); + + $this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result)); + + return $this->extensions_enabled; + } + + return false; + } + + /** + * Executes SORT command + * + * @param string $mailbox Mailbox name + * @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) + * @param string $add Searching criteria + * @param bool $return_uid Enables UID SORT usage + * @param string $encoding Character set + * + * @return rcube_result_index Response data + */ + function sort($mailbox, $field, $add='', $return_uid=false, $encoding = 'US-ASCII') + { + $field = strtoupper($field); + if ($field == 'INTERNALDATE') { + $field = 'ARRIVAL'; + } + + $fields = array('ARRIVAL' => 1,'CC' => 1,'DATE' => 1, + 'FROM' => 1, 'SIZE' => 1, 'SUBJECT' => 1, 'TO' => 1); + + if (!$fields[$field]) { + return new rcube_result_index($mailbox); + } + + if (!$this->select($mailbox)) { + return new rcube_result_index($mailbox); + } + + // RFC 5957: SORT=DISPLAY + if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) { + $field = 'DISPLAY' . $field; + } + + // message IDs + if (!empty($add)) + $add = $this->compressMessageSet($add); + + list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT', + array("($field)", $encoding, 'ALL' . (!empty($add) ? ' '.$add : ''))); + + if ($code != self::ERROR_OK) { + $response = null; + } + + return new rcube_result_index($mailbox, $response); + } + + /** + * Executes THREAD command + * + * @param string $mailbox Mailbox name + * @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS) + * @param string $criteria Searching criteria + * @param bool $return_uid Enables UIDs in result instead of sequence numbers + * @param string $encoding Character set + * + * @return rcube_result_thread Thread data + */ + function thread($mailbox, $algorithm='REFERENCES', $criteria='', $return_uid=false, $encoding='US-ASCII') + { + $old_sel = $this->selected; + + if (!$this->select($mailbox)) { + return new rcube_result_thread($mailbox); + } + + // return empty result when folder is empty and we're just after SELECT + if ($old_sel != $mailbox && !$this->data['EXISTS']) { + return new rcube_result_thread($mailbox); + } + + $encoding = $encoding ? trim($encoding) : 'US-ASCII'; + $algorithm = $algorithm ? trim($algorithm) : 'REFERENCES'; + $criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL'; + $data = ''; + + list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD', + array($algorithm, $encoding, $criteria)); + + if ($code != self::ERROR_OK) { + $response = null; + } + + return new rcube_result_thread($mailbox, $response); + } + + /** + * Executes SEARCH command + * + * @param string $mailbox Mailbox name + * @param string $criteria Searching criteria + * @param bool $return_uid Enable UID in result instead of sequence ID + * @param array $items Return items (MIN, MAX, COUNT, ALL) + * + * @return rcube_result_index Result data + */ + function search($mailbox, $criteria, $return_uid=false, $items=array()) + { + $old_sel = $this->selected; + + if (!$this->select($mailbox)) { + return new rcube_result_index($mailbox); + } + + // return empty result when folder is empty and we're just after SELECT + if ($old_sel != $mailbox && !$this->data['EXISTS']) { + return new rcube_result_index($mailbox, '* SEARCH'); + } + + // If ESEARCH is supported always use ALL + // but not when items are specified or using simple id2uid search + if (empty($items) && preg_match('/[^0-9]/', $criteria)) { + $items = array('ALL'); + } + + $esearch = empty($items) ? false : $this->getCapability('ESEARCH'); + $criteria = trim($criteria); + $params = ''; + + // RFC4731: ESEARCH + if (!empty($items) && $esearch) { + $params .= 'RETURN (' . implode(' ', $items) . ')'; + } + + if (!empty($criteria)) { + $modseq = stripos($criteria, 'MODSEQ') !== false; + $params .= ($params ? ' ' : '') . $criteria; + } + else { + $params .= 'ALL'; + } + + list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', + array($params)); + + if ($code != self::ERROR_OK) { + $response = null; + } + + return new rcube_result_index($mailbox, $response); + } + + /** + * Simulates SORT command by using FETCH and sorting. + * + * @param string $mailbox Mailbox name + * @param string|array $message_set Searching criteria (list of messages to return) + * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) + * @param bool $skip_deleted Makes that DELETED messages will be skipped + * @param bool $uidfetch Enables UID FETCH usage + * @param bool $return_uid Enables returning UIDs instead of IDs + * + * @return rcube_result_index Response data + */ + function index($mailbox, $message_set, $index_field='', $skip_deleted=true, + $uidfetch=false, $return_uid=false) + { + $msg_index = $this->fetchHeaderIndex($mailbox, $message_set, + $index_field, $skip_deleted, $uidfetch, $return_uid); + + if (!empty($msg_index)) { + asort($msg_index); // ASC + $msg_index = array_keys($msg_index); + $msg_index = '* SEARCH ' . implode(' ', $msg_index); + } + else { + $msg_index = is_array($msg_index) ? '* SEARCH' : null; + } + + return new rcube_result_index($mailbox, $msg_index); + } + + function fetchHeaderIndex($mailbox, $message_set, $index_field='', $skip_deleted=true, + $uidfetch=false, $return_uid=false) + { + if (is_array($message_set)) { + if (!($message_set = $this->compressMessageSet($message_set))) + return false; + } else { + list($from_idx, $to_idx) = explode(':', $message_set); + if (empty($message_set) || + (isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx)) { + return false; + } + } + + $index_field = empty($index_field) ? 'DATE' : strtoupper($index_field); + + $fields_a['DATE'] = 1; + $fields_a['INTERNALDATE'] = 4; + $fields_a['ARRIVAL'] = 4; + $fields_a['FROM'] = 1; + $fields_a['REPLY-TO'] = 1; + $fields_a['SENDER'] = 1; + $fields_a['TO'] = 1; + $fields_a['CC'] = 1; + $fields_a['SUBJECT'] = 1; + $fields_a['UID'] = 2; + $fields_a['SIZE'] = 2; + $fields_a['SEEN'] = 3; + $fields_a['RECENT'] = 3; + $fields_a['DELETED'] = 3; + + if (!($mode = $fields_a[$index_field])) { + return false; + } + + /* Do "SELECT" command */ + if (!$this->select($mailbox)) { + return false; + } + + // build FETCH command string + $key = $this->nextTag(); + $cmd = $uidfetch ? 'UID FETCH' : 'FETCH'; + $fields = array(); + + if ($return_uid) + $fields[] = 'UID'; + if ($skip_deleted) + $fields[] = 'FLAGS'; + + if ($mode == 1) { + if ($index_field == 'DATE') + $fields[] = 'INTERNALDATE'; + $fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]"; + } + else if ($mode == 2) { + if ($index_field == 'SIZE') + $fields[] = 'RFC822.SIZE'; + else if (!$return_uid || $index_field != 'UID') + $fields[] = $index_field; + } + else if ($mode == 3 && !$skip_deleted) + $fields[] = 'FLAGS'; + else if ($mode == 4) + $fields[] = 'INTERNALDATE'; + + $request = "$key $cmd $message_set (" . implode(' ', $fields) . ")"; + + if (!$this->putLine($request)) { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + return false; + } + + $result = array(); + + do { + $line = rtrim($this->readLine(200)); + $line = $this->multLine($line); + + if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { + $id = $m[1]; + $flags = NULL; + + if ($return_uid) { + if (preg_match('/UID ([0-9]+)/', $line, $matches)) + $id = (int) $matches[1]; + else + continue; + } + if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { + $flags = explode(' ', strtoupper($matches[1])); + if (in_array('\\DELETED', $flags)) { + $deleted[$id] = $id; + continue; + } + } + + if ($mode == 1 && $index_field == 'DATE') { + if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) { + $value = preg_replace(array('/^"*[a-z]+:/i'), '', $matches[1]); + $value = trim($value); + $result[$id] = $this->strToTime($value); + } + // non-existent/empty Date: header, use INTERNALDATE + if (empty($result[$id])) { + if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) + $result[$id] = $this->strToTime($matches[1]); + else + $result[$id] = 0; + } + } else if ($mode == 1) { + if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) { + $value = preg_replace(array('/^"*[a-z]+:/i', '/\s+$/sm'), array('', ''), $matches[2]); + $result[$id] = trim($value); + } else { + $result[$id] = ''; + } + } else if ($mode == 2) { + if (preg_match('/(UID|RFC822\.SIZE) ([0-9]+)/', $line, $matches)) { + $result[$id] = trim($matches[2]); + } else { + $result[$id] = 0; + } + } else if ($mode == 3) { + if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { + $flags = explode(' ', $matches[1]); + } + $result[$id] = in_array('\\'.$index_field, $flags) ? 1 : 0; + } else if ($mode == 4) { + if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) { + $result[$id] = $this->strToTime($matches[1]); + } else { + $result[$id] = 0; + } + } + } + } while (!$this->startsWith($line, $key, true, true)); + + return $result; + } + + /** + * Returns message sequence identifier + * + * @param string $mailbox Mailbox name + * @param int $uid Message unique identifier (UID) + * + * @return int Message sequence identifier + */ + function UID2ID($mailbox, $uid) + { + if ($uid > 0) { + $index = $this->search($mailbox, "UID $uid"); + + if ($index->count() == 1) { + $arr = $index->get(); + return (int) $arr[0]; + } + } + return null; + } + + /** + * Returns message unique identifier (UID) + * + * @param string $mailbox Mailbox name + * @param int $uid Message sequence identifier + * + * @return int Message unique identifier + */ + function ID2UID($mailbox, $id) + { + if (empty($id) || $id < 0) { + return null; + } + + if (!$this->select($mailbox)) { + return null; + } + + $index = $this->search($mailbox, $id, true); + + if ($index->count() == 1) { + $arr = $index->get(); + return (int) $arr[0]; + } + + return null; + } + + /** + * Sets flag of the message(s) + * + * @param string $mailbox Mailbox name + * @param string|array $messages Message UID(s) + * @param string $flag Flag name + * + * @return bool True on success, False on failure + */ + function flag($mailbox, $messages, $flag) { + return $this->modFlag($mailbox, $messages, $flag, '+'); + } + + /** + * Unsets flag of the message(s) + * + * @param string $mailbox Mailbox name + * @param string|array $messages Message UID(s) + * @param string $flag Flag name + * + * @return bool True on success, False on failure + */ + function unflag($mailbox, $messages, $flag) { + return $this->modFlag($mailbox, $messages, $flag, '-'); + } + + /** + * Changes flag of the message(s) + * + * @param string $mailbox Mailbox name + * @param string|array $messages Message UID(s) + * @param string $flag Flag name + * @param string $mod Modifier [+|-]. Default: "+". + * + * @return bool True on success, False on failure + */ + private function modFlag($mailbox, $messages, $flag, $mod = '+') + { + if ($mod != '+' && $mod != '-') { + $mod = '+'; + } + + if (!$this->select($mailbox)) { + return false; + } + + if (!$this->data['READ-WRITE']) { + $this->setError(self::ERROR_READONLY, "Mailbox is read-only", 'STORE'); + return false; + } + + // Clear internal status cache + if ($flag == 'SEEN') { + unset($this->data['STATUS:'.$mailbox]['UNSEEN']); + } + + $flag = $this->flags[strtoupper($flag)]; + $result = $this->execute('UID STORE', array( + $this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Copies message(s) from one folder to another + * + * @param string|array $messages Message UID(s) + * @param string $from Mailbox name + * @param string $to Destination mailbox name + * + * @return bool True on success, False on failure + */ + function copy($messages, $from, $to) + { + // Clear last COPYUID data + unset($this->data['COPYUID']); + + if (!$this->select($from)) { + return false; + } + + // Clear internal status cache + unset($this->data['STATUS:'.$to]); + + $result = $this->execute('UID COPY', array( + $this->compressMessageSet($messages), $this->escape($to)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Moves message(s) from one folder to another. + * Original message(s) will be marked as deleted. + * + * @param string|array $messages Message UID(s) + * @param string $from Mailbox name + * @param string $to Destination mailbox name + * + * @return bool True on success, False on failure + */ + function move($messages, $from, $to) + { + if (!$this->select($from)) { + return false; + } + + if (!$this->data['READ-WRITE']) { + $this->setError(self::ERROR_READONLY, "Mailbox is read-only", 'STORE'); + return false; + } + + $r = $this->copy($messages, $from, $to); + + if ($r) { + // Clear internal status cache + unset($this->data['STATUS:'.$from]); + + return $this->flag($from, $messages, 'DELETED'); + } + return $r; + } + + /** + * FETCH command (RFC3501) + * + * @param string $mailbox Mailbox name + * @param mixed $message_set Message(s) sequence identifier(s) or UID(s) + * @param bool $is_uid True if $message_set contains UIDs + * @param array $query_items FETCH command data items + * @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query + * @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query + * + * @return array List of rcube_message_header elements, False on error + * @since 0.6 + */ + function fetch($mailbox, $message_set, $is_uid = false, $query_items = array(), + $mod_seq = null, $vanished = false) + { + if (!$this->select($mailbox)) { + return false; + } + + $message_set = $this->compressMessageSet($message_set); + $result = array(); + + $key = $this->nextTag(); + $request = $key . ($is_uid ? ' UID' : '') . " FETCH $message_set "; + $request .= "(" . implode(' ', $query_items) . ")"; + + if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) { + $request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")"; + } + + if (!$this->putLine($request)) { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + return false; + } + + do { + $line = $this->readLine(4096); + + if (!$line) + break; + + // Sample reply line: + // * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen) + // INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...) + // BODY[HEADER.FIELDS ... + + if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { + $id = intval($m[1]); + + $result[$id] = new rcube_message_header; + $result[$id]->id = $id; + $result[$id]->subject = ''; + $result[$id]->messageID = 'mid:' . $id; + + $headers = null; + $lines = array(); + $line = substr($line, strlen($m[0]) + 2); + $ln = 0; + + // get complete entry + while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) { + $bytes = $m[1]; + $out = ''; + + while (strlen($out) < $bytes) { + $out = $this->readBytes($bytes); + if ($out === NULL) + break; + $line .= $out; + } + + $str = $this->readLine(4096); + if ($str === false) + break; + + $line .= $str; + } + + // Tokenize response and assign to object properties + while (list($name, $value) = $this->tokenizeResponse($line, 2)) { + if ($name == 'UID') { + $result[$id]->uid = intval($value); + } + else if ($name == 'RFC822.SIZE') { + $result[$id]->size = intval($value); + } + else if ($name == 'RFC822.TEXT') { + $result[$id]->body = $value; + } + else if ($name == 'INTERNALDATE') { + $result[$id]->internaldate = $value; + $result[$id]->date = $value; + $result[$id]->timestamp = $this->StrToTime($value); + } + else if ($name == 'FLAGS') { + if (!empty($value)) { + foreach ((array)$value as $flag) { + $flag = str_replace(array('$', '\\'), '', $flag); + $flag = strtoupper($flag); + + $result[$id]->flags[$flag] = true; + } + } + } + else if ($name == 'MODSEQ') { + $result[$id]->modseq = $value[0]; + } + else if ($name == 'ENVELOPE') { + $result[$id]->envelope = $value; + } + else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) { + if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) { + $value = array($value); + } + $result[$id]->bodystructure = $value; + } + else if ($name == 'RFC822') { + $result[$id]->body = $value; + } + else if ($name == 'BODY') { + $body = $this->tokenizeResponse($line, 1); + if ($value[0] == 'HEADER.FIELDS') + $headers = $body; + else if (!empty($value)) + $result[$id]->bodypart[$value[0]] = $body; + else + $result[$id]->body = $body; + } + } + + // create array with header field:data + if (!empty($headers)) { + $headers = explode("\n", trim($headers)); + foreach ($headers as $hid => $resln) { + if (ord($resln[0]) <= 32) { + $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($resln); + } else { + $lines[++$ln] = trim($resln); + } + } + + while (list($lines_key, $str) = each($lines)) { + list($field, $string) = explode(':', $str, 2); + + $field = strtolower($field); + $string = preg_replace('/\n[\t\s]*/', ' ', trim($string)); + + switch ($field) { + case 'date'; + $result[$id]->date = $string; + $result[$id]->timestamp = $this->strToTime($string); + break; + case 'from': + $result[$id]->from = $string; + break; + case 'to': + $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string); + break; + case 'subject': + $result[$id]->subject = $string; + break; + case 'reply-to': + $result[$id]->replyto = $string; + break; + case 'cc': + $result[$id]->cc = $string; + break; + case 'bcc': + $result[$id]->bcc = $string; + break; + case 'content-transfer-encoding': + $result[$id]->encoding = $string; + break; + case 'content-type': + $ctype_parts = preg_split('/[; ]+/', $string); + $result[$id]->ctype = strtolower(array_shift($ctype_parts)); + if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) { + $result[$id]->charset = $regs[1]; + } + break; + case 'in-reply-to': + $result[$id]->in_reply_to = str_replace(array("\n", '<', '>'), '', $string); + break; + case 'references': + $result[$id]->references = $string; + break; + case 'return-receipt-to': + case 'disposition-notification-to': + case 'x-confirm-reading-to': + $result[$id]->mdn_to = $string; + break; + case 'message-id': + $result[$id]->messageID = $string; + break; + case 'x-priority': + if (preg_match('/^(\d+)/', $string, $matches)) { + $result[$id]->priority = intval($matches[1]); + } + break; + default: + if (strlen($field) < 3) { + break; + } + if ($result[$id]->others[$field]) { + $string = array_merge((array)$result[$id]->others[$field], (array)$string); + } + $result[$id]->others[$field] = $string; + } + } + } + } + + // VANISHED response (QRESYNC RFC5162) + // Sample: * VANISHED (EARLIER) 300:310,405,411 + else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { + $line = substr($line, strlen($match[0])); + $v_data = $this->tokenizeResponse($line, 1); + + $this->data['VANISHED'] = $v_data; + } + + } while (!$this->startsWith($line, $key, true)); + + return $result; + } + + function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add = '') + { + $query_items = array('UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE'); + if ($bodystr) + $query_items[] = 'BODYSTRUCTURE'; + $query_items[] = 'BODY.PEEK[HEADER.FIELDS (' + . 'DATE FROM TO SUBJECT CONTENT-TYPE CC REPLY-TO LIST-POST DISPOSITION-NOTIFICATION-TO X-PRIORITY' + . ($add ? ' ' . trim($add) : '') + . ')]'; + + $result = $this->fetch($mailbox, $message_set, $is_uid, $query_items); + + return $result; + } + + function fetchHeader($mailbox, $id, $uidfetch=false, $bodystr=false, $add='') + { + $a = $this->fetchHeaders($mailbox, $id, $uidfetch, $bodystr, $add); + if (is_array($a)) { + return array_shift($a); + } + return false; + } + + function sortHeaders($a, $field, $flag) + { + if (empty($field)) { + $field = 'uid'; + } + else { + $field = strtolower($field); + } + + if ($field == 'date' || $field == 'internaldate') { + $field = 'timestamp'; + } + + if (empty($flag)) { + $flag = 'ASC'; + } else { + $flag = strtoupper($flag); + } + + $c = count($a); + if ($c > 0) { + // Strategy: + // First, we'll create an "index" array. + // Then, we'll use sort() on that array, + // and use that to sort the main array. + + // create "index" array + $index = array(); + reset($a); + while (list($key, $val) = each($a)) { + if ($field == 'timestamp') { + $data = $this->strToTime($val->date); + if (!$data) { + $data = $val->timestamp; + } + } else { + $data = $val->$field; + if (is_string($data)) { + $data = str_replace('"', '', $data); + if ($field == 'subject') { + $data = preg_replace('/^(Re: \s*|Fwd:\s*|Fw:\s*)+/i', '', $data); + } + $data = strtoupper($data); + } + } + $index[$key] = $data; + } + + // sort index + if ($flag == 'ASC') { + asort($index); + } else { + arsort($index); + } + + // form new array based on index + $result = array(); + reset($index); + while (list($key, $val) = each($index)) { + $result[$key] = $a[$key]; + } + } + + return $result; + } + + function fetchMIMEHeaders($mailbox, $uid, $parts, $mime=true) + { + if (!$this->select($mailbox)) { + return false; + } + + $result = false; + $parts = (array) $parts; + $key = $this->nextTag(); + $peeks = array(); + $type = $mime ? 'MIME' : 'HEADER'; + + // format request + foreach ($parts as $part) { + $peeks[] = "BODY.PEEK[$part.$type]"; + } + + $request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')'; + + // send request + if (!$this->putLine($request)) { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + return false; + } + + do { + $line = $this->readLine(1024); + + if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) { + $idx = $matches[1]; + $headers = ''; + + // get complete entry + if (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) { + $bytes = $m[1]; + $out = ''; + + while (strlen($out) < $bytes) { + $out = $this->readBytes($bytes); + if ($out === null) + break; + $headers .= $out; + } + } + + $result[$idx] = trim($headers); + } + } while (!$this->startsWith($line, $key, true)); + + return $result; + } + + function fetchPartHeader($mailbox, $id, $is_uid=false, $part=NULL) + { + $part = empty($part) ? 'HEADER' : $part.'.MIME'; + + return $this->handlePartBody($mailbox, $id, $is_uid, $part); + } + + function handlePartBody($mailbox, $id, $is_uid=false, $part='', $encoding=NULL, $print=NULL, $file=NULL, $formatted=false, $max_bytes=0) + { + if (!$this->select($mailbox)) { + return false; + } + + switch ($encoding) { + case 'base64': + $mode = 1; + break; + case 'quoted-printable': + $mode = 2; + break; + case 'x-uuencode': + case 'x-uue': + case 'uue': + case 'uuencode': + $mode = 3; + break; + default: + $mode = 0; + } + + // Use BINARY extension when possible (and safe) + $binary = $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY'); + $fetch_mode = $binary ? 'BINARY' : 'BODY'; + $partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : ''; + + // format request + $key = $this->nextTag(); + $request = $key . ($is_uid ? ' UID' : '') . " FETCH $id ($fetch_mode.PEEK[$part]$partial)"; + + // send request + if (!$this->putLine($request)) { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + return false; + } + + if ($binary) { + // WARNING: Use $formatting argument with care, this may break binary data stream + $mode = -1; + } + + // receive reply line + do { + $line = rtrim($this->readLine(1024)); + $a = explode(' ', $line); + } while (!($end = $this->startsWith($line, $key, true)) && $a[2] != 'FETCH'); + + $len = strlen($line); + $result = false; + + if ($a[2] != 'FETCH') { + } + // handle empty "* X FETCH ()" response + else if ($line[$len-1] == ')' && $line[$len-2] != '(') { + // one line response, get everything between first and last quotes + if (substr($line, -4, 3) == 'NIL') { + // NIL response + $result = ''; + } else { + $from = strpos($line, '"') + 1; + $to = strrpos($line, '"'); + $len = $to - $from; + $result = substr($line, $from, $len); + } + + if ($mode == 1) { + $result = base64_decode($result); + } + else if ($mode == 2) { + $result = quoted_printable_decode($result); + } + else if ($mode == 3) { + $result = convert_uudecode($result); + } + + } else if ($line[$len-1] == '}') { + // multi-line request, find sizes of content and receive that many bytes + $from = strpos($line, '{') + 1; + $to = strrpos($line, '}'); + $len = $to - $from; + $sizeStr = substr($line, $from, $len); + $bytes = (int)$sizeStr; + $prev = ''; + + while ($bytes > 0) { + $line = $this->readLine(8192); + + if ($line === NULL) { + break; + } + + $len = strlen($line); + + if ($len > $bytes) { + $line = substr($line, 0, $bytes); + $len = strlen($line); + } + $bytes -= $len; + + // BASE64 + if ($mode == 1) { + $line = rtrim($line, "\t\r\n\0\x0B"); + // create chunks with proper length for base64 decoding + $line = $prev.$line; + $length = strlen($line); + if ($length % 4) { + $length = floor($length / 4) * 4; + $prev = substr($line, $length); + $line = substr($line, 0, $length); + } + else + $prev = ''; + $line = base64_decode($line); + // QUOTED-PRINTABLE + } else if ($mode == 2) { + $line = rtrim($line, "\t\r\0\x0B"); + $line = quoted_printable_decode($line); + // UUENCODE + } else if ($mode == 3) { + $line = rtrim($line, "\t\r\n\0\x0B"); + if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) + continue; + $line = convert_uudecode($line); + // default + } else if ($formatted) { + $line = rtrim($line, "\t\r\n\0\x0B") . "\n"; + } + + if ($file) { + if (fwrite($file, $line) === false) + break; + } + else if ($print) + echo $line; + else + $result .= $line; + } + } + + // read in anything up until last line + if (!$end) + do { + $line = $this->readLine(1024); + } while (!$this->startsWith($line, $key, true)); + + if ($result !== false) { + if ($file) { + return fwrite($file, $result); + } else if ($print) { + echo $result; + } else + return $result; + return true; + } + + return false; + } + + /** + * Handler for IMAP APPEND command + * + * @param string $mailbox Mailbox name + * @param string $message Message content + * @param array $flags Message flags + * @param string $date Message internal date + * + * @return string|bool On success APPENDUID response (if available) or True, False on failure + */ + function append($mailbox, &$message, $flags = array(), $date = null) + { + unset($this->data['APPENDUID']); + + if ($mailbox === null || $mailbox === '') { + return false; + } + + $message = str_replace("\r", '', $message); + $message = str_replace("\n", "\r\n", $message); + + $len = strlen($message); + if (!$len) { + return false; + } + + // build APPEND command + $key = $this->nextTag(); + $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')'; + if (!empty($date)) { + $request .= ' ' . $this->escape($date); + } + $request .= ' {' . $len . ($this->prefs['literal+'] ? '+' : '') . '}'; + + // send APPEND command + if ($this->putLine($request)) { + // Do not wait when LITERAL+ is supported + if (!$this->prefs['literal+']) { + $line = $this->readReply(); + + if ($line[0] != '+') { + $this->parseResult($line, 'APPEND: '); + return false; + } + } + + if (!$this->putLine($message)) { + return false; + } + + do { + $line = $this->readLine(); + } while (!$this->startsWith($line, $key, true, true)); + + // Clear internal status cache + unset($this->data['STATUS:'.$mailbox]); + + if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) + return false; + else if (!empty($this->data['APPENDUID'])) + return $this->data['APPENDUID']; + else + return true; + } + else { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + } + + return false; + } + + /** + * Handler for IMAP APPEND command. + * + * @param string $mailbox Mailbox name + * @param string $path Path to the file with message body + * @param string $headers Message headers + * @param array $flags Message flags + * @param string $date Message internal date + * + * @return string|bool On success APPENDUID response (if available) or True, False on failure + */ + function appendFromFile($mailbox, $path, $headers=null, $flags = array(), $date = null) + { + unset($this->data['APPENDUID']); + + if ($mailbox === null || $mailbox === '') { + return false; + } + + // open message file + $in_fp = false; + if (file_exists(realpath($path))) { + $in_fp = fopen($path, 'r'); + } + + if (!$in_fp) { + $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading"); + return false; + } + + $body_separator = "\r\n\r\n"; + $len = filesize($path); + + if (!$len) { + return false; + } + + if ($headers) { + $headers = preg_replace('/[\r\n]+$/', '', $headers); + $len += strlen($headers) + strlen($body_separator); + } + + // build APPEND command + $key = $this->nextTag(); + $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')'; + if (!empty($date)) { + $request .= ' ' . $this->escape($date); + } + $request .= ' {' . $len . ($this->prefs['literal+'] ? '+' : '') . '}'; + + // send APPEND command + if ($this->putLine($request)) { + // Don't wait when LITERAL+ is supported + if (!$this->prefs['literal+']) { + $line = $this->readReply(); + + if ($line[0] != '+') { + $this->parseResult($line, 'APPEND: '); + return false; + } + } + + // send headers with body separator + if ($headers) { + $this->putLine($headers . $body_separator, false); + } + + // send file + while (!feof($in_fp) && $this->fp) { + $buffer = fgets($in_fp, 4096); + $this->putLine($buffer, false); + } + fclose($in_fp); + + if (!$this->putLine('')) { // \r\n + return false; + } + + // read response + do { + $line = $this->readLine(); + } while (!$this->startsWith($line, $key, true, true)); + + // Clear internal status cache + unset($this->data['STATUS:'.$mailbox]); + + if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) + return false; + else if (!empty($this->data['APPENDUID'])) + return $this->data['APPENDUID']; + else + return true; + } + else { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $request"); + } + + return false; + } + + /** + * Returns QUOTA information + * + * @return array Quota information + */ + function getQuota() + { + /* + * GETQUOTAROOT "INBOX" + * QUOTAROOT INBOX user/rchijiiwa1 + * QUOTA user/rchijiiwa1 (STORAGE 654 9765) + * OK Completed + */ + $result = false; + $quota_lines = array(); + $key = $this->nextTag(); + $command = $key . ' GETQUOTAROOT INBOX'; + + // get line(s) containing quota info + if ($this->putLine($command)) { + do { + $line = rtrim($this->readLine(5000)); + if (preg_match('/^\* QUOTA /', $line)) { + $quota_lines[] = $line; + } + } while (!$this->startsWith($line, $key, true, true)); + } + else { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $command"); + } + + // return false if not found, parse if found + $min_free = PHP_INT_MAX; + foreach ($quota_lines as $key => $quota_line) { + $quota_line = str_replace(array('(', ')'), '', $quota_line); + $parts = explode(' ', $quota_line); + $storage_part = array_search('STORAGE', $parts); + + if (!$storage_part) { + continue; + } + + $used = intval($parts[$storage_part+1]); + $total = intval($parts[$storage_part+2]); + $free = $total - $used; + + // return lowest available space from all quotas + if ($free < $min_free) { + $min_free = $free; + $result['used'] = $used; + $result['total'] = $total; + $result['percent'] = min(100, round(($used/max(1,$total))*100)); + $result['free'] = 100 - $result['percent']; + } + } + + return $result; + } + + /** + * Send the SETACL command (RFC4314) + * + * @param string $mailbox Mailbox name + * @param string $user User name + * @param mixed $acl ACL string or array + * + * @return boolean True on success, False on failure + * + * @since 0.5-beta + */ + function setACL($mailbox, $user, $acl) + { + if (is_array($acl)) { + $acl = implode('', $acl); + } + + $result = $this->execute('SETACL', array( + $this->escape($mailbox), $this->escape($user), strtolower($acl)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Send the DELETEACL command (RFC4314) + * + * @param string $mailbox Mailbox name + * @param string $user User name + * + * @return boolean True on success, False on failure + * + * @since 0.5-beta + */ + function deleteACL($mailbox, $user) + { + $result = $this->execute('DELETEACL', array( + $this->escape($mailbox), $this->escape($user)), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Send the GETACL command (RFC4314) + * + * @param string $mailbox Mailbox name + * + * @return array User-rights array on success, NULL on error + * @since 0.5-beta + */ + function getACL($mailbox) + { + list($code, $response) = $this->execute('GETACL', array($this->escape($mailbox))); + + if ($code == self::ERROR_OK && preg_match('/^\* ACL /i', $response)) { + // Parse server response (remove "* ACL ") + $response = substr($response, 6); + $ret = $this->tokenizeResponse($response); + $mbox = array_shift($ret); + $size = count($ret); + + // Create user-rights hash array + // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1 + // so we could return only standard rights defined in RFC4314, + // excluding 'c' and 'd' defined in RFC2086. + if ($size % 2 == 0) { + for ($i=0; $i<$size; $i++) { + $ret[$ret[$i]] = str_split($ret[++$i]); + unset($ret[$i-1]); + unset($ret[$i]); + } + return $ret; + } + + $this->setError(self::ERROR_COMMAND, "Incomplete ACL response"); + return NULL; + } + + return NULL; + } + + /** + * Send the LISTRIGHTS command (RFC4314) + * + * @param string $mailbox Mailbox name + * @param string $user User name + * + * @return array List of user rights + * @since 0.5-beta + */ + function listRights($mailbox, $user) + { + list($code, $response) = $this->execute('LISTRIGHTS', array( + $this->escape($mailbox), $this->escape($user))); + + if ($code == self::ERROR_OK && preg_match('/^\* LISTRIGHTS /i', $response)) { + // Parse server response (remove "* LISTRIGHTS ") + $response = substr($response, 13); + + $ret_mbox = $this->tokenizeResponse($response, 1); + $ret_user = $this->tokenizeResponse($response, 1); + $granted = $this->tokenizeResponse($response, 1); + $optional = trim($response); + + return array( + 'granted' => str_split($granted), + 'optional' => explode(' ', $optional), + ); + } + + return NULL; + } + + /** + * Send the MYRIGHTS command (RFC4314) + * + * @param string $mailbox Mailbox name + * + * @return array MYRIGHTS response on success, NULL on error + * @since 0.5-beta + */ + function myRights($mailbox) + { + list($code, $response) = $this->execute('MYRIGHTS', array($this->escape($mailbox))); + + if ($code == self::ERROR_OK && preg_match('/^\* MYRIGHTS /i', $response)) { + // Parse server response (remove "* MYRIGHTS ") + $response = substr($response, 11); + + $ret_mbox = $this->tokenizeResponse($response, 1); + $rights = $this->tokenizeResponse($response, 1); + + return str_split($rights); + } + + return NULL; + } + + /** + * Send the SETMETADATA command (RFC5464) + * + * @param string $mailbox Mailbox name + * @param array $entries Entry-value array (use NULL value as NIL) + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + function setMetadata($mailbox, $entries) + { + if (!is_array($entries) || empty($entries)) { + $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); + return false; + } + + foreach ($entries as $name => $value) { + $entries[$name] = $this->escape($name) . ' ' . $this->escape($value); + } + + $entries = implode(' ', $entries); + $result = $this->execute('SETMETADATA', array( + $this->escape($mailbox), '(' . $entries . ')'), + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Send the SETMETADATA command with NIL values (RFC5464) + * + * @param string $mailbox Mailbox name + * @param array $entries Entry names array + * + * @return boolean True on success, False on failure + * + * @since 0.5-beta + */ + function deleteMetadata($mailbox, $entries) + { + if (!is_array($entries) && !empty($entries)) { + $entries = explode(' ', $entries); + } + + if (empty($entries)) { + $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); + return false; + } + + foreach ($entries as $entry) { + $data[$entry] = NULL; + } + + return $this->setMetadata($mailbox, $data); + } + + /** + * Send the GETMETADATA command (RFC5464) + * + * @param string $mailbox Mailbox name + * @param array $entries Entries + * @param array $options Command options (with MAXSIZE and DEPTH keys) + * + * @return array GETMETADATA result on success, NULL on error + * + * @since 0.5-beta + */ + function getMetadata($mailbox, $entries, $options=array()) + { + if (!is_array($entries)) { + $entries = array($entries); + } + + // create entries string + foreach ($entries as $idx => $name) { + $entries[$idx] = $this->escape($name); + } + + $optlist = ''; + $entlist = '(' . implode(' ', $entries) . ')'; + + // create options string + if (is_array($options)) { + $options = array_change_key_case($options, CASE_UPPER); + $opts = array(); + + if (!empty($options['MAXSIZE'])) { + $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']); + } + if (!empty($options['DEPTH'])) { + $opts[] = 'DEPTH '.intval($options['DEPTH']); + } + + if ($opts) { + $optlist = '(' . implode(' ', $opts) . ')'; + } + } + + $optlist .= ($optlist ? ' ' : '') . $entlist; + + list($code, $response) = $this->execute('GETMETADATA', array( + $this->escape($mailbox), $optlist)); + + if ($code == self::ERROR_OK) { + $result = array(); + $data = $this->tokenizeResponse($response); + + // The METADATA response can contain multiple entries in a single + // response or multiple responses for each entry or group of entries + if (!empty($data) && ($size = count($data))) { + for ($i=0; $i<$size; $i++) { + if (isset($mbox) && is_array($data[$i])) { + $size_sub = count($data[$i]); + for ($x=0; $x<$size_sub; $x++) { + $result[$mbox][$data[$i][$x]] = $data[$i][++$x]; + } + unset($data[$i]); + } + else if ($data[$i] == '*') { + if ($data[$i+1] == 'METADATA') { + $mbox = $data[$i+2]; + unset($data[$i]); // "*" + unset($data[++$i]); // "METADATA" + unset($data[++$i]); // Mailbox + } + // get rid of other untagged responses + else { + unset($mbox); + unset($data[$i]); + } + } + else if (isset($mbox)) { + $result[$mbox][$data[$i]] = $data[++$i]; + unset($data[$i]); + unset($data[$i-1]); + } + else { + unset($data[$i]); + } + } + } + + return $result; + } + + return NULL; + } + + /** + * Send the SETANNOTATION command (draft-daboo-imap-annotatemore) + * + * @param string $mailbox Mailbox name + * @param array $data Data array where each item is an array with + * three elements: entry name, attribute name, value + * + * @return boolean True on success, False on failure + * @since 0.5-beta + */ + function setAnnotation($mailbox, $data) + { + if (!is_array($data) || empty($data)) { + $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); + return false; + } + + foreach ($data as $entry) { + // ANNOTATEMORE drafts before version 08 require quoted parameters + $entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true), + $this->escape($entry[1], true), $this->escape($entry[2], true)); + } + + $entries = implode(' ', $entries); + $result = $this->execute('SETANNOTATION', array( + $this->escape($mailbox), $entries), self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + + /** + * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore) + * + * @param string $mailbox Mailbox name + * @param array $data Data array where each item is an array with + * two elements: entry name and attribute name + * + * @return boolean True on success, False on failure + * + * @since 0.5-beta + */ + function deleteAnnotation($mailbox, $data) + { + if (!is_array($data) || empty($data)) { + $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); + return false; + } + + return $this->setAnnotation($mailbox, $data); + } + + /** + * Send the GETANNOTATION command (draft-daboo-imap-annotatemore) + * + * @param string $mailbox Mailbox name + * @param array $entries Entries names + * @param array $attribs Attribs names + * + * @return array Annotations result on success, NULL on error + * + * @since 0.5-beta + */ + function getAnnotation($mailbox, $entries, $attribs) + { + if (!is_array($entries)) { + $entries = array($entries); + } + // create entries string + // ANNOTATEMORE drafts before version 08 require quoted parameters + foreach ($entries as $idx => $name) { + $entries[$idx] = $this->escape($name, true); + } + $entries = '(' . implode(' ', $entries) . ')'; + + if (!is_array($attribs)) { + $attribs = array($attribs); + } + // create entries string + foreach ($attribs as $idx => $name) { + $attribs[$idx] = $this->escape($name, true); + } + $attribs = '(' . implode(' ', $attribs) . ')'; + + list($code, $response) = $this->execute('GETANNOTATION', array( + $this->escape($mailbox), $entries, $attribs)); + + if ($code == self::ERROR_OK) { + $result = array(); + $data = $this->tokenizeResponse($response); + + // Here we returns only data compatible with METADATA result format + if (!empty($data) && ($size = count($data))) { + for ($i=0; $i<$size; $i++) { + $entry = $data[$i]; + if (isset($mbox) && is_array($entry)) { + $attribs = $entry; + $entry = $last_entry; + } + else if ($entry == '*') { + if ($data[$i+1] == 'ANNOTATION') { + $mbox = $data[$i+2]; + unset($data[$i]); // "*" + unset($data[++$i]); // "ANNOTATION" + unset($data[++$i]); // Mailbox + } + // get rid of other untagged responses + else { + unset($mbox); + unset($data[$i]); + } + continue; + } + else if (isset($mbox)) { + $attribs = $data[++$i]; + } + else { + unset($data[$i]); + continue; + } + + if (!empty($attribs)) { + for ($x=0, $len=count($attribs); $x<$len;) { + $attr = $attribs[$x++]; + $value = $attribs[$x++]; + if ($attr == 'value.priv') { + $result[$mbox]['/private' . $entry] = $value; + } + else if ($attr == 'value.shared') { + $result[$mbox]['/shared' . $entry] = $value; + } + } + } + $last_entry = $entry; + unset($data[$i]); + } + } + + return $result; + } + + return NULL; + } + + /** + * Returns BODYSTRUCTURE for the specified message. + * + * @param string $mailbox Folder name + * @param int $id Message sequence number or UID + * @param bool $is_uid True if $id is an UID + * + * @return array/bool Body structure array or False on error. + * @since 0.6 + */ + function getStructure($mailbox, $id, $is_uid = false) + { + $result = $this->fetch($mailbox, $id, $is_uid, array('BODYSTRUCTURE')); + if (is_array($result)) { + $result = array_shift($result); + return $result->bodystructure; + } + return false; + } + + /** + * Returns data of a message part according to specified structure. + * + * @param array $structure Message structure (getStructure() result) + * @param string $part Message part identifier + * + * @return array Part data as hash array (type, encoding, charset, size) + */ + static function getStructurePartData($structure, $part) + { + $part_a = self::getStructurePartArray($structure, $part); + $data = array(); + + if (empty($part_a)) { + return $data; + } + + // content-type + if (is_array($part_a[0])) { + $data['type'] = 'multipart'; + } + else { + $data['type'] = strtolower($part_a[0]); + + // encoding + $data['encoding'] = strtolower($part_a[5]); + + // charset + if (is_array($part_a[2])) { + while (list($key, $val) = each($part_a[2])) { + if (strcasecmp($val, 'charset') == 0) { + $data['charset'] = $part_a[2][$key+1]; + break; + } + } + } + } + + // size + $data['size'] = intval($part_a[6]); + + return $data; + } + + static function getStructurePartArray($a, $part) + { + if (!is_array($a)) { + return false; + } + + if (empty($part)) { + return $a; + } + + $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : ''; + + if (strcasecmp($ctype, 'message/rfc822') == 0) { + $a = $a[8]; + } + + if (strpos($part, '.') > 0) { + $orig_part = $part; + $pos = strpos($part, '.'); + $rest = substr($orig_part, $pos+1); + $part = substr($orig_part, 0, $pos); + + return self::getStructurePartArray($a[$part-1], $rest); + } + else if ($part > 0) { + return (is_array($a[$part-1])) ? $a[$part-1] : $a; + } + } + + /** + * Creates next command identifier (tag) + * + * @return string Command identifier + * @since 0.5-beta + */ + function nextTag() + { + $this->cmd_num++; + $this->cmd_tag = sprintf('A%04d', $this->cmd_num); + + return $this->cmd_tag; + } + + /** + * Sends IMAP command and parses result + * + * @param string $command IMAP command + * @param array $arguments Command arguments + * @param int $options Execution options + * + * @return mixed Response code or list of response code and data + * @since 0.5-beta + */ + function execute($command, $arguments=array(), $options=0) + { + $tag = $this->nextTag(); + $query = $tag . ' ' . $command; + $noresp = ($options & self::COMMAND_NORESPONSE); + $response = $noresp ? null : ''; + + if (!empty($arguments)) { + foreach ($arguments as $arg) { + $query .= ' ' . self::r_implode($arg); + } + } + + // Send command + if (!$this->putLineC($query)) { + $this->setError(self::ERROR_COMMAND, "Unable to send command: $query"); + return $noresp ? self::ERROR_COMMAND : array(self::ERROR_COMMAND, ''); + } + + // Parse response + do { + $line = $this->readLine(4096); + if ($response !== null) { + $response .= $line; + } + } while (!$this->startsWith($line, $tag . ' ', true, true)); + + $code = $this->parseResult($line, $command . ': '); + + // Remove last line from response + if ($response) { + $line_len = min(strlen($response), strlen($line) + 2); + $response = substr($response, 0, -$line_len); + } + + // optional CAPABILITY response + if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK + && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches) + ) { + $this->parseCapability($matches[1], true); + } + + // return last line only (without command tag, result and response code) + if ($line && ($options & self::COMMAND_LASTLINE)) { + $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line)); + } + + return $noresp ? $code : array($code, $response); + } + + /** + * Splits IMAP response into string tokens + * + * @param string &$str The IMAP's server response + * @param int $num Number of tokens to return + * + * @return mixed Tokens array or string if $num=1 + * @since 0.5-beta + */ + static function tokenizeResponse(&$str, $num=0) + { + $result = array(); + + while (!$num || count($result) < $num) { + // remove spaces from the beginning of the string + $str = ltrim($str); + + switch ($str[0]) { + + // String literal + case '{': + if (($epos = strpos($str, "}\r\n", 1)) == false) { + // error + } + if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) { + // error + } + $result[] = $bytes ? substr($str, $epos + 3, $bytes) : ''; + // Advance the string + $str = substr($str, $epos + 3 + $bytes); + break; + + // Quoted string + case '"': + $len = strlen($str); + + for ($pos=1; $pos<$len; $pos++) { + if ($str[$pos] == '"') { + break; + } + if ($str[$pos] == "\\") { + if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") { + $pos++; + } + } + } + if ($str[$pos] != '"') { + // error + } + // we need to strip slashes for a quoted string + $result[] = stripslashes(substr($str, 1, $pos - 1)); + $str = substr($str, $pos + 1); + break; + + // Parenthesized list + case '(': + case '[': + $str = substr($str, 1); + $result[] = self::tokenizeResponse($str); + break; + case ')': + case ']': + $str = substr($str, 1); + return $result; + break; + + // String atom, number, NIL, *, % + default: + // empty string + if ($str === '' || $str === null) { + break 2; + } + + // excluded chars: SP, CTL, ), [, ] + if (preg_match('/^([^\x00-\x20\x29\x5B\x5D\x7F]+)/', $str, $m)) { + $result[] = $m[1] == 'NIL' ? NULL : $m[1]; + $str = substr($str, strlen($m[1])); + } + break; + } + } + + return $num == 1 ? $result[0] : $result; + } + + static function r_implode($element) + { + $string = ''; + + if (is_array($element)) { + reset($element); + while (list($key, $value) = each($element)) { + $string .= ' ' . self::r_implode($value); + } + } + else { + return $element; + } + + return '(' . trim($string) . ')'; + } + + /** + * Converts message identifiers array into sequence-set syntax + * + * @param array $messages Message identifiers + * @param bool $force Forces compression of any size + * + * @return string Compressed sequence-set + */ + static function compressMessageSet($messages, $force=false) + { + // given a comma delimited list of independent mid's, + // compresses by grouping sequences together + + if (!is_array($messages)) { + // if less than 255 bytes long, let's not bother + if (!$force && strlen($messages)<255) { + return $messages; + } + + // see if it's already been compressed + if (strpos($messages, ':') !== false) { + return $messages; + } + + // separate, then sort + $messages = explode(',', $messages); + } + + sort($messages); + + $result = array(); + $start = $prev = $messages[0]; + + foreach ($messages as $id) { + $incr = $id - $prev; + if ($incr > 1) { // found a gap + if ($start == $prev) { + $result[] = $prev; // push single id + } else { + $result[] = $start . ':' . $prev; // push sequence as start_id:end_id + } + $start = $id; // start of new sequence + } + $prev = $id; + } + + // handle the last sequence/id + if ($start == $prev) { + $result[] = $prev; + } else { + $result[] = $start.':'.$prev; + } + + // return as comma separated string + return implode(',', $result); + } + + /** + * Converts message sequence-set into array + * + * @param string $messages Message identifiers + * + * @return array List of message identifiers + */ + static function uncompressMessageSet($messages) + { + if (empty($messages)) { + return array(); + } + + $result = array(); + $messages = explode(',', $messages); + + foreach ($messages as $idx => $part) { + $items = explode(':', $part); + $max = max($items[0], $items[1]); + + for ($x=$items[0]; $x<=$max; $x++) { + $result[] = (int)$x; + } + unset($messages[$idx]); + } + + return $result; + } + + private function _xor($string, $string2) + { + $result = ''; + $size = strlen($string); + + for ($i=0; $i<$size; $i++) { + $result .= chr(ord($string[$i]) ^ ord($string2[$i])); + } + + return $result; + } + + /** + * Converts flags array into string for inclusion in IMAP command + * + * @param array $flags Flags (see self::flags) + * + * @return string Space-separated list of flags + */ + private function flagsToStr($flags) + { + foreach ((array)$flags as $idx => $flag) { + if ($flag = $this->flags[strtoupper($flag)]) { + $flags[$idx] = $flag; + } + } + + return implode(' ', (array)$flags); + } + + /** + * Converts datetime string into unix timestamp + * + * @param string $date Date string + * + * @return int Unix timestamp + */ + static function strToTime($date) + { + // support non-standard "GMTXXXX" literal + $date = preg_replace('/GMT\s*([+-][0-9]+)/', '\\1', $date); + + // if date parsing fails, we have a date in non-rfc format + // remove token from the end and try again + while (($ts = intval(@strtotime($date))) <= 0) { + $d = explode(' ', $date); + array_pop($d); + if (empty($d)) { + break; + } + $date = implode(' ', $d); + } + + return $ts < 0 ? 0 : $ts; + } + + /** + * CAPABILITY response parser + */ + private function parseCapability($str, $trusted=false) + { + $str = preg_replace('/^\* CAPABILITY /i', '', $str); + + $this->capability = explode(' ', strtoupper($str)); + + if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) { + $this->prefs['literal+'] = true; + } + + if ($trusted) { + $this->capability_readed = true; + } + } + + /** + * Escapes a string when it contains special characters (RFC3501) + * + * @param string $string IMAP string + * @param boolean $force_quotes Forces string quoting (for atoms) + * + * @return string String atom, quoted-string or string literal + * @todo lists + */ + static function escape($string, $force_quotes=false) + { + if ($string === null) { + return 'NIL'; + } + + if ($string === '') { + return '""'; + } + + // atom-string (only safe characters) + if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) { + return $string; + } + + // quoted-string + if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) { + return '"' . addcslashes($string, '\\"') . '"'; + } + + // literal-string + return sprintf("{%d}\r\n%s", strlen($string), $string); + } + + /** + * Set the value of the debugging flag. + * + * @param boolean $debug New value for the debugging flag. + * + * @since 0.5-stable + */ + function setDebug($debug, $handler = null) + { + $this->_debug = $debug; + $this->_debug_handler = $handler; + } + + /** + * Write the given debug text to the current debug output handler. + * + * @param string $message Debug mesage text. + * + * @since 0.5-stable + */ + private function debug($message) + { + if ($this->resourceid) { + $message = sprintf('[%s] %s', $this->resourceid, $message); + } + + if ($this->_debug_handler) { + call_user_func_array($this->_debug_handler, array(&$this, $message)); + } else { + echo "DEBUG: $message\n"; + } + } + +} diff --git a/program/lib/Roundcube/rcube_ldap.php b/program/lib/Roundcube/rcube_ldap.php new file mode 100644 index 000000000..c9a14d863 --- /dev/null +++ b/program/lib/Roundcube/rcube_ldap.php @@ -0,0 +1,2352 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_ldap.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2006-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Interface to an LDAP address directory | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Andreas Dick <andudi (at) gmx (dot) ch> | + | Aleksander Machniak <machniak@kolabsys.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Model class to access an LDAP address directory + * + * @package Framework + * @subpackage Addressbook + */ +class rcube_ldap extends rcube_addressbook +{ + /** public properties */ + public $primary_key = 'ID'; + public $groups = false; + public $readonly = true; + public $ready = false; + public $group_id = 0; + public $coltypes = array(); + + /** private properties */ + protected $conn; + protected $prop = array(); + protected $fieldmap = array(); + protected $sub_filter; + protected $filter = ''; + protected $result = null; + protected $ldap_result = null; + protected $mail_domain = ''; + protected $debug = false; + + private $base_dn = ''; + private $groups_base_dn = ''; + private $group_url = null; + private $cache; + + private $vlv_active = false; + private $vlv_count = 0; + + + /** + * Object constructor + * + * @param array $p LDAP connection properties + * @param boolean $debug Enables debug mode + * @param string $mail_domain Current user mail domain name + */ + function __construct($p, $debug = false, $mail_domain = null) + { + $this->prop = $p; + + if (isset($p['searchonly'])) + $this->searchonly = $p['searchonly']; + + // check if groups are configured + if (is_array($p['groups']) && count($p['groups'])) { + $this->groups = true; + // set member field + if (!empty($p['groups']['member_attr'])) + $this->prop['member_attr'] = strtolower($p['groups']['member_attr']); + else if (empty($p['member_attr'])) + $this->prop['member_attr'] = 'member'; + // set default name attribute to cn + if (empty($this->prop['groups']['name_attr'])) + $this->prop['groups']['name_attr'] = 'cn'; + if (empty($this->prop['groups']['scope'])) + $this->prop['groups']['scope'] = 'sub'; + } + + // fieldmap property is given + if (is_array($p['fieldmap'])) { + foreach ($p['fieldmap'] as $rf => $lf) + $this->fieldmap[$rf] = $this->_attr_name(strtolower($lf)); + } + else if (!empty($p)) { + // read deprecated *_field properties to remain backwards compatible + foreach ($p as $prop => $value) + if (preg_match('/^(.+)_field$/', $prop, $matches)) + $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value)); + } + + // use fieldmap to advertise supported coltypes to the application + foreach ($this->fieldmap as $colv => $lfv) { + list($col, $type) = explode(':', $colv); + list($lf, $limit, $delim) = explode(':', $lfv); + + if ($limit == '*') $limit = null; + else $limit = max(1, intval($limit)); + + if (!is_array($this->coltypes[$col])) { + $subtypes = $type ? array($type) : null; + $this->coltypes[$col] = array('limit' => $limit, 'subtypes' => $subtypes, 'attributes' => array($lf)); + } + elseif ($type) { + $this->coltypes[$col]['subtypes'][] = $type; + $this->coltypes[$col]['attributes'][] = $lf; + $this->coltypes[$col]['limit'] += $limit; + } + + if ($delim) + $this->coltypes[$col]['serialized'][$type] = $delim; + + $this->fieldmap[$colv] = $lf; + } + + // support for composite address + if ($this->coltypes['street'] && $this->coltypes['locality']) { + $this->coltypes['address'] = array( + 'limit' => max(1, $this->coltypes['locality']['limit'] + $this->coltypes['address']['limit']), + 'subtypes' => array_merge((array)$this->coltypes['address']['subtypes'], (array)$this->coltypes['locality']['subtypes']), + 'childs' => array(), + ) + (array)$this->coltypes['address']; + + foreach (array('street','locality','zipcode','region','country') as $childcol) { + if ($this->coltypes[$childcol]) { + $this->coltypes['address']['childs'][$childcol] = array('type' => 'text'); + unset($this->coltypes[$childcol]); // remove address child col from global coltypes list + } + } + + // at least one address type must be specified + if (empty($this->coltypes['address']['subtypes'])) { + $this->coltypes['address']['subtypes'] = array('home'); + } + } + else if ($this->coltypes['address']) { + $this->coltypes['address'] += array('type' => 'textarea', 'childs' => null, 'size' => 40); + + // 'serialized' means the UI has to present a composite address field + if ($this->coltypes['address']['serialized']) { + $childprop = array('type' => 'text'); + $this->coltypes['address']['type'] = 'composite'; + $this->coltypes['address']['childs'] = array('street' => $childprop, 'locality' => $childprop, 'zipcode' => $childprop, 'country' => $childprop); + } + } + + // make sure 'required_fields' is an array + if (!is_array($this->prop['required_fields'])) { + $this->prop['required_fields'] = (array) $this->prop['required_fields']; + } + + // make sure LDAP_rdn field is required + if (!empty($this->prop['LDAP_rdn']) && !in_array($this->prop['LDAP_rdn'], $this->prop['required_fields']) + && !in_array($this->prop['LDAP_rdn'], array_keys((array)$this->prop['autovalues']))) { + $this->prop['required_fields'][] = $this->prop['LDAP_rdn']; + } + + foreach ($this->prop['required_fields'] as $key => $val) { + $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val)); + } + + // Build sub_fields filter + if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) { + $this->sub_filter = ''; + foreach ($this->prop['sub_fields'] as $attr => $class) { + if (!empty($class)) { + $class = is_array($class) ? array_pop($class) : $class; + $this->sub_filter .= '(objectClass=' . $class . ')'; + } + } + if (count($this->prop['sub_fields']) > 1) { + $this->sub_filter = '(|' . $this->sub_filter . ')'; + } + } + + $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort']; + $this->debug = $debug; + $this->mail_domain = $mail_domain; + + // initialize cache + $rcube = rcube::get_instance(); + $this->cache = $rcube->get_cache('LDAP.' . asciiwords($this->prop['name']), 'db', 600); + + $this->_connect(); + } + + + /** + * Establish a connection to the LDAP server + */ + private function _connect() + { + $rcube = rcube::get_instance(); + + if (!function_exists('ldap_connect')) + rcube::raise_error(array('code' => 100, 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "No ldap support in this installation of PHP"), + true, true); + + if (is_resource($this->conn)) + return true; + + if (!is_array($this->prop['hosts'])) + $this->prop['hosts'] = array($this->prop['hosts']); + + if (empty($this->prop['ldap_version'])) + $this->prop['ldap_version'] = 3; + + foreach ($this->prop['hosts'] as $host) + { + $host = rcube_utils::idn_to_ascii(rcube_utils::parse_host($host)); + $hostname = $host.($this->prop['port'] ? ':'.$this->prop['port'] : ''); + + $this->_debug("C: Connect [$hostname] [{$this->prop['name']}]"); + + if ($lc = @ldap_connect($host, $this->prop['port'])) + { + if ($this->prop['use_tls'] === true) + if (!ldap_start_tls($lc)) + continue; + + $this->_debug("S: OK"); + + ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']); + $this->prop['host'] = $host; + $this->conn = $lc; + + if (isset($this->prop['referrals'])) + ldap_set_option($lc, LDAP_OPT_REFERRALS, $this->prop['referrals']); + break; + } + $this->_debug("S: NOT OK"); + } + + // See if the directory is writeable. + if ($this->prop['writable']) { + $this->readonly = false; + } + + if (!is_resource($this->conn)) { + rcube::raise_error(array('code' => 100, 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Could not connect to any LDAP server, last tried $hostname"), true); + + return false; + } + + $bind_pass = $this->prop['bind_pass']; + $bind_user = $this->prop['bind_user']; + $bind_dn = $this->prop['bind_dn']; + + $this->base_dn = $this->prop['base_dn']; + $this->groups_base_dn = ($this->prop['groups']['base_dn']) ? + $this->prop['groups']['base_dn'] : $this->base_dn; + + // User specific access, generate the proper values to use. + if ($this->prop['user_specific']) { + // No password set, use the session password + if (empty($bind_pass)) { + $bind_pass = $rcube->get_user_password(); + } + + // Get the pieces needed for variable replacement. + if ($fu = $rcube->get_user_email()) + list($u, $d) = explode('@', $fu); + else + $d = $this->mail_domain; + + $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string + + $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u); + + if ($this->prop['search_base_dn'] && $this->prop['search_filter']) { + if (!empty($this->prop['search_bind_dn']) && !empty($this->prop['search_bind_pw'])) { + $this->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']); + } + + // Search for the dn to use to authenticate + $this->prop['search_base_dn'] = strtr($this->prop['search_base_dn'], $replaces); + $this->prop['search_filter'] = strtr($this->prop['search_filter'], $replaces); + + $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}"); + + $res = @ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid')); + if ($res) { + if (($entry = ldap_first_entry($this->conn, $res)) + && ($bind_dn = ldap_get_dn($this->conn, $entry)) + ) { + $this->_debug("S: search returned dn: $bind_dn"); + $dn = ldap_explode_dn($bind_dn, 1); + $replaces['%dn'] = $dn[0]; + } + } + else { + $this->_debug("S: ".ldap_error($this->conn)); + } + + // DN not found + if (empty($replaces['%dn'])) { + if (!empty($this->prop['search_dn_default'])) + $replaces['%dn'] = $this->prop['search_dn_default']; + else { + rcube::raise_error(array( + 'code' => 100, 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "DN not found using LDAP search."), true); + return false; + } + } + } + + // Replace the bind_dn and base_dn variables. + $bind_dn = strtr($bind_dn, $replaces); + $this->base_dn = strtr($this->base_dn, $replaces); + $this->groups_base_dn = strtr($this->groups_base_dn, $replaces); + + if (empty($bind_user)) { + $bind_user = $u; + } + } + + if (empty($bind_pass)) { + $this->ready = true; + } + else { + if (!empty($bind_dn)) { + $this->ready = $this->bind($bind_dn, $bind_pass); + } + else if (!empty($this->prop['auth_cid'])) { + $this->ready = $this->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user); + } + else { + $this->ready = $this->sasl_bind($bind_user, $bind_pass); + } + } + + return $this->ready; + } + + + /** + * Bind connection with (SASL-) user and password + * + * @param string $authc Authentication user + * @param string $pass Bind password + * @param string $authz Autorization user + * + * @return boolean True on success, False on error + */ + public function sasl_bind($authc, $pass, $authz=null) + { + if (!$this->conn) { + return false; + } + + if (!function_exists('ldap_sasl_bind')) { + rcube::raise_error(array('code' => 100, 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Unable to bind: ldap_sasl_bind() not exists"), + true, true); + } + + if (!empty($authz)) { + $authz = 'u:' . $authz; + } + + if (!empty($this->prop['auth_method'])) { + $method = $this->prop['auth_method']; + } + else { + $method = 'DIGEST-MD5'; + } + + $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]"); + + if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) { + $this->_debug("S: OK"); + return true; + } + + $this->_debug("S: ".ldap_error($this->conn)); + + rcube::raise_error(array( + 'code' => ldap_errno($this->conn), 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Bind failed for authcid=$authc ".ldap_error($this->conn)), + true); + + return false; + } + + + /** + * Bind connection with DN and password + * + * @param string Bind DN + * @param string Bind password + * + * @return boolean True on success, False on error + */ + public function bind($dn, $pass) + { + if (!$this->conn) { + return false; + } + + $this->_debug("C: Bind [dn: $dn] [pass: $pass]"); + + if (@ldap_bind($this->conn, $dn, $pass)) { + $this->_debug("S: OK"); + return true; + } + + $this->_debug("S: ".ldap_error($this->conn)); + + rcube::raise_error(array( + 'code' => ldap_errno($this->conn), 'type' => 'ldap', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)), + true); + + return false; + } + + + /** + * Close connection to LDAP server + */ + function close() + { + if ($this->conn) + { + $this->_debug("C: Close"); + ldap_unbind($this->conn); + $this->conn = null; + } + } + + + /** + * Returns address book name + * + * @return string Address book name + */ + function get_name() + { + return $this->prop['name']; + } + + + /** + * Set internal sort settings + * + * @param string $sort_col Sort column + * @param string $sort_order Sort order + */ + function set_sort_order($sort_col, $sort_order = null) + { + if ($this->coltypes[$sort_col]['attributes']) + $this->sort_col = $this->coltypes[$sort_col]['attributes'][0]; + } + + + /** + * Save a search string for future listings + * + * @param string $filter Filter string + */ + function set_search_set($filter) + { + $this->filter = $filter; + } + + + /** + * Getter for saved search properties + * + * @return mixed Search properties used by this class + */ + function get_search_set() + { + return $this->filter; + } + + + /** + * Reset all saved results and search parameters + */ + function reset() + { + $this->result = null; + $this->ldap_result = null; + $this->filter = ''; + } + + + /** + * List the current set of contact records + * + * @param array List of cols to show + * @param int Only return this number of records + * + * @return array Indexed list of contact records, each a hash array + */ + function list_records($cols=null, $subset=0) + { + if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id) + { + $this->result = new rcube_result_set(0); + $this->result->searchonly = true; + return $this->result; + } + + // fetch group members recursively + if ($this->group_id && $this->group_data['dn']) + { + $entries = $this->list_group_members($this->group_data['dn']); + + // make list of entries unique and sort it + $seen = array(); + foreach ($entries as $i => $rec) { + if ($seen[$rec['dn']]++) + unset($entries[$i]); + } + usort($entries, array($this, '_entry_sort_cmp')); + + $entries['count'] = count($entries); + $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size); + } + else + { + // add general filter to query + if (!empty($this->prop['filter']) && empty($this->filter)) + $this->set_search_set($this->prop['filter']); + + // exec LDAP search if no result resource is stored + if ($this->conn && !$this->ldap_result) + $this->_exec_search(); + + // count contacts for this user + $this->result = $this->count(); + + // we have a search result resource + if ($this->ldap_result && $this->result->count > 0) + { + // sorting still on the ldap server + if ($this->sort_col && $this->prop['scope'] !== 'base' && !$this->vlv_active) + ldap_sort($this->conn, $this->ldap_result, $this->sort_col); + + // get all entries from the ldap server + $entries = ldap_get_entries($this->conn, $this->ldap_result); + } + + } // end else + + // start and end of the page + $start_row = $this->vlv_active ? 0 : $this->result->first; + $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row; + $last_row = $this->result->first + $this->page_size; + $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row; + + // filter entries for this page + for ($i = $start_row; $i < min($entries['count'], $last_row); $i++) + $this->result->add($this->_ldap2result($entries[$i])); + + return $this->result; + } + + /** + * Get all members of the given group + * + * @param string Group DN + * @param array Group entries (if called recursively) + * @return array Accumulated group members + */ + function list_group_members($dn, $count = false, $entries = null) + { + $group_members = array(); + + // fetch group object + if (empty($entries)) { + $result = @ldap_read($this->conn, $dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL')); + if ($result === false) + { + $this->_debug("S: ".ldap_error($this->conn)); + return $group_members; + } + + $entries = @ldap_get_entries($this->conn, $result); + } + + for ($i=0; $i < $entries['count']; $i++) + { + $entry = $entries[$i]; + + if (empty($entry['objectclass'])) + continue; + + foreach ((array)$entry['objectclass'] as $objectclass) + { + switch (strtolower($objectclass)) { + case "group": + case "groupofnames": + case "kolabgroupofnames": + $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'member', $count)); + break; + case "groupofuniquenames": + case "kolabgroupofuniquenames": + $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'uniquemember', $count)); + break; + case "groupofurls": + $group_members = array_merge($group_members, $this->_list_group_memberurl($dn, $entry, $count)); + break; + } + } + + if ($this->prop['sizelimit'] && count($group_members) > $this->prop['sizelimit']) + break; + } + + return array_filter($group_members); + } + + /** + * Fetch members of the given group entry from server + * + * @param string Group DN + * @param array Group entry + * @param string Member attribute to use + * @return array Accumulated group members + */ + private function _list_group_members($dn, $entry, $attr, $count) + { + // Use the member attributes to return an array of member ldap objects + // NOTE that the member attribute is supposed to contain a DN + $group_members = array(); + if (empty($entry[$attr])) + return $group_members; + + // read these attributes for all members + $attrib = $count ? array('dn') : array_values($this->fieldmap); + $attrib[] = 'objectClass'; + $attrib[] = 'member'; + $attrib[] = 'uniqueMember'; + $attrib[] = 'memberURL'; + + for ($i=0; $i < $entry[$attr]['count']; $i++) + { + if (empty($entry[$attr][$i])) + continue; + + $result = @ldap_read($this->conn, $entry[$attr][$i], '(objectclass=*)', + $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']); + + $members = @ldap_get_entries($this->conn, $result); + if ($members == false) + { + $this->_debug("S: ".ldap_error($this->conn)); + $members = array(); + } + + // for nested groups, call recursively + $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members); + + unset($members['count']); + $group_members = array_merge($group_members, array_filter($members), $nested_group_members); + } + + return $group_members; + } + + /** + * List members of group class groupOfUrls + * + * @param string Group DN + * @param array Group entry + * @param boolean True if only used for counting + * @return array Accumulated group members + */ + private function _list_group_memberurl($dn, $entry, $count) + { + $group_members = array(); + + for ($i=0; $i < $entry['memberurl']['count']; $i++) + { + // extract components from url + if (!preg_match('!ldap:///([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m)) + continue; + + // add search filter if any + $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3]; + $func = $m[2] == 'sub' ? 'ldap_search' : ($m[2] == 'base' ? 'ldap_read' : 'ldap_list'); + + $attrib = $count ? array('dn') : array_values($this->fieldmap); + if ($result = @$func($this->conn, $m[1], $filter, + $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']) + ) { + $this->_debug("S: ".ldap_count_entries($this->conn, $result)." record(s) for ".$m[1]); + } + else { + $this->_debug("S: ".ldap_error($this->conn)); + return $group_members; + } + + $entries = @ldap_get_entries($this->conn, $result); + for ($j = 0; $j < $entries['count']; $j++) + { + if ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count)) + $group_members = array_merge($group_members, $nested_group_members); + else + $group_members[] = $entries[$j]; + } + } + + return $group_members; + } + + /** + * Callback for sorting entries + */ + function _entry_sort_cmp($a, $b) + { + return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]); + } + + + /** + * Search contacts + * + * @param mixed $fields The field name of array of field names to search in + * @param mixed $value Search value (or array of values when $fields is array) + * @param int $mode Matching mode: + * 0 - partial (*abc*), + * 1 - strict (=), + * 2 - prefix (abc*) + * @param boolean $select True if results are requested, False if count only + * @param boolean $nocount (Not used) + * @param array $required List of fields that cannot be empty + * + * @return array Indexed list of contact records and 'count' value + */ + function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()) + { + $mode = intval($mode); + + // special treatment for ID-based search + if ($fields == 'ID' || $fields == $this->primary_key) + { + $ids = !is_array($value) ? explode(',', $value) : $value; + $result = new rcube_result_set(); + foreach ($ids as $id) + { + if ($rec = $this->get_record($id, true)) + { + $result->add($rec); + $result->count++; + } + } + return $result; + } + + // use VLV pseudo-search for autocompletion + $rcube = rcube::get_instance(); + $list_fields = $rcube->config->get('contactlist_fields'); + + if ($this->prop['vlv_search'] && $this->conn && join(',', (array)$fields) == join(',', $list_fields)) + { + // add general filter to query + if (!empty($this->prop['filter']) && empty($this->filter)) + $this->set_search_set($this->prop['filter']); + + // set VLV controls with encoded search string + $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size, $value); + + $function = $this->_scope2func($this->prop['scope']); + $this->ldap_result = @$function($this->conn, $this->base_dn, $this->filter ? $this->filter : '(objectclass=*)', + array_values($this->fieldmap), 0, $this->page_size, (int)$this->prop['timelimit']); + + $this->result = new rcube_result_set(0); + + if (!$this->ldap_result) { + $this->_debug("S: ".ldap_error($this->conn)); + return $this->result; + } + + $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)"); + + // get all entries of this page and post-filter those that really match the query + $search = mb_strtolower($value); + $entries = ldap_get_entries($this->conn, $this->ldap_result); + + for ($i = 0; $i < $entries['count']; $i++) { + $rec = $this->_ldap2result($entries[$i]); + foreach ($fields as $f) { + foreach ((array)$rec[$f] as $val) { + $val = mb_strtolower($val); + switch ($mode) { + case 1: + $got = ($val == $search); + break; + case 2: + $got = ($search == substr($val, 0, strlen($search))); + break; + default: + $got = (strpos($val, $search) !== false); + break; + } + + if ($got) { + $this->result->add($rec); + $this->result->count++; + break 2; + } + } + } + } + + return $this->result; + } + + // use AND operator for advanced searches + $filter = is_array($value) ? '(&' : '(|'; + // set wildcards + $wp = $ws = ''; + if (!empty($this->prop['fuzzy_search']) && $mode != 1) { + $ws = '*'; + if (!$mode) { + $wp = '*'; + } + } + + if ($fields == '*') + { + // search_fields are required for fulltext search + if (empty($this->prop['search_fields'])) + { + $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch'); + $this->result = new rcube_result_set(); + return $this->result; + } + if (is_array($this->prop['search_fields'])) + { + foreach ($this->prop['search_fields'] as $field) { + $filter .= "($field=$wp" . $this->_quote_string($value) . "$ws)"; + } + } + } + else + { + foreach ((array)$fields as $idx => $field) { + $val = is_array($value) ? $value[$idx] : $value; + if ($attrs = $this->_map_field($field)) { + if (count($attrs) > 1) + $filter .= '(|'; + foreach ($attrs as $f) + $filter .= "($f=$wp" . $this->_quote_string($val) . "$ws)"; + if (count($attrs) > 1) + $filter .= ')'; + } + } + } + $filter .= ')'; + + // add required (non empty) fields filter + $req_filter = ''; + foreach ((array)$required as $field) { + if ($attrs = $this->_map_field($field)) { + if (count($attrs) > 1) + $req_filter .= '(|'; + foreach ($attrs as $f) + $req_filter .= "($f=*)"; + if (count($attrs) > 1) + $req_filter .= ')'; + } + } + + if (!empty($req_filter)) + $filter = '(&' . $req_filter . $filter . ')'; + + // avoid double-wildcard if $value is empty + $filter = preg_replace('/\*+/', '*', $filter); + + // add general filter to query + if (!empty($this->prop['filter'])) + $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')'; + + // set filter string and execute search + $this->set_search_set($filter); + $this->_exec_search(); + + if ($select) + $this->list_records(); + else + $this->result = $this->count(); + + return $this->result; + } + + + /** + * Count number of available contacts in database + * + * @return object rcube_result_set Resultset with values for 'count' and 'first' + */ + function count() + { + $count = 0; + if ($this->conn && $this->ldap_result) { + $count = $this->vlv_active ? $this->vlv_count : ldap_count_entries($this->conn, $this->ldap_result); + } + else if ($this->group_id && $this->group_data['dn']) { + $count = count($this->list_group_members($this->group_data['dn'], true)); + } + else if ($this->conn) { + // We have a connection but no result set, attempt to get one. + if (empty($this->filter)) { + // The filter is not set, set it. + $this->filter = $this->prop['filter']; + } + + $count = (int) $this->_exec_search(true); + } + + return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); + } + + + /** + * Return the last result set + * + * @return object rcube_result_set Current resultset or NULL if nothing selected yet + */ + function get_result() + { + return $this->result; + } + + + /** + * Get a specific contact record + * + * @param mixed Record identifier + * @param boolean Return as associative array + * + * @return mixed Hash array or rcube_result_set with all record fields + */ + function get_record($dn, $assoc=false) + { + $res = $this->result = null; + + if ($this->conn && $dn) + { + $dn = self::dn_decode($dn); + + $this->_debug("C: Read [dn: $dn] [(objectclass=*)]"); + + if ($ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap))) { + $this->_debug("S: OK"); + + $entry = ldap_first_entry($this->conn, $ldap_result); + + if ($entry && ($rec = ldap_get_attributes($this->conn, $entry))) { + $rec = array_change_key_case($rec, CASE_LOWER); + } + } + else { + $this->_debug("S: ".ldap_error($this->conn)); + } + + // Use ldap_list to get subentries like country (c) attribute (#1488123) + if (!empty($rec) && $this->sub_filter) { + if ($entries = $this->ldap_list($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) { + foreach ($entries as $entry) { + $lrec = array_change_key_case($entry, CASE_LOWER); + $rec = array_merge($lrec, $rec); + } + } + } + + if (!empty($rec)) { + // Add in the dn for the entry. + $rec['dn'] = $dn; + $res = $this->_ldap2result($rec); + $this->result = new rcube_result_set(); + $this->result->add($res); + } + } + + return $assoc ? $res : $this->result; + } + + + /** + * Check the given data before saving. + * If input not valid, the message to display can be fetched using get_error() + * + * @param array Assoziative array with data to save + * @param boolean Try to fix/complete record automatically + * @return boolean True if input is valid, False if not. + */ + public function validate(&$save_data, $autofix = false) + { + // validate e-mail addresses + if (!parent::validate($save_data, $autofix)) { + return false; + } + + // check for name input + if (empty($save_data['name'])) { + $this->set_error(self::ERROR_VALIDATE, 'nonamewarning'); + return false; + } + + // Verify that the required fields are set. + $missing = null; + $ldap_data = $this->_map_data($save_data); + foreach ($this->prop['required_fields'] as $fld) { + if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') { + $missing[$fld] = 1; + } + } + + if ($missing) { + // try to complete record automatically + if ($autofix) { + $sn_field = $this->fieldmap['surname']; + $fn_field = $this->fieldmap['firstname']; + $mail_field = $this->fieldmap['email']; + + // try to extract surname and firstname from displayname + $reverse_map = array_flip($this->fieldmap); + $name_parts = preg_split('/[\s,.]+/', $save_data['name']); + + if ($sn_field && $missing[$sn_field]) { + $save_data['surname'] = array_pop($name_parts); + unset($missing[$sn_field]); + } + + if ($fn_field && $missing[$fn_field]) { + $save_data['firstname'] = array_shift($name_parts); + unset($missing[$fn_field]); + } + + // try to fix missing e-mail, very often on import + // from vCard we have email:other only defined + if ($mail_field && $missing[$mail_field]) { + $emails = $this->get_col_values('email', $save_data, true); + if (!empty($emails) && ($email = array_shift($emails))) { + $save_data['email'] = $email; + unset($missing[$mail_field]); + } + } + } + + // TODO: generate message saying which fields are missing + if (!empty($missing)) { + $this->set_error(self::ERROR_VALIDATE, 'formincomplete'); + return false; + } + } + + return true; + } + + + /** + * Create a new contact record + * + * @param array Hash array with save data + * + * @return encoded record ID on success, False on error + */ + function insert($save_cols) + { + // Map out the column names to their LDAP ones to build the new entry. + $newentry = $this->_map_data($save_cols); + $newentry['objectClass'] = $this->prop['LDAP_Object_Classes']; + + // add automatically generated attributes + $this->add_autovalues($newentry); + + // Verify that the required fields are set. + $missing = null; + foreach ($this->prop['required_fields'] as $fld) { + if (!isset($newentry[$fld])) { + $missing[] = $fld; + } + } + + // abort process if requiered fields are missing + // TODO: generate message saying which fields are missing + if ($missing) { + $this->set_error(self::ERROR_VALIDATE, 'formincomplete'); + return false; + } + + // Build the new entries DN. + $dn = $this->prop['LDAP_rdn'].'='.$this->_quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn; + + // Remove attributes that need to be added separately (child objects) + $xfields = array(); + if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) { + foreach ($this->prop['sub_fields'] as $xf => $xclass) { + if (!empty($newentry[$xf])) { + $xfields[$xf] = $newentry[$xf]; + unset($newentry[$xf]); + } + } + } + + if (!$this->ldap_add($dn, $newentry)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + foreach ($xfields as $xidx => $xf) { + $xdn = $xidx.'='.$this->_quote_string($xf).','.$dn; + $xf = array( + $xidx => $xf, + 'objectClass' => (array) $this->prop['sub_fields'][$xidx], + ); + + $this->ldap_add($xdn, $xf); + } + + $dn = self::dn_encode($dn); + + // add new contact to the selected group + if ($this->group_id) + $this->add_to_group($this->group_id, $dn); + + return $dn; + } + + + /** + * Update a specific contact record + * + * @param mixed Record identifier + * @param array Hash array with save data + * + * @return boolean True on success, False on error + */ + function update($id, $save_cols) + { + $record = $this->get_record($id, true); + + $newdata = array(); + $replacedata = array(); + $deletedata = array(); + $subdata = array(); + $subdeldata = array(); + $subnewdata = array(); + + $ldap_data = $this->_map_data($save_cols); + $old_data = $record['_raw_attrib']; + + // special handling of photo col + if ($photo_fld = $this->fieldmap['photo']) { + // undefined means keep old photo + if (!array_key_exists('photo', $save_cols)) { + $ldap_data[$photo_fld] = $record['photo']; + } + } + + foreach ($this->fieldmap as $col => $fld) { + if ($fld) { + $val = $ldap_data[$fld]; + $old = $old_data[$fld]; + // remove empty array values + if (is_array($val)) + $val = array_filter($val); + // $this->_map_data() result and _raw_attrib use different format + // make sure comparing array with one element with a string works as expected + if (is_array($old) && count($old) == 1 && !is_array($val)) { + $old = array_pop($old); + } + if (is_array($val) && count($val) == 1 && !is_array($old)) { + $val = array_pop($val); + } + // Subentries must be handled separately + if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) { + if ($old != $val) { + if ($old !== null) { + $subdeldata[$fld] = $old; + } + if ($val) { + $subnewdata[$fld] = $val; + } + } + else if ($old !== null) { + $subdata[$fld] = $old; + } + continue; + } + + // The field does exist compare it to the ldap record. + if ($old != $val) { + // Changed, but find out how. + if ($old === null) { + // Field was not set prior, need to add it. + $newdata[$fld] = $val; + } + else if ($val == '') { + // Field supplied is empty, verify that it is not required. + if (!in_array($fld, $this->prop['required_fields'])) { + // ...It is not, safe to clear. + // #1488420: Workaround "ldap_mod_del(): Modify: Inappropriate matching in..." + // jpegPhoto attribute require an array() here. It looks to me that it works for other attribs too + $deletedata[$fld] = array(); + //$deletedata[$fld] = $old_data[$fld]; + } + } + else { + // The data was modified, save it out. + $replacedata[$fld] = $val; + } + } // end if + } // end if + } // end foreach + + // console($old_data, $ldap_data, '----', $newdata, $replacedata, $deletedata, '----', $subdata, $subnewdata, $subdeldata); + + $dn = self::dn_decode($id); + + // Update the entry as required. + if (!empty($deletedata)) { + // Delete the fields. + if (!$this->ldap_mod_del($dn, $deletedata)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + } // end if + + if (!empty($replacedata)) { + // Handle RDN change + if ($replacedata[$this->prop['LDAP_rdn']]) { + $newdn = $this->prop['LDAP_rdn'].'=' + .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true) + .','.$this->base_dn; + if ($dn != $newdn) { + $newrdn = $this->prop['LDAP_rdn'].'=' + .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true); + unset($replacedata[$this->prop['LDAP_rdn']]); + } + } + // Replace the fields. + if (!empty($replacedata)) { + if (!$this->ldap_mod_replace($dn, $replacedata)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + } + } // end if + + // RDN change, we need to remove all sub-entries + if (!empty($newrdn)) { + $subdeldata = array_merge($subdeldata, $subdata); + $subnewdata = array_merge($subnewdata, $subdata); + } + + // remove sub-entries + if (!empty($subdeldata)) { + foreach ($subdeldata as $fld => $val) { + $subdn = $fld.'='.$this->_quote_string($val).','.$dn; + if (!$this->ldap_delete($subdn)) { + return false; + } + } + } + + if (!empty($newdata)) { + // Add the fields. + if (!$this->ldap_mod_add($dn, $newdata)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + } // end if + + // Handle RDN change + if (!empty($newrdn)) { + if (!$this->ldap_rename($dn, $newrdn, null, true)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + $dn = self::dn_encode($dn); + $newdn = self::dn_encode($newdn); + + // change the group membership of the contact + if ($this->groups) { + $group_ids = $this->get_record_groups($dn); + foreach ($group_ids as $group_id) + { + $this->remove_from_group($group_id, $dn); + $this->add_to_group($group_id, $newdn); + } + } + + $dn = self::dn_decode($newdn); + } + + // add sub-entries + if (!empty($subnewdata)) { + foreach ($subnewdata as $fld => $val) { + $subdn = $fld.'='.$this->_quote_string($val).','.$dn; + $xf = array( + $fld => $val, + 'objectClass' => (array) $this->prop['sub_fields'][$fld], + ); + $this->ldap_add($subdn, $xf); + } + } + + return $newdn ? $newdn : true; + } + + + /** + * Mark one or more contact records as deleted + * + * @param array Record identifiers + * @param boolean Remove record(s) irreversible (unsupported) + * + * @return boolean True on success, False on error + */ + function delete($ids, $force=true) + { + if (!is_array($ids)) { + // Not an array, break apart the encoded DNs. + $ids = explode(',', $ids); + } // end if + + foreach ($ids as $id) { + $dn = self::dn_decode($id); + + // Need to delete all sub-entries first + if ($this->sub_filter) { + if ($entries = $this->ldap_list($dn, $this->sub_filter)) { + foreach ($entries as $entry) { + if (!$this->ldap_delete($entry['dn'])) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + } + } + } + + // Delete the record. + if (!$this->ldap_delete($dn)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + // remove contact from all groups where he was member + if ($this->groups) { + $dn = self::dn_encode($dn); + $group_ids = $this->get_record_groups($dn); + foreach ($group_ids as $group_id) { + $this->remove_from_group($group_id, $dn); + } + } + } // end foreach + + return count($ids); + } + + + /** + * Remove all contact records + */ + function delete_all() + { + //searching for contact entries + $dn_list = $this->ldap_list($this->base_dn, $this->prop['filter'] ? $this->prop['filter'] : '(objectclass=*)'); + + if (!empty($dn_list)) { + foreach ($dn_list as $idx => $entry) { + $dn_list[$idx] = self::dn_encode($entry['dn']); + } + $this->delete($dn_list); + } + } + + /** + * Generate missing attributes as configured + * + * @param array LDAP record attributes + */ + protected function add_autovalues(&$attrs) + { + $attrvals = array(); + foreach ($attrs as $k => $v) { + $attrvals['{'.$k.'}'] = is_array($v) ? $v[0] : $v; + } + + foreach ((array)$this->prop['autovalues'] as $lf => $templ) { + if (empty($attrs[$lf])) { + // replace {attr} placeholders with concrete attribute values + $templ = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals)); + + if (strpos($templ, '(') !== false) + $attrs[$lf] = eval("return ($templ);"); + else + $attrs[$lf] = $templ; + } + } + } + + /** + * Execute the LDAP search based on the stored credentials + */ + private function _exec_search($count = false) + { + if ($this->ready) + { + $filter = $this->filter ? $this->filter : '(objectclass=*)'; + $function = $this->_scope2func($this->prop['scope'], $ns_function); + + $this->_debug("C: Search [$filter][dn: $this->base_dn]"); + + // when using VLV, we get the total count by... + if (!$count && $function != 'ldap_read' && $this->prop['vlv'] && !$this->group_id) { + // ...either reading numSubOrdinates attribute + if ($this->prop['numsub_filter'] && ($result_count = @$ns_function($this->conn, $this->base_dn, $this->prop['numsub_filter'], array('numSubOrdinates'), 0, 0, 0))) { + $counts = ldap_get_entries($this->conn, $result_count); + for ($this->vlv_count = $j = 0; $j < $counts['count']; $j++) + $this->vlv_count += $counts[$j]['numsubordinates'][0]; + $this->_debug("D: total numsubordinates = " . $this->vlv_count); + } + else if (!function_exists('ldap_parse_virtuallist_control')) // ...or by fetching all records dn and count them + $this->vlv_count = $this->_exec_search(true); + + $this->vlv_active = $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size); + } + + // only fetch dn for count (should keep the payload low) + $attrs = $count ? array('dn') : array_values($this->fieldmap); + if ($this->ldap_result = @$function($this->conn, $this->base_dn, $filter, + $attrs, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']) + ) { + // when running on a patched PHP we can use the extended functions to retrieve the total count from the LDAP search result + if ($this->vlv_active && function_exists('ldap_parse_virtuallist_control')) { + if (ldap_parse_result($this->conn, $this->ldap_result, + $errcode, $matcheddn, $errmsg, $referrals, $serverctrls) + ) { + ldap_parse_virtuallist_control($this->conn, $serverctrls, + $last_offset, $this->vlv_count, $vresult); + $this->_debug("S: VLV result: last_offset=$last_offset; content_count=$this->vlv_count"); + } + else { + $this->_debug("S: ".($errmsg ? $errmsg : ldap_error($this->conn))); + } + } + + $entries_count = ldap_count_entries($this->conn, $this->ldap_result); + $this->_debug("S: $entries_count record(s)"); + + return $count ? $entries_count : true; + } + else { + $this->_debug("S: ".ldap_error($this->conn)); + } + } + + return false; + } + + /** + * Choose the right PHP function according to scope property + */ + private function _scope2func($scope, &$ns_function = null) + { + switch ($scope) { + case 'sub': + $function = $ns_function = 'ldap_search'; + break; + case 'base': + $function = $ns_function = 'ldap_read'; + break; + default: + $function = 'ldap_list'; + $ns_function = 'ldap_read'; + break; + } + + return $function; + } + + /** + * Set server controls for Virtual List View (paginated listing) + */ + private function _vlv_set_controls($prop, $list_page, $page_size, $search = null) + { + $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473", 'value' => $this->_sort_ber_encode((array)$prop['sort'])); + $vlv_ctrl = array('oid' => "2.16.840.1.113730.3.4.9", 'value' => $this->_vlv_ber_encode(($offset = ($list_page-1) * $page_size + 1), $page_size, $search), 'iscritical' => true); + + $sort = (array)$prop['sort']; + $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ($sort[0]);" + . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset/$page_size)"); + + if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) { + $this->_debug("S: ".ldap_error($this->conn)); + $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported'); + return false; + } + + return true; + } + + + /** + * Converts LDAP entry into an array + */ + private function _ldap2result($rec) + { + $out = array(); + + if ($rec['dn']) + $out[$this->primary_key] = self::dn_encode($rec['dn']); + + foreach ($this->fieldmap as $rf => $lf) + { + for ($i=0; $i < $rec[$lf]['count']; $i++) { + if (!($value = $rec[$lf][$i])) + continue; + + list($col, $subtype) = explode(':', $rf); + $out['_raw_attrib'][$lf][$i] = $value; + + if ($col == 'email' && $this->mail_domain && !strpos($value, '@')) + $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain); + else if (in_array($col, array('street','zipcode','locality','country','region'))) + $out['address'.($subtype?':':'').$subtype][$i][$col] = $value; + else if ($col == 'address' && strpos($value, '$') !== false) // address data is represented as string separated with $ + list($out[$rf][$i]['street'], $out[$rf][$i]['locality'], $out[$rf][$i]['zipcode'], $out[$rf][$i]['country']) = explode('$', $value); + else if ($rec[$lf]['count'] > 1) + $out[$rf][] = $value; + else + $out[$rf] = $value; + } + + // Make sure name fields aren't arrays (#1488108) + if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) { + $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0]; + } + } + + return $out; + } + + + /** + * Return LDAP attribute(s) for the given field + */ + private function _map_field($field) + { + return (array)$this->coltypes[$field]['attributes']; + } + + + /** + * Convert a record data set into LDAP field attributes + */ + private function _map_data($save_cols) + { + // flatten composite fields first + foreach ($this->coltypes as $col => $colprop) { + if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) { + foreach ($values as $subtype => $childs) { + $subtype = $subtype ? ':'.$subtype : ''; + foreach ($childs as $i => $child_values) { + foreach ((array)$child_values as $childcol => $value) { + $save_cols[$childcol.$subtype][$i] = $value; + } + } + } + } + + // if addresses are to be saved as serialized string, do so + if (is_array($colprop['serialized'])) { + foreach ($colprop['serialized'] as $subtype => $delim) { + $key = $col.':'.$subtype; + foreach ((array)$save_cols[$key] as $i => $val) + $save_cols[$key][$i] = join($delim, array($val['street'], $val['locality'], $val['zipcode'], $val['country'])); + } + } + } + + $ldap_data = array(); + foreach ($this->fieldmap as $rf => $fld) { + $val = $save_cols[$rf]; + + // check for value in base field (eg.g email instead of email:foo) + list($col, $subtype) = explode(':', $rf); + if (!$val && !empty($save_cols[$col])) { + $val = $save_cols[$col]; + unset($save_cols[$col]); // only use this value once + } + else if (!$val && !$subtype) { // extract values from subtype cols + $val = $this->get_col_values($col, $save_cols, true); + } + + if (is_array($val)) + $val = array_filter($val); // remove empty entries + if ($fld && $val) { + // The field does exist, add it to the entry. + $ldap_data[$fld] = $val; + } + } + + return $ldap_data; + } + + + /** + * Returns unified attribute name (resolving aliases) + */ + private static function _attr_name($namev) + { + // list of known attribute aliases + static $aliases = array( + 'gn' => 'givenname', + 'rfc822mailbox' => 'email', + 'userid' => 'uid', + 'emailaddress' => 'email', + 'pkcs9email' => 'email', + ); + + list($name, $limit) = explode(':', $namev, 2); + $suffix = $limit ? ':'.$limit : ''; + + return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix; + } + + + /** + * Prints debug info to the log + */ + private function _debug($str) + { + if ($this->debug) { + rcube::write_log('ldap', $str); + } + } + + + /** + * Activate/deactivate debug mode + * + * @param boolean $dbg True if LDAP commands should be logged + * @access public + */ + function set_debug($dbg = true) + { + $this->debug = $dbg; + } + + + /** + * Quotes attribute value string + * + * @param string $str Attribute value + * @param bool $dn True if the attribute is a DN + * + * @return string Quoted string + */ + private static function _quote_string($str, $dn=false) + { + // take firt entry if array given + if (is_array($str)) + $str = reset($str); + + if ($dn) + $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c', + '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23'); + else + $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c', + '/'=>'\2f'); + + return strtr($str, $replace); + } + + + /** + * Setter for the current group + * (empty, has to be re-implemented by extending class) + */ + function set_group($group_id) + { + if ($group_id) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $this->group_id = $group_id; + $this->group_data = $group_cache[$group_id]; + } + else + { + $this->group_id = 0; + $this->group_data = null; + } + } + + /** + * List all active contact groups of this source + * + * @param string Optional search string to match group name + * @return array Indexed list of contact groups, each a hash array + */ + function list_groups($search = null) + { + if (!$this->groups) + return array(); + + // use cached list for searching + $this->cache->expunge(); + if (!$search || ($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $groups = array(); + if ($search) { + $search = mb_strtolower($search); + foreach ($group_cache as $group) { + if (strpos(mb_strtolower($group['name']), $search) !== false) + $groups[] = $group; + } + } + else + $groups = $group_cache; + + return array_values($groups); + } + + /** + * Fetch groups from server + */ + private function _fetch_groups($vlv_page = 0) + { + $base_dn = $this->groups_base_dn; + $filter = $this->prop['groups']['filter']; + $name_attr = $this->prop['groups']['name_attr']; + $email_attr = $this->prop['groups']['email_attr'] ? $this->prop['groups']['email_attr'] : 'mail'; + $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr); + $sort_attr = $sort_attrs[0]; + + $this->_debug("C: Search [$filter][dn: $base_dn]"); + + // use vlv to list groups + if ($this->prop['groups']['vlv']) { + $page_size = 200; + if (!$this->prop['groups']['sort']) + $this->prop['groups']['sort'] = $sort_attrs; + $vlv_active = $this->_vlv_set_controls($this->prop['groups'], $vlv_page+1, $page_size); + } + + $function = $this->_scope2func($this->prop['groups']['scope'], $ns_function); + $res = @$function($this->conn, $base_dn, $filter, array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr))); + if ($res === false) + { + $this->_debug("S: ".ldap_error($this->conn)); + return array(); + } + + $ldap_data = ldap_get_entries($this->conn, $res); + $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)"); + + $groups = array(); + $group_sortnames = array(); + $group_count = $ldap_data["count"]; + for ($i=0; $i < $group_count; $i++) + { + $group_name = is_array($ldap_data[$i][$name_attr]) ? $ldap_data[$i][$name_attr][0] : $ldap_data[$i][$name_attr]; + $group_id = self::dn_encode($group_name); + $groups[$group_id]['ID'] = $group_id; + $groups[$group_id]['dn'] = $ldap_data[$i]['dn']; + $groups[$group_id]['name'] = $group_name; + $groups[$group_id]['member_attr'] = $this->get_group_member_attr($ldap_data[$i]['objectclass']); + + // list email attributes of a group + for ($j=0; $ldap_data[$i][$email_attr] && $j < $ldap_data[$i][$email_attr]['count']; $j++) { + if (strpos($ldap_data[$i][$email_attr][$j], '@') > 0) + $groups[$group_id]['email'][] = $ldap_data[$i][$email_attr][$j]; + } + + $group_sortnames[] = mb_strtolower($ldap_data[$i][$sort_attr][0]); + } + + // recursive call can exit here + if ($vlv_page > 0) + return $groups; + + // call recursively until we have fetched all groups + while ($vlv_active && $group_count == $page_size) + { + $next_page = $this->_fetch_groups(++$vlv_page); + $groups = array_merge($groups, $next_page); + $group_count = count($next_page); + } + + // when using VLV the list of groups is already sorted + if (!$this->prop['groups']['vlv']) + array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups); + + // cache this + $this->cache->set('groups', $groups); + + return $groups; + } + + /** + * Get group properties such as name and email address(es) + * + * @param string Group identifier + * @return array Group properties as hash array + */ + function get_group($group_id) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $group_data = $group_cache[$group_id]; + unset($group_data['dn'], $group_data['member_attr']); + + return $group_data; + } + + /** + * Create a contact group with the given name + * + * @param string The group name + * @return mixed False on error, array with record props in success + */ + function create_group($group_name) + { + $base_dn = $this->groups_base_dn; + $new_dn = "cn=$group_name,$base_dn"; + $new_gid = self::dn_encode($group_name); + $member_attr = $this->get_group_member_attr(); + $name_attr = $this->prop['groups']['name_attr'] ? $this->prop['groups']['name_attr'] : 'cn'; + + $new_entry = array( + 'objectClass' => $this->prop['groups']['object_classes'], + $name_attr => $group_name, + $member_attr => '', + ); + + if (!$this->ldap_add($new_dn, $new_entry)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + $this->cache->remove('groups'); + + return array('id' => $new_gid, 'name' => $group_name); + } + + /** + * Delete the given group and all linked group members + * + * @param string Group identifier + * @return boolean True on success, false if no data was changed + */ + function delete_group($group_id) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $base_dn = $this->groups_base_dn; + $group_name = $group_cache[$group_id]['name']; + $del_dn = "cn=$group_name,$base_dn"; + + if (!$this->ldap_delete($del_dn)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + $this->cache->remove('groups'); + + return true; + } + + /** + * Rename a specific contact group + * + * @param string Group identifier + * @param string New name to set for this group + * @param string New group identifier (if changed, otherwise don't set) + * @return boolean New name on success, false if no data was changed + */ + function rename_group($group_id, $new_name, &$new_gid) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $base_dn = $this->groups_base_dn; + $group_name = $group_cache[$group_id]['name']; + $old_dn = "cn=$group_name,$base_dn"; + $new_rdn = "cn=$new_name"; + $new_gid = self::dn_encode($new_name); + + if (!$this->ldap_rename($old_dn, $new_rdn, null, true)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return false; + } + + $this->cache->remove('groups'); + + return $new_name; + } + + /** + * Add the given contact records the a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be added + * @return int Number of contacts added + */ + function add_to_group($group_id, $contact_ids) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + if (!is_array($contact_ids)) + $contact_ids = explode(',', $contact_ids); + + $base_dn = $this->groups_base_dn; + $group_name = $group_cache[$group_id]['name']; + $member_attr = $group_cache[$group_id]['member_attr']; + $group_dn = "cn=$group_name,$base_dn"; + + $new_attrs = array(); + foreach ($contact_ids as $id) + $new_attrs[$member_attr][] = self::dn_decode($id); + + if (!$this->ldap_mod_add($group_dn, $new_attrs)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return 0; + } + + $this->cache->remove('groups'); + + return count($new_attrs['member']); + } + + /** + * Remove the given contact records from a certain group + * + * @param string Group identifier + * @param array List of contact identifiers to be removed + * @return int Number of deleted group members + */ + function remove_from_group($group_id, $contact_ids) + { + if (($group_cache = $this->cache->get('groups')) === null) + $group_cache = $this->_fetch_groups(); + + $base_dn = $this->groups_base_dn; + $group_name = $group_cache[$group_id]['name']; + $member_attr = $group_cache[$group_id]['member_attr']; + $group_dn = "cn=$group_name,$base_dn"; + + $del_attrs = array(); + foreach (explode(",", $contact_ids) as $id) + $del_attrs[$member_attr][] = self::dn_decode($id); + + if (!$this->ldap_mod_del($group_dn, $del_attrs)) { + $this->set_error(self::ERROR_SAVING, 'errorsaving'); + return 0; + } + + $this->cache->remove('groups'); + + return count($del_attrs['member']); + } + + /** + * Get group assignments of a specific contact record + * + * @param mixed Record identifier + * + * @return array List of assigned groups as ID=>Name pairs + * @since 0.5-beta + */ + function get_record_groups($contact_id) + { + if (!$this->groups) + return array(); + + $base_dn = $this->groups_base_dn; + $contact_dn = self::dn_decode($contact_id); + $name_attr = $this->prop['groups']['name_attr'] ? $this->prop['groups']['name_attr'] : 'cn'; + $member_attr = $this->get_group_member_attr(); + $add_filter = ''; + if ($member_attr != 'member' && $member_attr != 'uniqueMember') + $add_filter = "($member_attr=$contact_dn)"; + $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\')); + + $this->_debug("C: Search [$filter][dn: $base_dn]"); + + $res = @ldap_search($this->conn, $base_dn, $filter, array($name_attr)); + if ($res === false) + { + $this->_debug("S: ".ldap_error($this->conn)); + return array(); + } + $ldap_data = ldap_get_entries($this->conn, $res); + $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)"); + + $groups = array(); + for ($i=0; $i<$ldap_data["count"]; $i++) + { + $group_name = $ldap_data[$i][$name_attr][0]; + $group_id = self::dn_encode($group_name); + $groups[$group_id] = $group_id; + } + return $groups; + } + + /** + * Detects group member attribute name + */ + private function get_group_member_attr($object_classes = array()) + { + if (empty($object_classes)) { + $object_classes = $this->prop['groups']['object_classes']; + } + if (!empty($object_classes)) { + foreach ((array)$object_classes as $oc) { + switch (strtolower($oc)) { + case 'group': + case 'groupofnames': + case 'kolabgroupofnames': + $member_attr = 'member'; + break; + + case 'groupofuniquenames': + case 'kolabgroupofuniquenames': + $member_attr = 'uniqueMember'; + break; + } + } + } + + if (!empty($member_attr)) { + return $member_attr; + } + + if (!empty($this->prop['groups']['member_attr'])) { + return $this->prop['groups']['member_attr']; + } + + return 'member'; + } + + + /** + * Generate BER encoded string for Virtual List View option + * + * @param integer List offset (first record) + * @param integer Records per page + * @return string BER encoded option value + */ + private function _vlv_ber_encode($offset, $rpp, $search = '') + { + # this string is ber-encoded, php will prefix this value with: + # 04 (octet string) and 10 (length of 16 bytes) + # the code behind this string is broken down as follows: + # 30 = ber sequence with a length of 0e (14) bytes following + # 02 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0) + # 02 = type integer (in two's complement form) with 2 bytes following (afterCount): 01 18 (ie 25-1=24) + # a0 = type context-specific/constructed with a length of 06 (6) bytes following + # 02 = type integer with 2 bytes following (offset): 01 01 (ie 1) + # 02 = type integer with 2 bytes following (contentCount): 01 00 + + # whith a search string present: + # 81 = type context-specific/constructed with a length of 04 (4) bytes following (the length will change here) + # 81 indicates a user string is present where as a a0 indicates just a offset search + # 81 = type context-specific/constructed with a length of 06 (6) bytes following + + # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the + # encoding of integer values (note: these values are in + # two-complement form so since offset will never be negative bit 8 of the + # leftmost octet should never by set to 1): + # 8.3.2: If the contents octets of an integer value encoding consist + # of more than one octet, then the bits of the first octet (rightmost) and bit 8 + # of the second (to the left of first octet) octet: + # a) shall not all be ones; and + # b) shall not all be zero + + if ($search) + { + $search = preg_replace('/[^-[:alpha:] ,.()0-9]+/', '', $search); + $ber_val = self::_string2hex($search); + $str = self::_ber_addseq($ber_val, '81'); + } + else + { + # construct the string from right to left + $str = "020100"; # contentCount + + $ber_val = self::_ber_encode_int($offset); // returns encoded integer value in hex format + + // calculate octet length of $ber_val + $str = self::_ber_addseq($ber_val, '02') . $str; + + // now compute length over $str + $str = self::_ber_addseq($str, 'a0'); + } + + // now tack on records per page + $str = "020100" . self::_ber_addseq(self::_ber_encode_int($rpp-1), '02') . $str; + + // now tack on sequence identifier and length + $str = self::_ber_addseq($str, '30'); + + return pack('H'.strlen($str), $str); + } + + + /** + * create ber encoding for sort control + * + * @param array List of cols to sort by + * @return string BER encoded option value + */ + private function _sort_ber_encode($sortcols) + { + $str = ''; + foreach (array_reverse((array)$sortcols) as $col) { + $ber_val = self::_string2hex($col); + + # 30 = ber sequence with a length of octet value + # 04 = octet string with a length of the ascii value + $oct = self::_ber_addseq($ber_val, '04'); + $str = self::_ber_addseq($oct, '30') . $str; + } + + // now tack on sequence identifier and length + $str = self::_ber_addseq($str, '30'); + + return pack('H'.strlen($str), $str); + } + + /** + * Add BER sequence with correct length and the given identifier + */ + private static function _ber_addseq($str, $identifier) + { + $len = dechex(strlen($str)/2); + if (strlen($len) % 2 != 0) + $len = '0'.$len; + + return $identifier . $len . $str; + } + + /** + * Returns BER encoded integer value in hex format + */ + private static function _ber_encode_int($offset) + { + $val = dechex($offset); + $prefix = ''; + + // check if bit 8 of high byte is 1 + if (preg_match('/^[89abcdef]/', $val)) + $prefix = '00'; + + if (strlen($val)%2 != 0) + $prefix .= '0'; + + return $prefix . $val; + } + + /** + * Returns ascii string encoded in hex + */ + private static function _string2hex($str) + { + $hex = ''; + for ($i=0; $i < strlen($str); $i++) + $hex .= dechex(ord($str[$i])); + return $hex; + } + + /** + * HTML-safe DN string encoding + * + * @param string $str DN string + * + * @return string Encoded HTML identifier string + */ + static function dn_encode($str) + { + // @TODO: to make output string shorter we could probably + // remove dc=* items from it + return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); + } + + /** + * Decodes DN string encoded with _dn_encode() + * + * @param string $str Encoded HTML identifier string + * + * @return string DN string + */ + static function dn_decode($str) + { + $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT); + return base64_decode($str); + } + + /** + * Wrapper for ldap_add() + */ + protected function ldap_add($dn, $entry) + { + $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true)); + + $res = ldap_add($this->conn, $dn, $entry); + if ($res === false) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_delete() + */ + protected function ldap_delete($dn) + { + $this->_debug("C: Delete [dn: $dn]"); + + $res = ldap_delete($this->conn, $dn); + if ($res === false) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_mod_replace() + */ + protected function ldap_mod_replace($dn, $entry) + { + $this->_debug("C: Replace [dn: $dn]: ".print_r($entry, true)); + + if (!ldap_mod_replace($this->conn, $dn, $entry)) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_mod_add() + */ + protected function ldap_mod_add($dn, $entry) + { + $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true)); + + if (!ldap_mod_add($this->conn, $dn, $entry)) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_mod_del() + */ + protected function ldap_mod_del($dn, $entry) + { + $this->_debug("C: Delete [dn: $dn]: ".print_r($entry, true)); + + if (!ldap_mod_del($this->conn, $dn, $entry)) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_rename() + */ + protected function ldap_rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true) + { + $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]"); + + if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) { + $this->_debug("S: ".ldap_error($this->conn)); + return false; + } + + $this->_debug("S: OK"); + return true; + } + + /** + * Wrapper for ldap_list() + */ + protected function ldap_list($dn, $filter, $attrs = array('')) + { + $list = array(); + $this->_debug("C: List [dn: $dn] [{$filter}]"); + + if ($result = ldap_list($this->conn, $dn, $filter, $attrs)) { + $list = ldap_get_entries($this->conn, $result); + + if ($list === false) { + $this->_debug("S: ".ldap_error($this->conn)); + return array(); + } + + $count = $list['count']; + unset($list['count']); + + $this->_debug("S: $count record(s)"); + } + else { + $this->_debug("S: ".ldap_error($this->conn)); + } + + return $list; + } + +} diff --git a/program/lib/Roundcube/rcube_message.php b/program/lib/Roundcube/rcube_message.php new file mode 100644 index 000000000..4ef534a0a --- /dev/null +++ b/program/lib/Roundcube/rcube_message.php @@ -0,0 +1,759 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_message.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2010, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Logical representation of a mail message with all its data | + | and related functions | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Logical representation of a mail message with all its data + * and related functions + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + */ +class rcube_message +{ + /** + * Instace of framework class. + * + * @var rcube + */ + private $app; + + /** + * Instance of storage class + * + * @var rcube_storage + */ + private $storage; + + /** + * Instance of mime class + * + * @var rcube_mime + */ + private $mime; + private $opt = array(); + private $parse_alternative = false; + + public $uid; + public $folder; + public $headers; + public $parts = array(); + public $mime_parts = array(); + public $inline_parts = array(); + public $attachments = array(); + public $subject = ''; + public $sender = null; + public $is_safe = false; + + + /** + * __construct + * + * Provide a uid, and parse message structure. + * + * @param string $uid The message UID. + * @param string $folder Folder name + * + * @see self::$app, self::$storage, self::$opt, self::$parts + */ + function __construct($uid, $folder = null) + { + $this->uid = $uid; + $this->app = rcube::get_instance(); + $this->storage = $this->app->get_storage(); + $this->folder = strlen($folder) ? $folder : $this->storage->get_folder(); + $this->storage->set_options(array('all_headers' => true)); + + // Set current folder + $this->storage->set_folder($this->folder); + + $this->headers = $this->storage->get_message($uid); + + if (!$this->headers) + return; + + $this->mime = new rcube_mime($this->headers->charset); + + $this->subject = $this->mime->decode_mime_string($this->headers->subject); + list(, $this->sender) = each($this->mime->decode_address_list($this->headers->from, 1)); + + $this->set_safe((intval($_GET['_safe']) || $_SESSION['safe_messages'][$uid])); + $this->opt = array( + 'safe' => $this->is_safe, + 'prefer_html' => $this->app->config->get('prefer_html'), + 'get_url' => $this->app->url(array( + 'action' => 'get', + 'mbox' => $this->storage->get_folder(), + 'uid' => $uid)) + ); + + if (!empty($this->headers->structure)) { + $this->get_mime_numbers($this->headers->structure); + $this->parse_structure($this->headers->structure); + } + else { + $this->body = $this->storage->get_body($uid); + } + + // notify plugins and let them analyze this structured message object + $this->app->plugins->exec_hook('message_load', array('object' => $this)); + } + + + /** + * Return a (decoded) message header + * + * @param string $name Header name + * @param bool $row Don't mime-decode the value + * @return string Header value + */ + public function get_header($name, $raw = false) + { + if (empty($this->headers)) + return null; + + if ($this->headers->$name) + $value = $this->headers->$name; + else if ($this->headers->others[$name]) + $value = $this->headers->others[$name]; + + return $raw ? $value : $this->mime->decode_header($value); + } + + + /** + * Set is_safe var and session data + * + * @param bool $safe enable/disable + */ + public function set_safe($safe = true) + { + $this->is_safe = $safe; + $_SESSION['safe_messages'][$this->uid] = $this->is_safe; + } + + + /** + * Compose a valid URL for getting a message part + * + * @param string $mime_id Part MIME-ID + * @return string URL or false if part does not exist + */ + public function get_part_url($mime_id, $embed = false) + { + if ($this->mime_parts[$mime_id]) + return $this->opt['get_url'] . '&_part=' . $mime_id . ($embed ? '&_embed=1' : ''); + else + return false; + } + + + /** + * Get content of a specific part of this message + * + * @param string $mime_id Part MIME-ID + * @param resource $fp File pointer to save the message part + * @param boolean $skip_charset_conv Disables charset conversion + * @param int $max_bytes Only read this number of bytes + * + * @return string Part content + */ + public function get_part_content($mime_id, $fp = null, $skip_charset_conv = false, $max_bytes = 0) + { + if ($part = $this->mime_parts[$mime_id]) { + // stored in message structure (winmail/inline-uuencode) + if (!empty($part->body) || $part->encoding == 'stream') { + if ($fp) { + fwrite($fp, $part->body); + } + return $fp ? true : $part->body; + } + + // get from IMAP + $this->storage->set_folder($this->folder); + + return $this->storage->get_message_part($this->uid, $mime_id, $part, NULL, $fp, $skip_charset_conv, $max_bytes); + } + } + + + /** + * Determine if the message contains a HTML part + * + * @param bool $recursive Enables checking in all levels of the structure + * @param bool $enriched Enables checking for text/enriched parts too + * + * @return bool True if a HTML is available, False if not + */ + function has_html_part($recursive = true, $enriched = false) + { + // check all message parts + foreach ($this->parts as $part) { + if ($part->mimetype == 'text/html' || ($enriched && $part->mimetype == 'text/enriched')) { + // Level check, we'll skip e.g. HTML attachments + if (!$recursive) { + $level = explode('.', $part->mime_id); + + // Skip if level too deep or part has a file name + if (count($level) > 2 || $part->filename) { + continue; + } + + // HTML part can be on the lower level, if not... + if (count($level) > 1) { + array_pop($level); + $parent = $this->mime_parts[join('.', $level)]; + // ... parent isn't multipart/alternative or related + if ($parent->mimetype != 'multipart/alternative' && $parent->mimetype != 'multipart/related') { + continue; + } + } + } + + return true; + } + } + + return false; + } + + + /** + * Return the first HTML part of this message + * + * @return string HTML message part content + */ + function first_html_part() + { + // check all message parts + foreach ($this->mime_parts as $pid => $part) { + if ($part->mimetype == 'text/html') { + return $this->get_part_content($pid); + } + } + } + + + /** + * Return the first text part of this message + * + * @param rcube_message_part $part Reference to the part if found + * @return string Plain text message/part content + */ + function first_text_part(&$part=null) + { + // no message structure, return complete body + if (empty($this->parts)) + return $this->body; + + // check all message parts + foreach ($this->mime_parts as $mime_id => $part) { + if ($part->mimetype == 'text/plain') { + return $this->get_part_content($mime_id); + } + else if ($part->mimetype == 'text/html') { + $out = $this->get_part_content($mime_id); + + // create instance of html2text class + $txt = new html2text($out); + return $txt->get_text(); + } + } + + $part = null; + return null; + } + + + /** + * Checks if part of the message is an attachment (or part of it) + * + * @param rcube_message_part $part Message part + * + * @return bool True if the part is an attachment part + */ + public function is_attachment($part) + { + foreach ($this->attachments as $att_part) { + if ($att_part->mime_id == $part->mime_id) { + return true; + } + + // check if the part is a subpart of another attachment part (message/rfc822) + if ($att_part->mimetype == 'message/rfc822') { + if (in_array($part, (array)$att_part->parts)) { + return true; + } + } + } + + return false; + } + + + /** + * Read the message structure returend by the IMAP server + * and build flat lists of content parts and attachments + * + * @param rcube_message_part $structure Message structure node + * @param bool $recursive True when called recursively + */ + private function parse_structure($structure, $recursive = false) + { + // real content-type of message/rfc822 part + if ($structure->mimetype == 'message/rfc822' && $structure->real_mimetype) + $mimetype = $structure->real_mimetype; + else + $mimetype = $structure->mimetype; + + // show message headers + if ($recursive && is_array($structure->headers) && isset($structure->headers['subject'])) { + $c = new stdClass; + $c->type = 'headers'; + $c->headers = &$structure->headers; + $this->parts[] = $c; + } + + // Allow plugins to handle message parts + $plugin = $this->app->plugins->exec_hook('message_part_structure', + array('object' => $this, 'structure' => $structure, + 'mimetype' => $mimetype, 'recursive' => $recursive)); + + if ($plugin['abort']) + return; + + $structure = $plugin['structure']; + list($message_ctype_primary, $message_ctype_secondary) = explode('/', $plugin['mimetype']); + + // print body if message doesn't have multiple parts + if ($message_ctype_primary == 'text' && !$recursive) { + $structure->type = 'content'; + $this->parts[] = &$structure; + + // Parse simple (plain text) message body + if ($message_ctype_secondary == 'plain') + foreach ((array)$this->uu_decode($structure) as $uupart) { + $this->mime_parts[$uupart->mime_id] = $uupart; + $this->attachments[] = $uupart; + } + } + // the same for pgp signed messages + else if ($mimetype == 'application/pgp' && !$recursive) { + $structure->type = 'content'; + $this->parts[] = &$structure; + } + // message contains (more than one!) alternative parts + else if ($mimetype == 'multipart/alternative' + && is_array($structure->parts) && count($structure->parts) > 1 + ) { + // get html/plaintext parts + $plain_part = $html_part = $print_part = $related_part = null; + + foreach ($structure->parts as $p => $sub_part) { + $sub_mimetype = $sub_part->mimetype; + + // skip empty text parts + if (!$sub_part->size && preg_match('#^text/(plain|html|enriched)$#', $sub_mimetype)) { + continue; + } + + // check if sub part is + if ($sub_mimetype == 'text/plain') + $plain_part = $p; + else if ($sub_mimetype == 'text/html') + $html_part = $p; + else if ($sub_mimetype == 'text/enriched') + $enriched_part = $p; + else if (in_array($sub_mimetype, array('multipart/related', 'multipart/mixed', 'multipart/alternative'))) + $related_part = $p; + } + + // parse related part (alternative part could be in here) + if ($related_part !== null && !$this->parse_alternative) { + $this->parse_alternative = true; + $this->parse_structure($structure->parts[$related_part], true); + $this->parse_alternative = false; + + // if plain part was found, we should unset it if html is preferred + if ($this->opt['prefer_html'] && count($this->parts)) + $plain_part = null; + } + + // choose html/plain part to print + if ($html_part !== null && $this->opt['prefer_html']) { + $print_part = &$structure->parts[$html_part]; + } + else if ($enriched_part !== null) { + $print_part = &$structure->parts[$enriched_part]; + } + else if ($plain_part !== null) { + $print_part = &$structure->parts[$plain_part]; + } + + // add the right message body + if (is_object($print_part)) { + $print_part->type = 'content'; + $this->parts[] = $print_part; + } + // show plaintext warning + else if ($html_part !== null && empty($this->parts)) { + $c = new stdClass; + $c->type = 'content'; + $c->ctype_primary = 'text'; + $c->ctype_secondary = 'plain'; + $c->mimetype = 'text/plain'; + $c->realtype = 'text/html'; + + $this->parts[] = $c; + } + + // add html part as attachment + if ($html_part !== null && $structure->parts[$html_part] !== $print_part) { + $html_part = &$structure->parts[$html_part]; + $html_part->mimetype = 'text/html'; + + $this->attachments[] = $html_part; + } + } + // this is an ecrypted message -> create a plaintext body with the according message + else if ($mimetype == 'multipart/encrypted') { + $p = new stdClass; + $p->type = 'content'; + $p->ctype_primary = 'text'; + $p->ctype_secondary = 'plain'; + $p->mimetype = 'text/plain'; + $p->realtype = 'multipart/encrypted'; + + $this->parts[] = $p; + } + // message contains multiple parts + else if (is_array($structure->parts) && !empty($structure->parts)) { + // iterate over parts + for ($i=0; $i < count($structure->parts); $i++) { + $mail_part = &$structure->parts[$i]; + $primary_type = $mail_part->ctype_primary; + $secondary_type = $mail_part->ctype_secondary; + + // real content-type of message/rfc822 + if ($mail_part->real_mimetype) { + $part_orig_mimetype = $mail_part->mimetype; + $part_mimetype = $mail_part->real_mimetype; + list($primary_type, $secondary_type) = explode('/', $part_mimetype); + } + else + $part_mimetype = $mail_part->mimetype; + + // multipart/alternative + if ($primary_type == 'multipart') { + $this->parse_structure($mail_part, true); + + // list message/rfc822 as attachment as well (mostly .eml) + if ($part_orig_mimetype == 'message/rfc822' && !empty($mail_part->filename)) + $this->attachments[] = $mail_part; + } + // part text/[plain|html] or delivery status + else if ((($part_mimetype == 'text/plain' || $part_mimetype == 'text/html') && $mail_part->disposition != 'attachment') || + in_array($part_mimetype, array('message/delivery-status', 'text/rfc822-headers', 'message/disposition-notification')) + ) { + // Allow plugins to handle also this part + $plugin = $this->app->plugins->exec_hook('message_part_structure', + array('object' => $this, 'structure' => $mail_part, + 'mimetype' => $part_mimetype, 'recursive' => true)); + + if ($plugin['abort']) + continue; + + if ($part_mimetype == 'text/html' && $mail_part->size) { + $got_html_part = true; + } + + $mail_part = $plugin['structure']; + list($primary_type, $secondary_type) = explode('/', $plugin['mimetype']); + + // add text part if it matches the prefs + if (!$this->parse_alternative || + ($secondary_type == 'html' && $this->opt['prefer_html']) || + ($secondary_type == 'plain' && !$this->opt['prefer_html']) + ) { + $mail_part->type = 'content'; + $this->parts[] = $mail_part; + } + + // list as attachment as well + if (!empty($mail_part->filename)) { + $this->attachments[] = $mail_part; + } + // list html part as attachment (here the part is most likely inside a multipart/related part) + else if ($this->parse_alternative && ($secondary_type == 'html' && !$this->opt['prefer_html'])) { + $this->attachments[] = $mail_part; + } + } + // part message/* + else if ($primary_type == 'message') { + $this->parse_structure($mail_part, true); + + // list as attachment as well (mostly .eml) + if (!empty($mail_part->filename)) + $this->attachments[] = $mail_part; + } + // ignore "virtual" protocol parts + else if ($primary_type == 'protocol') { + continue; + } + // part is Microsoft Outlook TNEF (winmail.dat) + else if ($part_mimetype == 'application/ms-tnef') { + foreach ((array)$this->tnef_decode($mail_part) as $tpart) { + $this->mime_parts[$tpart->mime_id] = $tpart; + $this->attachments[] = $tpart; + } + } + // part is a file/attachment + else if (preg_match('/^(inline|attach)/', $mail_part->disposition) || + $mail_part->headers['content-id'] || + ($mail_part->filename && + (empty($mail_part->disposition) || preg_match('/^[a-z0-9!#$&.+^_-]+$/i', $mail_part->disposition))) + ) { + // skip apple resource forks + if ($message_ctype_secondary == 'appledouble' && $secondary_type == 'applefile') + continue; + + // part belongs to a related message and is linked + if ($mimetype == 'multipart/related' + && ($mail_part->headers['content-id'] || $mail_part->headers['content-location'])) { + if ($mail_part->headers['content-id']) + $mail_part->content_id = preg_replace(array('/^</', '/>$/'), '', $mail_part->headers['content-id']); + if ($mail_part->headers['content-location']) + $mail_part->content_location = $mail_part->headers['content-base'] . $mail_part->headers['content-location']; + + $this->inline_parts[] = $mail_part; + } + // attachment encapsulated within message/rfc822 part needs further decoding (#1486743) + else if ($part_orig_mimetype == 'message/rfc822') { + $this->parse_structure($mail_part, true); + + // list as attachment as well (mostly .eml) + if (!empty($mail_part->filename)) + $this->attachments[] = $mail_part; + } + // regular attachment with valid content type + // (content-type name regexp according to RFC4288.4.2) + else if (preg_match('/^[a-z0-9!#$&.+^_-]+\/[a-z0-9!#$&.+^_-]+$/i', $part_mimetype)) { + if (!$mail_part->filename) + $mail_part->filename = 'Part '.$mail_part->mime_id; + + $this->attachments[] = $mail_part; + } + // attachment with invalid content type + // replace malformed content type with application/octet-stream (#1487767) + else if ($mail_part->filename) { + $mail_part->ctype_primary = 'application'; + $mail_part->ctype_secondary = 'octet-stream'; + $mail_part->mimetype = 'application/octet-stream'; + + $this->attachments[] = $mail_part; + } + } + // attachment part as message/rfc822 (#1488026) + else if ($mail_part->mimetype == 'message/rfc822') { + $this->parse_structure($mail_part); + } + } + + // if this was a related part try to resolve references + if ($mimetype == 'multipart/related' && sizeof($this->inline_parts)) { + $a_replaces = array(); + $img_regexp = '/^image\/(gif|jpe?g|png|tiff|bmp|svg)/'; + + foreach ($this->inline_parts as $inline_object) { + $part_url = $this->get_part_url($inline_object->mime_id, true); + if ($inline_object->content_id) + $a_replaces['cid:'.$inline_object->content_id] = $part_url; + if ($inline_object->content_location) { + $a_replaces[$inline_object->content_location] = $part_url; + } + + if (!empty($inline_object->filename)) { + // MS Outlook sends sometimes non-related attachments as related + // In this case multipart/related message has only one text part + // We'll add all such attachments to the attachments list + if (!isset($got_html_part) && empty($inline_object->content_id)) { + $this->attachments[] = $inline_object; + } + // MS Outlook sometimes also adds non-image attachments as related + // We'll add all such attachments to the attachments list + // Warning: some browsers support pdf in <img/> + else if (!preg_match($img_regexp, $inline_object->mimetype)) { + $this->attachments[] = $inline_object; + } + // @TODO: we should fetch HTML body and find attachment's content-id + // to handle also image attachments without reference in the body + // @TODO: should we list all image attachments in text mode? + } + } + + // add replace array to each content part + // (will be applied later when part body is available) + foreach ($this->parts as $i => $part) { + if ($part->type == 'content') + $this->parts[$i]->replaces = $a_replaces; + } + } + } + // message is a single part non-text + else if ($structure->filename) { + $this->attachments[] = $structure; + } + // message is a single part non-text (without filename) + else if (preg_match('/application\//i', $mimetype)) { + $structure->filename = 'Part '.$structure->mime_id; + $this->attachments[] = $structure; + } + } + + + /** + * Fill aflat array with references to all parts, indexed by part numbers + * + * @param rcube_message_part $part Message body structure + */ + private function get_mime_numbers(&$part) + { + if (strlen($part->mime_id)) + $this->mime_parts[$part->mime_id] = &$part; + + if (is_array($part->parts)) + for ($i=0; $i<count($part->parts); $i++) + $this->get_mime_numbers($part->parts[$i]); + } + + + /** + * Decode a Microsoft Outlook TNEF part (winmail.dat) + * + * @param rcube_message_part $part Message part to decode + * @return array + */ + function tnef_decode(&$part) + { + // @TODO: attachment may be huge, hadle it via file + if (!isset($part->body)) { + $this->storage->set_folder($this->folder); + $part->body = $this->storage->get_message_part($this->uid, $part->mime_id, $part); + } + + $parts = array(); + $tnef = new tnef_decoder; + $tnef_arr = $tnef->decompress($part->body); + + foreach ($tnef_arr as $pid => $winatt) { + $tpart = new rcube_message_part; + + $tpart->filename = trim($winatt['name']); + $tpart->encoding = 'stream'; + $tpart->ctype_primary = trim(strtolower($winatt['type'])); + $tpart->ctype_secondary = trim(strtolower($winatt['subtype'])); + $tpart->mimetype = $tpart->ctype_primary . '/' . $tpart->ctype_secondary; + $tpart->mime_id = 'winmail.' . $part->mime_id . '.' . $pid; + $tpart->size = $winatt['size']; + $tpart->body = $winatt['stream']; + + $parts[] = $tpart; + unset($tnef_arr[$pid]); + } + + return $parts; + } + + + /** + * Parse message body for UUencoded attachments bodies + * + * @param rcube_message_part $part Message part to decode + * @return array + */ + function uu_decode(&$part) + { + // @TODO: messages may be huge, hadle body via file + if (!isset($part->body)) { + $this->storage->set_folder($this->folder); + $part->body = $this->storage->get_message_part($this->uid, $part->mime_id, $part); + } + + $parts = array(); + // FIXME: line length is max.65? + $uu_regexp = '/begin [0-7]{3,4} ([^\n]+)\n/s'; + + if (preg_match_all($uu_regexp, $part->body, $matches, PREG_SET_ORDER)) { + // update message content-type + $part->ctype_primary = 'multipart'; + $part->ctype_secondary = 'mixed'; + $part->mimetype = $part->ctype_primary . '/' . $part->ctype_secondary; + $uu_endstring = "`\nend\n"; + + // add attachments to the structure + foreach ($matches as $pid => $att) { + $startpos = strpos($part->body, $att[1]) + strlen($att[1]) + 1; // "\n" + $endpos = strpos($part->body, $uu_endstring); + $filebody = substr($part->body, $startpos, $endpos-$startpos); + + // remove attachments bodies from the message body + $part->body = substr_replace($part->body, "", $startpos, $endpos+strlen($uu_endstring)-$startpos); + + $uupart = new rcube_message_part; + + $uupart->filename = trim($att[1]); + $uupart->encoding = 'stream'; + $uupart->body = convert_uudecode($filebody); + $uupart->size = strlen($uupart->body); + $uupart->mime_id = 'uu.' . $part->mime_id . '.' . $pid; + + $ctype = rcube_mime::content_type($uupart->body, $uupart->filename, 'application/octet-stream', true); + $uupart->mimetype = $ctype; + list($uupart->ctype_primary, $uupart->ctype_secondary) = explode('/', $ctype); + + $parts[] = $uupart; + unset($matches[$pid]); + } + + // remove attachments bodies from the message body + $part->body = preg_replace($uu_regexp, '', $part->body); + } + + return $parts; + } + + + /** + * Deprecated methods (to be removed) + */ + + public static function unfold_flowed($text) + { + return rcube_mime::unfold_flowed($text); + } + + public static function format_flowed($text, $length = 72) + { + return rcube_mime::format_flowed($text, $length); + } + +} diff --git a/program/lib/Roundcube/rcube_message_header.php b/program/lib/Roundcube/rcube_message_header.php new file mode 100644 index 000000000..445d0bd39 --- /dev/null +++ b/program/lib/Roundcube/rcube_message_header.php @@ -0,0 +1,289 @@ +<?php + +/** + +-----------------------------------------------------------------------+ + | program/include/rcube_message_header.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | E-mail message headers representation | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Struct representing an e-mail message header + * + * @package Framework + * @subpackage Storage + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_message_header +{ + /** + * Message sequence number + * + * @var int + */ + public $id; + + /** + * Message unique identifier + * + * @var int + */ + public $uid; + + /** + * Message subject + * + * @var string + */ + public $subject; + + /** + * Message sender (From) + * + * @var string + */ + public $from; + + /** + * Message recipient (To) + * + * @var string + */ + public $to; + + /** + * Message additional recipients (Cc) + * + * @var string + */ + public $cc; + + /** + * Message Reply-To header + * + * @var string + */ + public $replyto; + + /** + * Message In-Reply-To header + * + * @var string + */ + public $in_reply_to; + + /** + * Message date (Date) + * + * @var string + */ + public $date; + + /** + * Message identifier (Message-ID) + * + * @var string + */ + public $messageID; + + /** + * Message size + * + * @var int + */ + public $size; + + /** + * Message encoding + * + * @var string + */ + public $encoding; + + /** + * Message charset + * + * @var string + */ + public $charset; + + /** + * Message Content-type + * + * @var string + */ + public $ctype; + + /** + * Message timestamp (based on message date) + * + * @var int + */ + public $timestamp; + + /** + * IMAP bodystructure string + * + * @var string + */ + public $bodystructure; + + /** + * IMAP internal date + * + * @var string + */ + public $internaldate; + + /** + * Message References header + * + * @var string + */ + public $references; + + /** + * Message priority (X-Priority) + * + * @var int + */ + public $priority; + + /** + * Message receipt recipient + * + * @var string + */ + public $mdn_to; + + /** + * Other message headers + * + * @var array + */ + public $others = array(); + + /** + * Message flags + * + * @var array + */ + public $flags = array(); + + // map header to rcube_message_header object property + private $obj_headers = array( + 'date' => 'date', + 'from' => 'from', + 'to' => 'to', + 'subject' => 'subject', + 'reply-to' => 'replyto', + 'cc' => 'cc', + 'bcc' => 'bcc', + 'content-transfer-encoding' => 'encoding', + 'in-reply-to' => 'in_reply_to', + 'content-type' => 'ctype', + 'charset' => 'charset', + 'references' => 'references', + 'return-receipt-to' => 'mdn_to', + 'disposition-notification-to' => 'mdn_to', + 'x-confirm-reading-to' => 'mdn_to', + 'message-id' => 'messageID', + 'x-priority' => 'priority', + ); + + /** + * Returns header value + */ + public function get($name, $decode = true) + { + $name = strtolower($name); + + if (isset($this->obj_headers[$name])) { + $value = $this->{$this->obj_headers[$name]}; + } + else { + $value = $this->others[$name]; + } + + return $decode ? rcube_mime::decode_header($value, $this->charset) : $value; + } + + /** + * Sets header value + */ + public function set($name, $value) + { + $name = strtolower($name); + + if (isset($this->obj_headers[$name])) { + $this->{$this->obj_headers[$name]} = $value; + } + else { + $this->others[$name] = $value; + } + } +} + + +/** + * Class for sorting an array of rcube_message_header objects in a predetermined order. + * + * @package Mail + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_message_header_sorter +{ + private $uids = array(); + + + /** + * Set the predetermined sort order. + * + * @param array $index Numerically indexed array of IMAP UIDs + */ + function set_index($index) + { + $index = array_flip($index); + + $this->uids = $index; + } + + /** + * Sort the array of header objects + * + * @param array $headers Array of rcube_message_header objects indexed by UID + */ + function sort_headers(&$headers) + { + uksort($headers, array($this, "compare_uids")); + } + + /** + * Sort method called by uksort() + * + * @param int $a Array key (UID) + * @param int $b Array key (UID) + */ + function compare_uids($a, $b) + { + // then find each sequence number in my ordered list + $posa = isset($this->uids[$a]) ? intval($this->uids[$a]) : -1; + $posb = isset($this->uids[$b]) ? intval($this->uids[$b]) : -1; + + // return the relative position as the comparison value + return $posa - $posb; + } +} diff --git a/program/lib/Roundcube/rcube_message_part.php b/program/lib/Roundcube/rcube_message_part.php new file mode 100644 index 000000000..c9c9257eb --- /dev/null +++ b/program/lib/Roundcube/rcube_message_part.php @@ -0,0 +1,100 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_message_part.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Class representing a message part | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class representing a message part + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_message_part +{ + /** + * Part MIME identifier + * + * @var string + */ + public $mime_id = ''; + + /** + * Content main type + * + * @var string + */ + public $ctype_primary = 'text'; + + /** + * Content subtype + * + * @var string + */ + public $ctype_secondary = 'plain'; + + /** + * Complete content type + * + * @var string + */ + public $mimetype = 'text/plain'; + + public $disposition = ''; + public $filename = ''; + public $encoding = '8bit'; + public $charset = ''; + + /** + * Part size in bytes + * + * @var int + */ + public $size = 0; + + /** + * Part headers + * + * @var array + */ + public $headers = array(); + + public $d_parameters = array(); + public $ctype_parameters = array(); + + + /** + * Clone handler. + */ + function __clone() + { + if (isset($this->parts)) { + foreach ($this->parts as $idx => $part) { + if (is_object($part)) { + $this->parts[$idx] = clone $part; + } + } + } + } + +} diff --git a/program/lib/Roundcube/rcube_mime.php b/program/lib/Roundcube/rcube_mime.php new file mode 100644 index 000000000..17cb3f015 --- /dev/null +++ b/program/lib/Roundcube/rcube_mime.php @@ -0,0 +1,780 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_mime.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | MIME message parsing utilities | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class for parsing MIME messages + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_mime +{ + private static $default_charset; + + + /** + * Object constructor. + */ + function __construct($default_charset = null) + { + self::$default_charset = $default_charset; + } + + + /** + * Returns message/object character set name + * + * @return string Characted set name + */ + public static function get_charset() + { + if (self::$default_charset) { + return self::$default_charset; + } + + if ($charset = rcube::get_instance()->config->get('default_charset')) { + return $charset; + } + + return RCUBE_CHARSET; + } + + + /** + * Parse the given raw message source and return a structure + * of rcube_message_part objects. + * + * It makes use of the PEAR:Mail_mimeDecode library + * + * @param string The message source + * @return object rcube_message_part The message structure + */ + public static function parse_message($raw_body) + { + $mime = new Mail_mimeDecode($raw_body); + $struct = $mime->decode(array('include_bodies' => true, 'decode_bodies' => true)); + return self::structure_part($struct); + } + + + /** + * Recursive method to convert a Mail_mimeDecode part into a rcube_message_part object + * + * @param object A message part struct + * @param int Part count + * @param string Parent MIME ID + * + * @return object rcube_message_part + */ + private static function structure_part($part, $count=0, $parent='') + { + $struct = new rcube_message_part; + $struct->mime_id = $part->mime_id ? $part->mime_id : (empty($parent) ? (string)$count : "$parent.$count"); + $struct->headers = $part->headers; + $struct->ctype_primary = $part->ctype_primary; + $struct->ctype_secondary = $part->ctype_secondary; + $struct->mimetype = $part->ctype_primary . '/' . $part->ctype_secondary; + $struct->ctype_parameters = $part->ctype_parameters; + + if ($part->headers['content-transfer-encoding']) + $struct->encoding = $part->headers['content-transfer-encoding']; + if ($part->ctype_parameters['charset']) + $struct->charset = $part->ctype_parameters['charset']; + + $part_charset = $struct->charset ? $struct->charset : self::get_charset(); + + // determine filename + if (($filename = $part->d_parameters['filename']) || ($filename = $part->ctype_parameters['name'])) { + $struct->filename = rcube_mime::decode_mime_string($filename, $part_charset); + } + + // copy part body and convert it to UTF-8 if necessary + $struct->body = $part->ctype_primary == 'text' || !$part->ctype_parameters['charset'] ? rcube_charset::convert($part->body, $part_charset) : $part->body; + $struct->size = strlen($part->body); + $struct->disposition = $part->disposition; + + foreach ((array)$part->parts as $child_part) { + $struct->parts[] = self::structure_part($child_part, ++$count, $struct->mime_id); + } + + return $struct; + } + + + /** + * Split an address list into a structured array list + * + * @param string $input Input string + * @param int $max List only this number of addresses + * @param boolean $decode Decode address strings + * @param string $fallback Fallback charset if none specified + * + * @return array Indexed list of addresses + */ + static function decode_address_list($input, $max = null, $decode = true, $fallback = null) + { + $a = self::parse_address_list($input, $decode, $fallback); + $out = array(); + $j = 0; + + // Special chars as defined by RFC 822 need to in quoted string (or escaped). + $special_chars = '[\(\)\<\>\\\.\[\]@,;:"]'; + + if (!is_array($a)) + return $out; + + foreach ($a as $val) { + $j++; + $address = trim($val['address']); + $name = trim($val['name']); + + if ($name && $address && $name != $address) + $string = sprintf('%s <%s>', preg_match("/$special_chars/", $name) ? '"'.addcslashes($name, '"').'"' : $name, $address); + else if ($address) + $string = $address; + else if ($name) + $string = $name; + + $out[$j] = array( + 'name' => $name, + 'mailto' => $address, + 'string' => $string + ); + + if ($max && $j==$max) + break; + } + + return $out; + } + + + /** + * Decode a message header value + * + * @param string $input Header value + * @param string $fallback Fallback charset if none specified + * + * @return string Decoded string + */ + public static function decode_header($input, $fallback = null) + { + $str = self::decode_mime_string((string)$input, $fallback); + + return $str; + } + + + /** + * Decode a mime-encoded string to internal charset + * + * @param string $input Header value + * @param string $fallback Fallback charset if none specified + * + * @return string Decoded string + */ + public static function decode_mime_string($input, $fallback = null) + { + $default_charset = !empty($fallback) ? $fallback : self::get_charset(); + + // rfc: all line breaks or other characters not found + // in the Base64 Alphabet must be ignored by decoding software + // delete all blanks between MIME-lines, differently we can + // receive unnecessary blanks and broken utf-8 symbols + $input = preg_replace("/\?=\s+=\?/", '?==?', $input); + + // encoded-word regexp + $re = '/=\?([^?]+)\?([BbQq])\?([^\n]*?)\?=/'; + + // Find all RFC2047's encoded words + if (preg_match_all($re, $input, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + // Initialize variables + $tmp = array(); + $out = ''; + $start = 0; + + foreach ($matches as $idx => $m) { + $pos = $m[0][1]; + $charset = $m[1][0]; + $encoding = $m[2][0]; + $text = $m[3][0]; + $length = strlen($m[0][0]); + + // Append everything that is before the text to be decoded + if ($start != $pos) { + $substr = substr($input, $start, $pos-$start); + $out .= rcube_charset::convert($substr, $default_charset); + $start = $pos; + } + $start += $length; + + // Per RFC2047, each string part "MUST represent an integral number + // of characters . A multi-octet character may not be split across + // adjacent encoded-words." However, some mailers break this, so we + // try to handle characters spanned across parts anyway by iterating + // through and aggregating sequential encoded parts with the same + // character set and encoding, then perform the decoding on the + // aggregation as a whole. + + $tmp[] = $text; + if ($next_match = $matches[$idx+1]) { + if ($next_match[0][1] == $start + && $next_match[1][0] == $charset + && $next_match[2][0] == $encoding + ) { + continue; + } + } + + $count = count($tmp); + $text = ''; + + // Decode and join encoded-word's chunks + if ($encoding == 'B' || $encoding == 'b') { + // base64 must be decoded a segment at a time + for ($i=0; $i<$count; $i++) + $text .= base64_decode($tmp[$i]); + } + else { //if ($encoding == 'Q' || $encoding == 'q') { + // quoted printable can be combined and processed at once + for ($i=0; $i<$count; $i++) + $text .= $tmp[$i]; + + $text = str_replace('_', ' ', $text); + $text = quoted_printable_decode($text); + } + + $out .= rcube_charset::convert($text, $charset); + $tmp = array(); + } + + // add the last part of the input string + if ($start != strlen($input)) { + $out .= rcube_charset::convert(substr($input, $start), $default_charset); + } + + // return the results + return $out; + } + + // no encoding information, use fallback + return rcube_charset::convert($input, $default_charset); + } + + + /** + * Decode a mime part + * + * @param string $input Input string + * @param string $encoding Part encoding + * @return string Decoded string + */ + public static function decode($input, $encoding = '7bit') + { + switch (strtolower($encoding)) { + case 'quoted-printable': + return quoted_printable_decode($input); + case 'base64': + return base64_decode($input); + case 'x-uuencode': + case 'x-uue': + case 'uue': + case 'uuencode': + return convert_uudecode($input); + case '7bit': + default: + return $input; + } + } + + + /** + * Split RFC822 header string into an associative array + * @access private + */ + public static function parse_headers($headers) + { + $a_headers = array(); + $headers = preg_replace('/\r?\n(\t| )+/', ' ', $headers); + $lines = explode("\n", $headers); + $c = count($lines); + + for ($i=0; $i<$c; $i++) { + if ($p = strpos($lines[$i], ': ')) { + $field = strtolower(substr($lines[$i], 0, $p)); + $value = trim(substr($lines[$i], $p+1)); + if (!empty($value)) + $a_headers[$field] = $value; + } + } + + return $a_headers; + } + + + /** + * @access private + */ + private static function parse_address_list($str, $decode = true, $fallback = null) + { + // remove any newlines and carriage returns before + $str = preg_replace('/\r?\n(\s|\t)?/', ' ', $str); + + // extract list items, remove comments + $str = self::explode_header_string(',;', $str, true); + $result = array(); + + // simplified regexp, supporting quoted local part + $email_rx = '(\S+|("\s*(?:[^"\f\n\r\t\v\b\s]+\s*)+"))@\S+'; + + foreach ($str as $key => $val) { + $name = ''; + $address = ''; + $val = trim($val); + + if (preg_match('/(.*)<('.$email_rx.')>$/', $val, $m)) { + $address = $m[2]; + $name = trim($m[1]); + } + else if (preg_match('/^('.$email_rx.')$/', $val, $m)) { + $address = $m[1]; + $name = ''; + } + else { + $name = $val; + } + + // dequote and/or decode name + if ($name) { + if ($name[0] == '"' && $name[strlen($name)-1] == '"') { + $name = substr($name, 1, -1); + $name = stripslashes($name); + } + if ($decode) { + $name = self::decode_header($name, $fallback); + } + } + + if (!$address && $name) { + $address = $name; + } + + if ($address) { + $result[$key] = array('name' => $name, 'address' => $address); + } + } + + return $result; + } + + + /** + * Explodes header (e.g. address-list) string into array of strings + * using specified separator characters with proper handling + * of quoted-strings and comments (RFC2822) + * + * @param string $separator String containing separator characters + * @param string $str Header string + * @param bool $remove_comments Enable to remove comments + * + * @return array Header items + */ + public static function explode_header_string($separator, $str, $remove_comments = false) + { + $length = strlen($str); + $result = array(); + $quoted = false; + $comment = 0; + $out = ''; + + for ($i=0; $i<$length; $i++) { + // we're inside a quoted string + if ($quoted) { + if ($str[$i] == '"') { + $quoted = false; + } + else if ($str[$i] == "\\") { + if ($comment <= 0) { + $out .= "\\"; + } + $i++; + } + } + // we are inside a comment string + else if ($comment > 0) { + if ($str[$i] == ')') { + $comment--; + } + else if ($str[$i] == '(') { + $comment++; + } + else if ($str[$i] == "\\") { + $i++; + } + continue; + } + // separator, add to result array + else if (strpos($separator, $str[$i]) !== false) { + if ($out) { + $result[] = $out; + } + $out = ''; + continue; + } + // start of quoted string + else if ($str[$i] == '"') { + $quoted = true; + } + // start of comment + else if ($remove_comments && $str[$i] == '(') { + $comment++; + } + + if ($comment <= 0) { + $out .= $str[$i]; + } + } + + if ($out && $comment <= 0) { + $result[] = $out; + } + + return $result; + } + + + /** + * Interpret a format=flowed message body according to RFC 2646 + * + * @param string $text Raw body formatted as flowed text + * + * @return string Interpreted text with unwrapped lines and stuffed space removed + */ + public static function unfold_flowed($text) + { + $text = preg_split('/\r?\n/', $text); + $last = -1; + $q_level = 0; + + foreach ($text as $idx => $line) { + if ($line[0] == '>' && preg_match('/^(>+\s*)/', $line, $regs)) { + $q = strlen(str_replace(' ', '', $regs[0])); + $line = substr($line, strlen($regs[0])); + + if ($q == $q_level && $line + && isset($text[$last]) + && $text[$last][strlen($text[$last])-1] == ' ' + ) { + $text[$last] .= $line; + unset($text[$idx]); + } + else { + $last = $idx; + } + } + else { + $q = 0; + if ($line == '-- ') { + $last = $idx; + } + else { + // remove space-stuffing + $line = preg_replace('/^\s/', '', $line); + + if (isset($text[$last]) && $line + && $text[$last] != '-- ' + && $text[$last][strlen($text[$last])-1] == ' ' + ) { + $text[$last] .= $line; + unset($text[$idx]); + } + else { + $text[$idx] = $line; + $last = $idx; + } + } + } + $q_level = $q; + } + + return implode("\r\n", $text); + } + + + /** + * Wrap the given text to comply with RFC 2646 + * + * @param string $text Text to wrap + * @param int $length Length + * @param string $charset Character encoding of $text + * + * @return string Wrapped text + */ + public static function format_flowed($text, $length = 72, $charset=null) + { + $text = preg_split('/\r?\n/', $text); + + foreach ($text as $idx => $line) { + if ($line != '-- ') { + if ($line[0] == '>' && preg_match('/^(>+ {0,1})+/', $line, $regs)) { + $level = substr_count($regs[0], '>'); + $prefix = str_repeat('>', $level) . ' '; + $line = rtrim(substr($line, strlen($regs[0]))); + $line = $prefix . self::wordwrap($line, $length - $level - 2, " \r\n$prefix", false, $charset); + } + else if ($line) { + $line = self::wordwrap(rtrim($line), $length - 2, " \r\n", false, $charset); + // space-stuffing + $line = preg_replace('/(^|\r\n)(From| |>)/', '\\1 \\2', $line); + } + + $text[$idx] = $line; + } + } + + return implode("\r\n", $text); + } + + + /** + * Improved wordwrap function. + * + * @param string $string Text to wrap + * @param int $width Line width + * @param string $break Line separator + * @param bool $cut Enable to cut word + * @param string $charset Charset of $string + * + * @return string Text + */ + public static function wordwrap($string, $width=75, $break="\n", $cut=false, $charset=null) + { + if ($charset && function_exists('mb_internal_encoding')) { + mb_internal_encoding($charset); + } + + $para = preg_split('/\r?\n/', $string); + $string = ''; + + while (count($para)) { + $line = array_shift($para); + if ($line[0] == '>') { + $string .= $line.$break; + continue; + } + + $list = explode(' ', $line); + $len = 0; + while (count($list)) { + $line = array_shift($list); + $l = mb_strlen($line); + $newlen = $len + $l + ($len ? 1 : 0); + + if ($newlen <= $width) { + $string .= ($len ? ' ' : '').$line; + $len += (1 + $l); + } + else { + if ($l > $width) { + if ($cut) { + $start = 0; + while ($l) { + $str = mb_substr($line, $start, $width); + $strlen = mb_strlen($str); + $string .= ($len ? $break : '').$str; + $start += $strlen; + $l -= $strlen; + $len = $strlen; + } + } + else { + $string .= ($len ? $break : '').$line; + if (count($list)) { + $string .= $break; + } + $len = 0; + } + } + else { + $string .= $break.$line; + $len = $l; + } + } + } + + if (count($para)) { + $string .= $break; + } + } + + if ($charset && function_exists('mb_internal_encoding')) { + mb_internal_encoding(RCUBE_CHARSET); + } + + return $string; + } + + + /** + * A method to guess the mime_type of an attachment. + * + * @param string $path Path to the file or file contents + * @param string $name File name (with suffix) + * @param string $failover Mime type supplied for failover + * @param boolean $is_stream Set to True if $path contains file contents + * @param boolean $skip_suffix Set to True if the config/mimetypes.php mappig should be ignored + * + * @return string + * @author Till Klampaeckel <till@php.net> + * @see http://de2.php.net/manual/en/ref.fileinfo.php + * @see http://de2.php.net/mime_content_type + */ + public static function file_content_type($path, $name, $failover = 'application/octet-stream', $is_stream = false, $skip_suffix = false) + { + $mime_type = null; + $mime_magic = rcube::get_instance()->config->get('mime_magic'); + $mime_ext = $skip_suffix ? null : @include(RCUBE_CONFIG_DIR . '/mimetypes.php'); + + // use file name suffix with hard-coded mime-type map + if (is_array($mime_ext) && $name) { + if ($suffix = substr($name, strrpos($name, '.')+1)) { + $mime_type = $mime_ext[strtolower($suffix)]; + } + } + + // try fileinfo extension if available + if (!$mime_type && function_exists('finfo_open')) { + if ($finfo = finfo_open(FILEINFO_MIME, $mime_magic)) { + if ($is_stream) + $mime_type = finfo_buffer($finfo, $path); + else + $mime_type = finfo_file($finfo, $path); + finfo_close($finfo); + } + } + + // try PHP's mime_content_type + if (!$mime_type && !$is_stream && function_exists('mime_content_type')) { + $mime_type = @mime_content_type($path); + } + + // fall back to user-submitted string + if (!$mime_type) { + $mime_type = $failover; + } + else { + // Sometimes (PHP-5.3?) content-type contains charset definition, + // Remove it (#1487122) also "charset=binary" is useless + $mime_type = array_shift(preg_split('/[; ]/', $mime_type)); + } + + return $mime_type; + } + + + /** + * Get mimetype => file extension mapping + * + * @param string Mime-Type to get extensions for + * @return array List of extensions matching the given mimetype or a hash array with ext -> mimetype mappings if $mimetype is not given + */ + public static function get_mime_extensions($mimetype = null) + { + static $mime_types, $mime_extensions; + + // return cached data + if (is_array($mime_types)) { + return $mimetype ? $mime_types[$mimetype] : $mime_extensions; + } + + // load mapping file + $file_paths = array(); + + if ($mime_types = rcube::get_instance()->config->get('mime_types')) + $file_paths[] = $mime_types; + + // try common locations + $file_paths[] = '/etc/httpd/mime.types'; + $file_paths[] = '/etc/httpd2/mime.types'; + $file_paths[] = '/etc/apache/mime.types'; + $file_paths[] = '/etc/apache2/mime.types'; + $file_paths[] = '/usr/local/etc/httpd/conf/mime.types'; + $file_paths[] = '/usr/local/etc/apache/conf/mime.types'; + + foreach ($file_paths as $fp) { + if (is_readable($fp)) { + $lines = file($fp, FILE_IGNORE_NEW_LINES); + break; + } + } + + $mime_types = $mime_extensions = array(); + $regex = "/([\w\+\-\.\/]+)\t+([\w\s]+)/i"; + foreach((array)$lines as $line) { + // skip comments or mime types w/o any extensions + if ($line[0] == '#' || !preg_match($regex, $line, $matches)) + continue; + + $mime = $matches[1]; + foreach (explode(' ', $matches[2]) as $ext) { + $ext = trim($ext); + $mime_types[$mime][] = $ext; + $mime_extensions[$ext] = $mime; + } + } + + // fallback to some well-known types most important for daily emails + if (empty($mime_types)) { + $mime_extensions = @include(RCUBE_CONFIG_DIR . '/mimetypes.php'); + $mime_extensions += array('gif' => 'image/gif', 'png' => 'image/png', 'jpg' => 'image/jpg', 'jpeg' => 'image/jpeg', 'tif' => 'image/tiff'); + + foreach ($mime_extensions as $ext => $mime) + $mime_types[$mime][] = $ext; + } + + return $mimetype ? $mime_types[$mimetype] : $mime_extensions; + } + + + /** + * Detect image type of the given binary data by checking magic numbers. + * + * @param string $data Binary file content + * + * @return string Detected mime-type or jpeg as fallback + */ + public static function image_content_type($data) + { + $type = 'jpeg'; + if (preg_match('/^\x89\x50\x4E\x47/', $data)) $type = 'png'; + else if (preg_match('/^\x47\x49\x46\x38/', $data)) $type = 'gif'; + else if (preg_match('/^\x00\x00\x01\x00/', $data)) $type = 'ico'; + // else if (preg_match('/^\xFF\xD8\xFF\xE0/', $data)) $type = 'jpeg'; + + return 'image/' . $type; + } + +} diff --git a/program/lib/Roundcube/rcube_output.php b/program/lib/Roundcube/rcube_output.php new file mode 100644 index 000000000..4ef42f598 --- /dev/null +++ b/program/lib/Roundcube/rcube_output.php @@ -0,0 +1,271 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_output.php | + | | + | This file is part of the Roundcube PHP suite | + | Copyright (C) 2005-2012 The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | CONTENTS: | + | Abstract class for output generation | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Class for output generation + * + * @package Framework + * @subpackage View + */ +abstract class rcube_output +{ + public $browser; + + protected $app; + protected $config; + protected $charset = RCUBE_CHARSET; + protected $env = array(); + + + /** + * Object constructor + */ + public function __construct() + { + $this->app = rcube::get_instance(); + $this->config = $this->app->config; + $this->browser = new rcube_browser(); + } + + + /** + * Magic getter + */ + public function __get($var) + { + // allow read-only access to $env + if ($var == 'env') + return $this->env; + + return null; + } + + + /** + * Setter for output charset. + * To be specified in a meta tag and sent as http-header + * + * @param string $charset Charset name + */ + public function set_charset($charset) + { + $this->charset = $charset; + } + + + /** + * Getter for output charset + * + * @return string Output charset name + */ + public function get_charset() + { + return $this->charset; + } + + + /** + * Set environment variable + * + * @param string $name Property name + * @param mixed $value Property value + */ + public function set_env($name, $value) + { + $this->env[$name] = $value; + } + + + /** + * Environment variable getter. + * + * @param string $name Property name + * + * @return mixed Property value + */ + public function get_env($name) + { + return $this->env[$name]; + } + + + /** + * Delete all stored env variables and commands + */ + public function reset() + { + $this->env = array(); + } + + + /** + * Invoke display_message command + * + * @param string $message Message to display + * @param string $type Message type [notice|confirm|error] + * @param array $vars Key-value pairs to be replaced in localized text + * @param boolean $override Override last set message + * @param int $timeout Message displaying time in seconds + */ + abstract function show_message($message, $type = 'notice', $vars = null, $override = true, $timeout = 0); + + + /** + * Redirect to a certain url. + * + * @param mixed $p Either a string with the action or url parameters as key-value pairs + * @param int $delay Delay in seconds + */ + abstract function redirect($p = array(), $delay = 1); + + + /** + * Send output to the client. + */ + abstract function send(); + + + /** + * Send HTTP headers to prevent caching a page + */ + public function nocacheing_headers() + { + if (headers_sent()) { + return; + } + + header("Expires: ".gmdate("D, d M Y H:i:s")." GMT"); + header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT"); + + // Request browser to disable DNS prefetching (CVE-2010-0464) + header("X-DNS-Prefetch-Control: off"); + + // We need to set the following headers to make downloads work using IE in HTTPS mode. + if ($this->browser->ie && rcube_utils::https_check()) { + header('Pragma: private'); + header("Cache-Control: private, must-revalidate"); + } + else { + header("Cache-Control: private, no-cache, must-revalidate, post-check=0, pre-check=0"); + header("Pragma: no-cache"); + } + } + + /** + * Send header with expire date 30 days in future + * + * @param int Expiration time in seconds + */ + public function future_expire_header($offset = 2600000) + { + if (headers_sent()) + return; + + header("Expires: " . gmdate("D, d M Y H:i:s", time()+$offset) . " GMT"); + header("Cache-Control: max-age=$offset"); + header("Pragma: "); + } + + + /** + * Show error page and terminate script execution + * + * @param int $code Error code + * @param string $message Error message + */ + public function raise_error($code, $message) + { + // STUB: to be overloaded by specific output classes + fputs(STDERR, "Error $code: $message\n"); + exit(-1); + } + + + /** + * Create an edit field for inclusion on a form + * + * @param string col field name + * @param string value field value + * @param array attrib HTML element attributes for field + * @param string type HTML element type (default 'text') + * + * @return string HTML field definition + */ + public static function get_edit_field($col, $value, $attrib, $type = 'text') + { + static $colcounts = array(); + + $fname = '_'.$col; + $attrib['name'] = $fname . ($attrib['array'] ? '[]' : ''); + $attrib['class'] = trim($attrib['class'] . ' ff_' . $col); + + if ($type == 'checkbox') { + $attrib['value'] = '1'; + $input = new html_checkbox($attrib); + } + else if ($type == 'textarea') { + $attrib['cols'] = $attrib['size']; + $input = new html_textarea($attrib); + } + else if ($type == 'select') { + $input = new html_select($attrib); + $input->add('---', ''); + $input->add(array_values($attrib['options']), array_keys($attrib['options'])); + } + else if ($attrib['type'] == 'password') { + $input = new html_passwordfield($attrib); + } + else { + if ($attrib['type'] != 'text' && $attrib['type'] != 'hidden') { + $attrib['type'] = 'text'; + } + $input = new html_inputfield($attrib); + } + + // use value from post + if (isset($_POST[$fname])) { + $postvalue = rcube_utils::get_input_value($fname, rcube_utils::INPUT_POST, true); + $value = $attrib['array'] ? $postvalue[intval($colcounts[$col]++)] : $postvalue; + } + + $out = $input->show($value); + + return $out; + } + + + /** + * Convert a variable into a javascript object notation + * + * @param mixed Input value + * + * @return string Serialized JSON string + */ + public static function json_serialize($input) + { + $input = rcube_charset::clean($input); + + // sometimes even using rcube_charset::clean() the input contains invalid UTF-8 sequences + // that's why we have @ here + return @json_encode($input); + } + +} diff --git a/program/lib/Roundcube/rcube_plugin.php b/program/lib/Roundcube/rcube_plugin.php new file mode 100644 index 000000000..dbb15e8be --- /dev/null +++ b/program/lib/Roundcube/rcube_plugin.php @@ -0,0 +1,360 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_plugin.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2009, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Abstract plugins interface/class | + | All plugins need to extend this class | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Plugin interface class + * + * @package Framework + * @subpackage PluginAPI + */ +abstract class rcube_plugin +{ + /** + * Class name of the plugin instance + * + * @var string + */ + public $ID; + + /** + * Instance of Plugin API + * + * @var rcube_plugin_api + */ + public $api; + + /** + * Regular expression defining task(s) to bind with + * + * @var string + */ + public $task; + + /** + * Disables plugin in AJAX requests + * + * @var boolean + */ + public $noajax = false; + + /** + * Disables plugin in framed mode + * + * @var boolean + */ + public $noframe = false; + + protected $home; + protected $urlbase; + private $mytask; + + + /** + * Default constructor. + * + * @param rcube_plugin_api $api Plugin API + */ + public function __construct($api) + { + $this->ID = get_class($this); + $this->api = $api; + $this->home = $api->dir . $this->ID; + $this->urlbase = $api->url . $this->ID . '/'; + } + + /** + * Initialization method, needs to be implemented by the plugin itself + */ + abstract function init(); + + + /** + * Attempt to load the given plugin which is required for the current plugin + * + * @param string Plugin name + * @return boolean True on success, false on failure + */ + public function require_plugin($plugin_name) + { + return $this->api->load_plugin($plugin_name); + } + + + /** + * Load local config file from plugins directory. + * The loaded values are patched over the global configuration. + * + * @param string $fname Config file name relative to the plugin's folder + * @return boolean True on success, false on failure + */ + public function load_config($fname = 'config.inc.php') + { + $fpath = $this->home.'/'.$fname; + $rcube = rcube::get_instance(); + if (is_file($fpath) && !$rcube->config->load_from_file($fpath)) { + rcube::raise_error(array( + 'code' => 527, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to load config from $fpath"), true, false); + return false; + } + + return true; + } + + /** + * Register a callback function for a specific (server-side) hook + * + * @param string $hook Hook name + * @param mixed $callback Callback function as string or array with object reference and method name + */ + public function add_hook($hook, $callback) + { + $this->api->register_hook($hook, $callback); + } + + /** + * Unregister a callback function for a specific (server-side) hook. + * + * @param string $hook Hook name + * @param mixed $callback Callback function as string or array with object reference and method name + */ + public function remove_hook($hook, $callback) + { + $this->api->unregister_hook($hook, $callback); + } + + /** + * Load localized texts from the plugins dir + * + * @param string $dir Directory to search in + * @param mixed $add2client Make texts also available on the client (array with list or true for all) + */ + public function add_texts($dir, $add2client = false) + { + $domain = $this->ID; + $lang = $_SESSION['language']; + $langs = array_unique(array('en_US', $lang)); + $locdir = slashify(realpath(slashify($this->home) . $dir)); + $texts = array(); + + // Language aliases used to find localization in similar lang, see below + $aliases = array( + 'de_CH' => 'de_DE', + 'es_AR' => 'es_ES', + 'fa_AF' => 'fa_IR', + 'nl_BE' => 'nl_NL', + 'pt_BR' => 'pt_PT', + 'zh_CN' => 'zh_TW', + ); + + // use buffering to handle empty lines/spaces after closing PHP tag + ob_start(); + + foreach ($langs as $lng) { + $fpath = $locdir . $lng . '.inc'; + if (is_file($fpath) && is_readable($fpath)) { + include $fpath; + $texts = (array)$labels + (array)$messages + (array)$texts; + } + else if ($lng != 'en_US') { + // Find localization in similar language (#1488401) + $alias = null; + if (!empty($aliases[$lng])) { + $alias = $aliases[$lng]; + } + else if ($key = array_search($lng, $aliases)) { + $alias = $key; + } + + if (!empty($alias)) { + $fpath = $locdir . $alias . '.inc'; + if (is_file($fpath) && is_readable($fpath)) { + include $fpath; + $texts = (array)$labels + (array)$messages + (array)$texts; + } + } + } + } + + ob_end_clean(); + + // prepend domain to text keys and add to the application texts repository + if (!empty($texts)) { + $add = array(); + foreach ($texts as $key => $value) + $add[$domain.'.'.$key] = $value; + + $rcmail = rcube::get_instance(); + $rcmail->load_language($lang, $add); + + // add labels to client + if ($add2client) { + $js_labels = is_array($add2client) ? array_map(array($this, 'label_map_callback'), $add2client) : array_keys($add); + $rcmail->output->add_label($js_labels); + } + } + } + + /** + * Wrapper for rcmail::gettext() adding the plugin ID as domain + * + * @param string $p Message identifier + * @return string Localized text + * @see rcmail::gettext() + */ + public function gettext($p) + { + return rcube::get_instance()->gettext($p, $this->ID); + } + + /** + * Register this plugin to be responsible for a specific task + * + * @param string $task Task name (only characters [a-z0-9_.-] are allowed) + */ + public function register_task($task) + { + if ($this->api->register_task($task, $this->ID)) + $this->mytask = $task; + } + + /** + * Register a handler for a specific client-request action + * + * The callback will be executed upon a request like /?_task=mail&_action=plugin.myaction + * + * @param string $action Action name (should be unique) + * @param mixed $callback Callback function as string or array with object reference and method name + */ + public function register_action($action, $callback) + { + $this->api->register_action($action, $this->ID, $callback, $this->mytask); + } + + /** + * Register a handler function for a template object + * + * When parsing a template for display, tags like <roundcube:object name="plugin.myobject" /> + * will be replaced by the return value if the registered callback function. + * + * @param string $name Object name (should be unique and start with 'plugin.') + * @param mixed $callback Callback function as string or array with object reference and method name + */ + public function register_handler($name, $callback) + { + $this->api->register_handler($name, $this->ID, $callback); + } + + /** + * Make this javascipt file available on the client + * + * @param string $fn File path; absolute or relative to the plugin directory + */ + public function include_script($fn) + { + $this->api->include_script($this->resource_url($fn)); + } + + /** + * Make this stylesheet available on the client + * + * @param string $fn File path; absolute or relative to the plugin directory + */ + public function include_stylesheet($fn) + { + $this->api->include_stylesheet($this->resource_url($fn)); + } + + /** + * Append a button to a certain container + * + * @param array $p Hash array with named parameters (as used in skin templates) + * @param string $container Container name where the buttons should be added to + * @see rcube_remplate::button() + */ + public function add_button($p, $container) + { + if ($this->api->output->type == 'html') { + // fix relative paths + foreach (array('imagepas', 'imageact', 'imagesel') as $key) + if ($p[$key]) + $p[$key] = $this->api->url . $this->resource_url($p[$key]); + + $this->api->add_content($this->api->output->button($p), $container); + } + } + + /** + * Generate an absolute URL to the given resource within the current + * plugin directory + * + * @param string $fn The file name + * @return string Absolute URL to the given resource + */ + public function url($fn) + { + return $this->api->url . $this->resource_url($fn); + } + + /** + * Make the given file name link into the plugin directory + * + * @param string $fn Filename + */ + private function resource_url($fn) + { + if ($fn[0] != '/' && !preg_match('|^https?://|i', $fn)) + return $this->ID . '/' . $fn; + else + return $fn; + } + + /** + * Provide path to the currently selected skin folder within the plugin directory + * with a fallback to the default skin folder. + * + * @return string Skin path relative to plugins directory + */ + public function local_skin_path() + { + $rcmail = rcube::get_instance(); + foreach (array($rcmail->config->get('skin'), 'larry') as $skin) { + $skin_path = 'skins/' . $skin; + if (is_dir(realpath(slashify($this->home) . $skin_path))) + break; + } + + return $skin_path; + } + + /** + * Callback function for array_map + * + * @param string $key Array key. + * @return string + */ + private function label_map_callback($key) + { + return $this->ID.'.'.$key; + } + +} diff --git a/program/lib/Roundcube/rcube_plugin_api.php b/program/lib/Roundcube/rcube_plugin_api.php new file mode 100644 index 000000000..51cf5d246 --- /dev/null +++ b/program/lib/Roundcube/rcube_plugin_api.php @@ -0,0 +1,499 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_plugin_api.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2011, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Plugins repository | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +// location where plugins are loade from +if (!defined('RCUBE_PLUGINS_DIR')) + define('RCUBE_PLUGINS_DIR', RCUBE_INSTALL_PATH . 'plugins/'); + + +/** + * The plugin loader and global API + * + * @package Framework + * @subpackage PluginAPI + */ +class rcube_plugin_api +{ + static protected $instance; + + public $dir; + public $url = 'plugins/'; + public $task = ''; + public $output; + + public $handlers = array(); + protected $plugins = array(); + protected $tasks = array(); + protected $actions = array(); + protected $actionmap = array(); + protected $objectsmap = array(); + protected $template_contents = array(); + protected $active_hook = false; + + // Deprecated names of hooks, will be removed after 0.5-stable release + protected $deprecated_hooks = array( + 'create_user' => 'user_create', + 'kill_session' => 'session_destroy', + 'upload_attachment' => 'attachment_upload', + 'save_attachment' => 'attachment_save', + 'get_attachment' => 'attachment_get', + 'cleanup_attachments' => 'attachments_cleanup', + 'display_attachment' => 'attachment_display', + 'remove_attachment' => 'attachment_delete', + 'outgoing_message_headers' => 'message_outgoing_headers', + 'outgoing_message_body' => 'message_outgoing_body', + 'address_sources' => 'addressbooks_list', + 'get_address_book' => 'addressbook_get', + 'create_contact' => 'contact_create', + 'save_contact' => 'contact_update', + 'contact_save' => 'contact_update', + 'delete_contact' => 'contact_delete', + 'manage_folders' => 'folders_list', + 'list_mailboxes' => 'mailboxes_list', + 'save_preferences' => 'preferences_save', + 'user_preferences' => 'preferences_list', + 'list_prefs_sections' => 'preferences_sections_list', + 'list_identities' => 'identities_list', + 'create_identity' => 'identity_create', + 'delete_identity' => 'identity_delete', + 'save_identity' => 'identity_update', + 'identity_save' => 'identity_update', + // to be removed after 0.8 + 'imap_init' => 'storage_init', + 'mailboxes_list' => 'storage_folders', + ); + + /** + * This implements the 'singleton' design pattern + * + * @return rcube_plugin_api The one and only instance if this class + */ + static function get_instance() + { + if (!self::$instance) { + self::$instance = new rcube_plugin_api(); + } + + return self::$instance; + } + + + /** + * Private constructor + */ + protected function __construct() + { + $this->dir = slashify(RCUBE_PLUGINS_DIR); + } + + + /** + * Initialize plugin engine + * + * This has to be done after rcmail::load_gui() or rcmail::json_init() + * was called because plugins need to have access to rcmail->output + * + * @param object rcube Instance of the rcube base class + * @param string Current application task (used for conditional plugin loading) + */ + public function init($app, $task = '') + { + $this->task = $task; + $this->output = $app->output; + + // register an internal hook + $this->register_hook('template_container', array($this, 'template_container_hook')); + + // maybe also register a shudown function which triggers shutdown functions of all plugin objects + } + + + /** + * Load and init all enabled plugins + * + * This has to be done after rcmail::load_gui() or rcmail::json_init() + * was called because plugins need to have access to rcmail->output + * + * @param array List of configured plugins to load + * @param array List of plugins required by the application + */ + public function load_plugins($plugins_enabled, $required_plugins = array()) + { + foreach ($plugins_enabled as $plugin_name) { + $this->load_plugin($plugin_name); + } + + // check existance of all required core plugins + foreach ($required_plugins as $plugin_name) { + $loaded = false; + foreach ($this->plugins as $plugin) { + if ($plugin instanceof $plugin_name) { + $loaded = true; + break; + } + } + + // load required core plugin if no derivate was found + if (!$loaded) + $loaded = $this->load_plugin($plugin_name); + + // trigger fatal error if still not loaded + if (!$loaded) { + rcube::raise_error(array('code' => 520, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Requried plugin $plugin_name was not loaded"), true, true); + } + } + } + + /** + * Load the specified plugin + * + * @param string Plugin name + * @return boolean True on success, false if not loaded or failure + */ + public function load_plugin($plugin_name) + { + static $plugins_dir; + + if (!$plugins_dir) { + $dir = dir($this->dir); + $plugins_dir = unslashify($dir->path); + } + + // plugin already loaded + if ($this->plugins[$plugin_name] || class_exists($plugin_name, false)) + return true; + + $fn = $plugins_dir . DIRECTORY_SEPARATOR . $plugin_name . DIRECTORY_SEPARATOR . $plugin_name . '.php'; + + if (file_exists($fn)) { + include($fn); + + // instantiate class if exists + if (class_exists($plugin_name, false)) { + $plugin = new $plugin_name($this); + // check inheritance... + if (is_subclass_of($plugin, 'rcube_plugin')) { + // ... task, request type and framed mode + if ((!$plugin->task || preg_match('/^('.$plugin->task.')$/i', $this->task)) + && (!$plugin->noajax || (is_object($this->output) && $this->output->type == 'html')) + && (!$plugin->noframe || empty($_REQUEST['_framed'])) + ) { + $plugin->init(); + $this->plugins[$plugin_name] = $plugin; + } + return true; + } + } + else { + rcube::raise_error(array('code' => 520, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "No plugin class $plugin_name found in $fn"), true, false); + } + } + else { + rcube::raise_error(array('code' => 520, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to load plugin file $fn"), true, false); + } + + return false; + } + + + /** + * Allows a plugin object to register a callback for a certain hook + * + * @param string $hook Hook name + * @param mixed $callback String with global function name or array($obj, 'methodname') + */ + public function register_hook($hook, $callback) + { + if (is_callable($callback)) { + if (isset($this->deprecated_hooks[$hook])) { + rcube::raise_error(array('code' => 522, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Deprecated hook name. ".$hook.' -> '.$this->deprecated_hooks[$hook]), true, false); + $hook = $this->deprecated_hooks[$hook]; + } + $this->handlers[$hook][] = $callback; + } + else + rcube::raise_error(array('code' => 521, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Invalid callback function for $hook"), true, false); + } + + /** + * Allow a plugin object to unregister a callback. + * + * @param string $hook Hook name + * @param mixed $callback String with global function name or array($obj, 'methodname') + */ + public function unregister_hook($hook, $callback) + { + $callback_id = array_search($callback, $this->handlers[$hook]); + if ($callback_id !== false) { + unset($this->handlers[$hook][$callback_id]); + } + } + + + /** + * Triggers a plugin hook. + * This is called from the application and executes all registered handlers + * + * @param string $hook Hook name + * @param array $args Named arguments (key->value pairs) + * @return array The (probably) altered hook arguments + */ + public function exec_hook($hook, $args = array()) + { + if (!is_array($args)) + $args = array('arg' => $args); + + $args += array('abort' => false); + $this->active_hook = $hook; + + foreach ((array)$this->handlers[$hook] as $callback) { + $ret = call_user_func($callback, $args); + if ($ret && is_array($ret)) + $args = $ret + $args; + + if ($args['abort']) + break; + } + + $this->active_hook = false; + return $args; + } + + + /** + * Let a plugin register a handler for a specific request + * + * @param string $action Action name (_task=mail&_action=plugin.foo) + * @param string $owner Plugin name that registers this action + * @param mixed $callback Callback: string with global function name or array($obj, 'methodname') + * @param string $task Task name registered by this plugin + */ + public function register_action($action, $owner, $callback, $task = null) + { + // check action name + if ($task) + $action = $task.'.'.$action; + else if (strpos($action, 'plugin.') !== 0) + $action = 'plugin.'.$action; + + // can register action only if it's not taken or registered by myself + if (!isset($this->actionmap[$action]) || $this->actionmap[$action] == $owner) { + $this->actions[$action] = $callback; + $this->actionmap[$action] = $owner; + } + else { + rcube::raise_error(array('code' => 523, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Cannot register action $action; already taken by another plugin"), true, false); + } + } + + + /** + * This method handles requests like _task=mail&_action=plugin.foo + * It executes the callback function that was registered with the given action. + * + * @param string $action Action name + */ + public function exec_action($action) + { + if (isset($this->actions[$action])) { + call_user_func($this->actions[$action]); + } + else if (rcube::get_instance()->action != 'refresh') { + rcube::raise_error(array('code' => 524, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "No handler found for action $action"), true, true); + } + } + + + /** + * Register a handler function for template objects + * + * @param string $name Object name + * @param string $owner Plugin name that registers this action + * @param mixed $callback Callback: string with global function name or array($obj, 'methodname') + */ + public function register_handler($name, $owner, $callback) + { + // check name + if (strpos($name, 'plugin.') !== 0) + $name = 'plugin.'.$name; + + // can register handler only if it's not taken or registered by myself + if (is_object($this->output) && (!isset($this->objectsmap[$name]) || $this->objectsmap[$name] == $owner)) { + $this->output->add_handler($name, $callback); + $this->objectsmap[$name] = $owner; + } + else { + rcube::raise_error(array('code' => 525, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Cannot register template handler $name; already taken by another plugin or no output object available"), true, false); + } + } + + + /** + * Register this plugin to be responsible for a specific task + * + * @param string $task Task name (only characters [a-z0-9_.-] are allowed) + * @param string $owner Plugin name that registers this action + */ + public function register_task($task, $owner) + { + // tasks are irrelevant in framework mode + if (!class_exists('rcmail', false)) + return true; + + if ($task != asciiwords($task)) { + rcube::raise_error(array('code' => 526, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Invalid task name: $task. Only characters [a-z0-9_.-] are allowed"), true, false); + } + else if (in_array($task, rcmail::$main_tasks)) { + rcube::raise_error(array('code' => 526, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Cannot register taks $task; already taken by another plugin or the application itself"), true, false); + } + else { + $this->tasks[$task] = $owner; + rcmail::$main_tasks[] = $task; + return true; + } + + return false; + } + + + /** + * Checks whether the given task is registered by a plugin + * + * @param string $task Task name + * @return boolean True if registered, otherwise false + */ + public function is_plugin_task($task) + { + return $this->tasks[$task] ? true : false; + } + + + /** + * Check if a plugin hook is currently processing. + * Mainly used to prevent loops and recursion. + * + * @param string $hook Hook to check (optional) + * @return boolean True if any/the given hook is currently processed, otherwise false + */ + public function is_processing($hook = null) + { + return $this->active_hook && (!$hook || $this->active_hook == $hook); + } + + /** + * Include a plugin script file in the current HTML page + * + * @param string $fn Path to script + */ + public function include_script($fn) + { + if (is_object($this->output) && $this->output->type == 'html') { + $src = $this->resource_url($fn); + $this->output->add_header(html::tag('script', array('type' => "text/javascript", 'src' => $src))); + } + } + + + /** + * Include a plugin stylesheet in the current HTML page + * + * @param string $fn Path to stylesheet + */ + public function include_stylesheet($fn) + { + if (is_object($this->output) && $this->output->type == 'html') { + $src = $this->resource_url($fn); + $this->output->include_css($src); + } + } + + + /** + * Save the given HTML content to be added to a template container + * + * @param string $html HTML content + * @param string $container Template container identifier + */ + public function add_content($html, $container) + { + $this->template_contents[$container] .= $html . "\n"; + } + + + /** + * Returns list of loaded plugins names + * + * @return array List of plugin names + */ + public function loaded_plugins() + { + return array_keys($this->plugins); + } + + + /** + * Callback for template_container hooks + * + * @param array $attrib + * @return array + */ + protected function template_container_hook($attrib) + { + $container = $attrib['name']; + return array('content' => $attrib['content'] . $this->template_contents[$container]); + } + + + /** + * Make the given file name link into the plugins directory + * + * @param string $fn Filename + * @return string + */ + protected function resource_url($fn) + { + if ($fn[0] != '/' && !preg_match('|^https?://|i', $fn)) + return $this->url . $fn; + else + return $fn; + } + +} diff --git a/program/lib/Roundcube/rcube_result_index.php b/program/lib/Roundcube/rcube_result_index.php new file mode 100644 index 000000000..4d1ae13b6 --- /dev/null +++ b/program/lib/Roundcube/rcube_result_index.php @@ -0,0 +1,453 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_result_index.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2011, The Roundcube Dev Team | + | Copyright (C) 2011, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | SORT/SEARCH/ESEARCH response handler | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class for accessing IMAP's SORT/SEARCH/ESEARCH result + * + * @package Framework + * @subpackage Storage + */ +class rcube_result_index +{ + protected $raw_data; + protected $mailbox; + protected $meta = array(); + protected $params = array(); + protected $order = 'ASC'; + + const SEPARATOR_ELEMENT = ' '; + + + /** + * Object constructor. + */ + public function __construct($mailbox = null, $data = null) + { + $this->mailbox = $mailbox; + $this->init($data); + } + + + /** + * Initializes object with SORT command response + * + * @param string $data IMAP response string + */ + public function init($data = null) + { + $this->meta = array(); + + $data = explode('*', (string)$data); + + // ...skip unilateral untagged server responses + for ($i=0, $len=count($data); $i<$len; $i++) { + $data_item = &$data[$i]; + if (preg_match('/^ SORT/i', $data_item)) { + // valid response, initialize raw_data for is_error() + $this->raw_data = ''; + $data_item = substr($data_item, 5); + break; + } + else if (preg_match('/^ (E?SEARCH)/i', $data_item, $m)) { + // valid response, initialize raw_data for is_error() + $this->raw_data = ''; + $data_item = substr($data_item, strlen($m[0])); + + if (strtoupper($m[1]) == 'ESEARCH') { + $data_item = trim($data_item); + // remove MODSEQ response + if (preg_match('/\(MODSEQ ([0-9]+)\)$/i', $data_item, $m)) { + $data_item = substr($data_item, 0, -strlen($m[0])); + $this->params['MODSEQ'] = $m[1]; + } + // remove TAG response part + if (preg_match('/^\(TAG ["a-z0-9]+\)\s*/i', $data_item, $m)) { + $data_item = substr($data_item, strlen($m[0])); + } + // remove UID + $data_item = preg_replace('/^UID\s*/i', '', $data_item); + + // ESEARCH parameters + while (preg_match('/^([a-z]+) ([0-9:,]+)\s*/i', $data_item, $m)) { + $param = strtoupper($m[1]); + $value = $m[2]; + + $this->params[$param] = $value; + $data_item = substr($data_item, strlen($m[0])); + + if (in_array($param, array('COUNT', 'MIN', 'MAX'))) { + $this->meta[strtolower($param)] = (int) $value; + } + } + +// @TODO: Implement compression using compressMessageSet() in __sleep() and __wakeup() ? +// @TODO: work with compressed result?! + if (isset($this->params['ALL'])) { + $data_item = implode(self::SEPARATOR_ELEMENT, + rcube_imap_generic::uncompressMessageSet($this->params['ALL'])); + } + } + + break; + } + + unset($data[$i]); + } + + $data = array_filter($data); + + if (empty($data)) { + return; + } + + $data = array_shift($data); + $data = trim($data); + $data = preg_replace('/[\r\n]/', '', $data); + $data = preg_replace('/\s+/', ' ', $data); + + $this->raw_data = $data; + } + + + /** + * Checks the result from IMAP command + * + * @return bool True if the result is an error, False otherwise + */ + public function is_error() + { + return $this->raw_data === null ? true : false; + } + + + /** + * Checks if the result is empty + * + * @return bool True if the result is empty, False otherwise + */ + public function is_empty() + { + return empty($this->raw_data) ? true : false; + } + + + /** + * Returns number of elements in the result + * + * @return int Number of elements + */ + public function count() + { + if ($this->meta['count'] !== null) + return $this->meta['count']; + + if (empty($this->raw_data)) { + $this->meta['count'] = 0; + $this->meta['length'] = 0; + } + else { + $this->meta['count'] = 1 + substr_count($this->raw_data, self::SEPARATOR_ELEMENT); + } + + return $this->meta['count']; + } + + + /** + * Returns number of elements in the result. + * Alias for count() for compatibility with rcube_result_thread + * + * @return int Number of elements + */ + public function count_messages() + { + return $this->count(); + } + + + /** + * Returns maximal message identifier in the result + * + * @return int Maximal message identifier + */ + public function max() + { + if (!isset($this->meta['max'])) { + $this->meta['max'] = (int) @max($this->get()); + } + + return $this->meta['max']; + } + + + /** + * Returns minimal message identifier in the result + * + * @return int Minimal message identifier + */ + public function min() + { + if (!isset($this->meta['min'])) { + $this->meta['min'] = (int) @min($this->get()); + } + + return $this->meta['min']; + } + + + /** + * Slices data set. + * + * @param $offset Offset (as for PHP's array_slice()) + * @param $length Number of elements (as for PHP's array_slice()) + * + */ + public function slice($offset, $length) + { + $data = $this->get(); + $data = array_slice($data, $offset, $length); + + $this->meta = array(); + $this->meta['count'] = count($data); + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data); + } + + + /** + * Filters data set. Removes elements listed in $ids list. + * + * @param array $ids List of IDs to remove. + */ + public function filter($ids = array()) + { + $data = $this->get(); + $data = array_diff($data, $ids); + + $this->meta = array(); + $this->meta['count'] = count($data); + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data); + } + + + /** + * Filters data set. Removes elements not listed in $ids list. + * + * @param array $ids List of IDs to keep. + */ + public function intersect($ids = array()) + { + $data = $this->get(); + $data = array_intersect($data, $ids); + + $this->meta = array(); + $this->meta['count'] = count($data); + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data); + } + + + /** + * Reverts order of elements in the result + */ + public function revert() + { + $this->order = $this->order == 'ASC' ? 'DESC' : 'ASC'; + + if (empty($this->raw_data)) { + return; + } + + // @TODO: maybe do this in chunks + $data = $this->get(); + $data = array_reverse($data); + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data); + + $this->meta['pos'] = array(); + } + + + /** + * Check if the given message ID exists in the object + * + * @param int $msgid Message ID + * @param bool $get_index When enabled element's index will be returned. + * Elements are indexed starting with 0 + * + * @return mixed False if message ID doesn't exist, True if exists or + * index of the element if $get_index=true + */ + public function exists($msgid, $get_index = false) + { + if (empty($this->raw_data)) { + return false; + } + + $msgid = (int) $msgid; + $begin = implode('|', array('^', preg_quote(self::SEPARATOR_ELEMENT, '/'))); + $end = implode('|', array('$', preg_quote(self::SEPARATOR_ELEMENT, '/'))); + + if (preg_match("/($begin)$msgid($end)/", $this->raw_data, $m, + $get_index ? PREG_OFFSET_CAPTURE : null) + ) { + if ($get_index) { + $idx = 0; + if ($m[0][1]) { + $idx = 1 + substr_count($this->raw_data, self::SEPARATOR_ELEMENT, 0, $m[0][1]); + } + // cache position of this element, so we can use it in get_element() + $this->meta['pos'][$idx] = (int)$m[0][1]; + + return $idx; + } + return true; + } + + return false; + } + + + /** + * Return all messages in the result. + * + * @return array List of message IDs + */ + public function get() + { + if (empty($this->raw_data)) { + return array(); + } + return explode(self::SEPARATOR_ELEMENT, $this->raw_data); + } + + + /** + * Return all messages in the result. + * + * @return array List of message IDs + */ + public function get_compressed() + { + if (empty($this->raw_data)) { + return ''; + } + + return rcube_imap_generic::compressMessageSet($this->get()); + } + + + /** + * Return result element at specified index + * + * @param int|string $index Element's index or "FIRST" or "LAST" + * + * @return int Element value + */ + public function get_element($index) + { + $count = $this->count(); + + if (!$count) { + return null; + } + + // first element + if ($index === 0 || $index === '0' || $index === 'FIRST') { + $pos = strpos($this->raw_data, self::SEPARATOR_ELEMENT); + if ($pos === false) + $result = (int) $this->raw_data; + else + $result = (int) substr($this->raw_data, 0, $pos); + + return $result; + } + + // last element + if ($index === 'LAST' || $index == $count-1) { + $pos = strrpos($this->raw_data, self::SEPARATOR_ELEMENT); + if ($pos === false) + $result = (int) $this->raw_data; + else + $result = (int) substr($this->raw_data, $pos); + + return $result; + } + + // do we know the position of the element or the neighbour of it? + if (!empty($this->meta['pos'])) { + if (isset($this->meta['pos'][$index])) + $pos = $this->meta['pos'][$index]; + else if (isset($this->meta['pos'][$index-1])) + $pos = strpos($this->raw_data, self::SEPARATOR_ELEMENT, + $this->meta['pos'][$index-1] + 1); + else if (isset($this->meta['pos'][$index+1])) + $pos = strrpos($this->raw_data, self::SEPARATOR_ELEMENT, + $this->meta['pos'][$index+1] - $this->length() - 1); + + if (isset($pos) && preg_match('/([0-9]+)/', $this->raw_data, $m, null, $pos)) { + return (int) $m[1]; + } + } + + // Finally use less effective method + $data = explode(self::SEPARATOR_ELEMENT, $this->raw_data); + + return $data[$index]; + } + + + /** + * Returns response parameters, e.g. ESEARCH's MIN/MAX/COUNT/ALL/MODSEQ + * or internal data e.g. MAILBOX, ORDER + * + * @param string $param Parameter name + * + * @return array|string Response parameters or parameter value + */ + public function get_parameters($param=null) + { + $params = $this->params; + $params['MAILBOX'] = $this->mailbox; + $params['ORDER'] = $this->order; + + if ($param !== null) { + return $params[$param]; + } + + return $params; + } + + + /** + * Returns length of internal data representation + * + * @return int Data length + */ + protected function length() + { + if (!isset($this->meta['length'])) { + $this->meta['length'] = strlen($this->raw_data); + } + + return $this->meta['length']; + } +} diff --git a/program/lib/Roundcube/rcube_result_set.php b/program/lib/Roundcube/rcube_result_set.php new file mode 100644 index 000000000..456d1c9d6 --- /dev/null +++ b/program/lib/Roundcube/rcube_result_set.php @@ -0,0 +1,72 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_result_set.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2006-2011, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Class representing an address directory result set | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Roundcube result set class. + * Representing an address directory result set. + * + * @package Framework + * @subpackage Addressbook + */ +class rcube_result_set +{ + var $count = 0; + var $first = 0; + var $current = 0; + var $searchonly = false; + var $records = array(); + + + function __construct($c=0, $f=0) + { + $this->count = (int)$c; + $this->first = (int)$f; + } + + function add($rec) + { + $this->records[] = $rec; + } + + function iterate() + { + return $this->records[$this->current++]; + } + + function first() + { + $this->current = 0; + return $this->records[$this->current++]; + } + + // alias for iterate() + function next() + { + return $this->iterate(); + } + + function seek($i) + { + $this->current = $i; + } + +} diff --git a/program/lib/Roundcube/rcube_result_thread.php b/program/lib/Roundcube/rcube_result_thread.php new file mode 100644 index 000000000..c609bdc39 --- /dev/null +++ b/program/lib/Roundcube/rcube_result_thread.php @@ -0,0 +1,676 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_result_thread.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2011, The Roundcube Dev Team | + | Copyright (C) 2011, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | THREAD response handler | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class for accessing IMAP's THREAD result + * + * @package Framework + * @subpackage Storage + */ +class rcube_result_thread +{ + protected $raw_data; + protected $mailbox; + protected $meta = array(); + protected $order = 'ASC'; + + const SEPARATOR_ELEMENT = ' '; + const SEPARATOR_ITEM = '~'; + const SEPARATOR_LEVEL = ':'; + + + /** + * Object constructor. + */ + public function __construct($mailbox = null, $data = null) + { + $this->mailbox = $mailbox; + $this->init($data); + } + + + /** + * Initializes object with IMAP command response + * + * @param string $data IMAP response string + */ + public function init($data = null) + { + $this->meta = array(); + + $data = explode('*', (string)$data); + + // ...skip unilateral untagged server responses + for ($i=0, $len=count($data); $i<$len; $i++) { + if (preg_match('/^ THREAD/i', $data[$i])) { + // valid response, initialize raw_data for is_error() + $this->raw_data = ''; + $data[$i] = substr($data[$i], 7); + break; + } + + unset($data[$i]); + } + + if (empty($data)) { + return; + } + + $data = array_shift($data); + $data = trim($data); + $data = preg_replace('/[\r\n]/', '', $data); + $data = preg_replace('/\s+/', ' ', $data); + + $this->raw_data = $this->parse_thread($data); + } + + + /** + * Checks the result from IMAP command + * + * @return bool True if the result is an error, False otherwise + */ + public function is_error() + { + return $this->raw_data === null ? true : false; + } + + + /** + * Checks if the result is empty + * + * @return bool True if the result is empty, False otherwise + */ + public function is_empty() + { + return empty($this->raw_data) ? true : false; + } + + + /** + * Returns number of elements (threads) in the result + * + * @return int Number of elements + */ + public function count() + { + if ($this->meta['count'] !== null) + return $this->meta['count']; + + if (empty($this->raw_data)) { + $this->meta['count'] = 0; + } + else { + $this->meta['count'] = 1 + substr_count($this->raw_data, self::SEPARATOR_ELEMENT); + } + + if (!$this->meta['count']) + $this->meta['messages'] = 0; + + return $this->meta['count']; + } + + + /** + * Returns number of all messages in the result + * + * @return int Number of elements + */ + public function count_messages() + { + if ($this->meta['messages'] !== null) + return $this->meta['messages']; + + if (empty($this->raw_data)) { + $this->meta['messages'] = 0; + } + else { + $this->meta['messages'] = 1 + + substr_count($this->raw_data, self::SEPARATOR_ELEMENT) + + substr_count($this->raw_data, self::SEPARATOR_ITEM); + } + + if ($this->meta['messages'] == 0 || $this->meta['messages'] == 1) + $this->meta['count'] = $this->meta['messages']; + + return $this->meta['messages']; + } + + + /** + * Returns maximum message identifier in the result + * + * @return int Maximum message identifier + */ + public function max() + { + if (!isset($this->meta['max'])) { + $this->meta['max'] = (int) @max($this->get()); + } + return $this->meta['max']; + } + + + /** + * Returns minimum message identifier in the result + * + * @return int Minimum message identifier + */ + public function min() + { + if (!isset($this->meta['min'])) { + $this->meta['min'] = (int) @min($this->get()); + } + return $this->meta['min']; + } + + + /** + * Slices data set. + * + * @param $offset Offset (as for PHP's array_slice()) + * @param $length Number of elements (as for PHP's array_slice()) + */ + public function slice($offset, $length) + { + $data = explode(self::SEPARATOR_ELEMENT, $this->raw_data); + $data = array_slice($data, $offset, $length); + + $this->meta = array(); + $this->meta['count'] = count($data); + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data); + } + + + /** + * Filters data set. Removes threads not listed in $roots list. + * + * @param array $roots List of IDs of thread roots. + */ + public function filter($roots) + { + $datalen = strlen($this->raw_data); + $roots = array_flip($roots); + $result = ''; + $start = 0; + + $this->meta = array(); + $this->meta['count'] = 0; + + while (($pos = @strpos($this->raw_data, self::SEPARATOR_ELEMENT, $start)) + || ($start < $datalen && ($pos = $datalen)) + ) { + $len = $pos - $start; + $elem = substr($this->raw_data, $start, $len); + $start = $pos + 1; + + // extract root message ID + if ($npos = strpos($elem, self::SEPARATOR_ITEM)) { + $root = (int) substr($elem, 0, $npos); + } + else { + $root = $elem; + } + + if (isset($roots[$root])) { + $this->meta['count']++; + $result .= self::SEPARATOR_ELEMENT . $elem; + } + } + + $this->raw_data = ltrim($result, self::SEPARATOR_ELEMENT); + } + + + /** + * Reverts order of elements in the result + */ + public function revert() + { + $this->order = $this->order == 'ASC' ? 'DESC' : 'ASC'; + + if (empty($this->raw_data)) { + return; + } + + $this->meta['pos'] = array(); + $datalen = strlen($this->raw_data); + $result = ''; + $start = 0; + + while (($pos = @strpos($this->raw_data, self::SEPARATOR_ELEMENT, $start)) + || ($start < $datalen && ($pos = $datalen)) + ) { + $len = $pos - $start; + $elem = substr($this->raw_data, $start, $len); + $start = $pos + 1; + + $result = $elem . self::SEPARATOR_ELEMENT . $result; + } + + $this->raw_data = rtrim($result, self::SEPARATOR_ELEMENT); + } + + + /** + * Check if the given message ID exists in the object + * + * @param int $msgid Message ID + * @param bool $get_index When enabled element's index will be returned. + * Elements are indexed starting with 0 + * + * @return boolean True on success, False if message ID doesn't exist + */ + public function exists($msgid, $get_index = false) + { + $msgid = (int) $msgid; + $begin = implode('|', array( + '^', + preg_quote(self::SEPARATOR_ELEMENT, '/'), + preg_quote(self::SEPARATOR_LEVEL, '/'), + )); + $end = implode('|', array( + '$', + preg_quote(self::SEPARATOR_ELEMENT, '/'), + preg_quote(self::SEPARATOR_ITEM, '/'), + )); + + if (preg_match("/($begin)$msgid($end)/", $this->raw_data, $m, + $get_index ? PREG_OFFSET_CAPTURE : null) + ) { + if ($get_index) { + $idx = 0; + if ($m[0][1]) { + $idx = substr_count($this->raw_data, self::SEPARATOR_ELEMENT, 0, $m[0][1]+1) + + substr_count($this->raw_data, self::SEPARATOR_ITEM, 0, $m[0][1]+1); + } + // cache position of this element, so we can use it in get_element() + $this->meta['pos'][$idx] = (int)$m[0][1]; + + return $idx; + } + return true; + } + + return false; + } + + + /** + * Return IDs of all messages in the result. Threaded data will be flattened. + * + * @return array List of message identifiers + */ + public function get() + { + if (empty($this->raw_data)) { + return array(); + } + + $regexp = '/(' . preg_quote(self::SEPARATOR_ELEMENT, '/') + . '|' . preg_quote(self::SEPARATOR_ITEM, '/') . '[0-9]+' . preg_quote(self::SEPARATOR_LEVEL, '/') + .')/'; + + return preg_split($regexp, $this->raw_data); + } + + + /** + * Return all messages in the result. + * + * @return array List of message identifiers + */ + public function get_compressed() + { + if (empty($this->raw_data)) { + return ''; + } + + return rcube_imap_generic::compressMessageSet($this->get()); + } + + + /** + * Return result element at specified index (all messages, not roots) + * + * @param int|string $index Element's index or "FIRST" or "LAST" + * + * @return int Element value + */ + public function get_element($index) + { + $count = $this->count(); + + if (!$count) { + return null; + } + + // first element + if ($index === 0 || $index === '0' || $index === 'FIRST') { + preg_match('/^([0-9]+)/', $this->raw_data, $m); + $result = (int) $m[1]; + return $result; + } + + // last element + if ($index === 'LAST' || $index == $count-1) { + preg_match('/([0-9]+)$/', $this->raw_data, $m); + $result = (int) $m[1]; + return $result; + } + + // do we know the position of the element or the neighbour of it? + if (!empty($this->meta['pos'])) { + $element = preg_quote(self::SEPARATOR_ELEMENT, '/'); + $item = preg_quote(self::SEPARATOR_ITEM, '/') . '[0-9]+' . preg_quote(self::SEPARATOR_LEVEL, '/') .'?'; + $regexp = '(' . $element . '|' . $item . ')'; + + if (isset($this->meta['pos'][$index])) { + if (preg_match('/([0-9]+)/', $this->raw_data, $m, null, $this->meta['pos'][$index])) + $result = $m[1]; + } + else if (isset($this->meta['pos'][$index-1])) { + // get chunk of data after previous element + $data = substr($this->raw_data, $this->meta['pos'][$index-1]+1, 50); + $data = preg_replace('/^[0-9]+/', '', $data); // remove UID at $index position + $data = preg_replace("/^$regexp/", '', $data); // remove separator + if (preg_match('/^([0-9]+)/', $data, $m)) + $result = $m[1]; + } + else if (isset($this->meta['pos'][$index+1])) { + // get chunk of data before next element + $pos = max(0, $this->meta['pos'][$index+1] - 50); + $len = min(50, $this->meta['pos'][$index+1]); + $data = substr($this->raw_data, $pos, $len); + $data = preg_replace("/$regexp\$/", '', $data); // remove separator + + if (preg_match('/([0-9]+)$/', $data, $m)) + $result = $m[1]; + } + + if (isset($result)) { + return (int) $result; + } + } + + // Finally use less effective method + $data = $this->get(); + + return $data[$index]; + } + + + /** + * Returns response parameters e.g. MAILBOX, ORDER + * + * @param string $param Parameter name + * + * @return array|string Response parameters or parameter value + */ + public function get_parameters($param=null) + { + $params = $this->params; + $params['MAILBOX'] = $this->mailbox; + $params['ORDER'] = $this->order; + + if ($param !== null) { + return $params[$param]; + } + + return $params; + } + + + /** + * THREAD=REFS sorting implementation (based on provided index) + * + * @param rcube_result_index $index Sorted message identifiers + */ + public function sort($index) + { + $this->sort_order = $index->get_parameters('ORDER'); + + if (empty($this->raw_data)) { + return; + } + + // when sorting search result it's good to make the index smaller + if ($index->count() != $this->count_messages()) { + $index->intersect($this->get()); + } + + $result = array_fill_keys($index->get(), null); + $datalen = strlen($this->raw_data); + $start = 0; + + // Here we're parsing raw_data twice, we want only one big array + // in memory at a time + + // Assign roots + while (($pos = @strpos($this->raw_data, self::SEPARATOR_ELEMENT, $start)) + || ($start < $datalen && ($pos = $datalen)) + ) { + $len = $pos - $start; + $elem = substr($this->raw_data, $start, $len); + $start = $pos + 1; + + $items = explode(self::SEPARATOR_ITEM, $elem); + $root = (int) array_shift($items); + + if ($root) { + $result[$root] = $root; + foreach ($items as $item) { + list($lv, $id) = explode(self::SEPARATOR_LEVEL, $item); + $result[$id] = $root; + } + } + } + + // get only unique roots + $result = array_filter($result); // make sure there are no nulls + $result = array_unique($result); + + // Re-sort raw data + $result = array_fill_keys($result, null); + $start = 0; + + while (($pos = @strpos($this->raw_data, self::SEPARATOR_ELEMENT, $start)) + || ($start < $datalen && ($pos = $datalen)) + ) { + $len = $pos - $start; + $elem = substr($this->raw_data, $start, $len); + $start = $pos + 1; + + $npos = strpos($elem, self::SEPARATOR_ITEM); + $root = (int) ($npos ? substr($elem, 0, $npos) : $elem); + + $result[$root] = $elem; + } + + $this->raw_data = implode(self::SEPARATOR_ELEMENT, $result); + } + + + /** + * Returns data as tree + * + * @return array Data tree + */ + public function get_tree() + { + $datalen = strlen($this->raw_data); + $result = array(); + $start = 0; + + while (($pos = @strpos($this->raw_data, self::SEPARATOR_ELEMENT, $start)) + || ($start < $datalen && ($pos = $datalen)) + ) { + $len = $pos - $start; + $elem = substr($this->raw_data, $start, $len); + $items = explode(self::SEPARATOR_ITEM, $elem); + $result[array_shift($items)] = $this->build_thread($items); + $start = $pos + 1; + } + + return $result; + } + + + /** + * Returns thread depth and children data + * + * @return array Thread data + */ + public function get_thread_data() + { + $data = $this->get_tree(); + $depth = array(); + $children = array(); + + $this->build_thread_data($data, $depth, $children); + + return array($depth, $children); + } + + + /** + * Creates 'depth' and 'children' arrays from stored thread 'tree' data. + */ + protected function build_thread_data($data, &$depth, &$children, $level = 0) + { + foreach ((array)$data as $key => $val) { + $empty = empty($val) || !is_array($val); + $children[$key] = !$empty; + $depth[$key] = $level; + if (!$empty) { + $this->build_thread_data($val, $depth, $children, $level + 1); + } + } + } + + + /** + * Converts part of the raw thread into an array + */ + protected function build_thread($items, $level = 1, &$pos = 0) + { + $result = array(); + + for ($len=count($items); $pos < $len; $pos++) { + list($lv, $id) = explode(self::SEPARATOR_LEVEL, $items[$pos]); + if ($level == $lv) { + $pos++; + $result[$id] = $this->build_thread($items, $level+1, $pos); + } + else { + $pos--; + break; + } + } + + return $result; + } + + + /** + * IMAP THREAD response parser + */ + protected function parse_thread($str, $begin = 0, $end = 0, $depth = 0) + { + // Don't be tempted to change $str to pass by reference to speed this up - it will slow it down by about + // 7 times instead :-) See comments on http://uk2.php.net/references and this article: + // http://derickrethans.nl/files/phparch-php-variables-article.pdf + $node = ''; + if (!$end) { + $end = strlen($str); + } + + // Let's try to store data in max. compacted stracture as a string, + // arrays handling is much more expensive + // For the following structure: THREAD (2)(3 6 (4 23)(44 7 96)) + // -- 2 + // + // -- 3 + // \-- 6 + // |-- 4 + // | \-- 23 + // | + // \-- 44 + // \-- 7 + // \-- 96 + // + // The output will be: 2,3^1:6^2:4^3:23^2:44^3:7^4:96 + + if ($str[$begin] != '(') { + $stop = $begin + strspn($str, '1234567890', $begin, $end - $begin); + $msg = substr($str, $begin, $stop - $begin); + if (!$msg) { + return $node; + } + + $this->meta['messages']++; + + $node .= ($depth ? self::SEPARATOR_ITEM.$depth.self::SEPARATOR_LEVEL : '').$msg; + + if ($stop + 1 < $end) { + $node .= $this->parse_thread($str, $stop + 1, $end, $depth + 1); + } + } else { + $off = $begin; + while ($off < $end) { + $start = $off; + $off++; + $n = 1; + while ($n > 0) { + $p = strpos($str, ')', $off); + if ($p === false) { + // error, wrong structure, mismatched brackets in IMAP THREAD response + // @TODO: write error to the log or maybe set $this->raw_data = null; + return $node; + } + $p1 = strpos($str, '(', $off); + if ($p1 !== false && $p1 < $p) { + $off = $p1 + 1; + $n++; + } else { + $off = $p + 1; + $n--; + } + } + + $thread = $this->parse_thread($str, $start + 1, $off - 1, $depth); + if ($thread) { + if (!$depth) { + if ($node) { + $node .= self::SEPARATOR_ELEMENT; + } + } + $node .= $thread; + } + } + } + + return $node; + } +} diff --git a/program/lib/Roundcube/rcube_session.php b/program/lib/Roundcube/rcube_session.php new file mode 100644 index 000000000..fdbf668ca --- /dev/null +++ b/program/lib/Roundcube/rcube_session.php @@ -0,0 +1,632 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_session.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2011, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Provide database supported session management | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + +/** + * Class to provide database supported session storage + * + * @package Framework + * @subpackage Core + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_session +{ + private $db; + private $ip; + private $start; + private $changed; + private $unsets = array(); + private $gc_handlers = array(); + private $cookiename = 'roundcube_sessauth'; + private $vars; + private $key; + private $now; + private $secret = ''; + private $ip_check = false; + private $logging = false; + private $memcache; + + /** + * Default constructor + */ + public function __construct($db, $config) + { + $this->db = $db; + $this->start = microtime(true); + $this->ip = $_SERVER['REMOTE_ADDR']; + $this->logging = $config->get('log_session', false); + + $lifetime = $config->get('session_lifetime', 1) * 60; + $this->set_lifetime($lifetime); + + // use memcache backend + if ($config->get('session_storage', 'db') == 'memcache') { + $this->memcache = rcube::get_instance()->get_memcache(); + + // set custom functions for PHP session management if memcache is available + if ($this->memcache) { + session_set_save_handler( + array($this, 'open'), + array($this, 'close'), + array($this, 'mc_read'), + array($this, 'mc_write'), + array($this, 'mc_destroy'), + array($this, 'gc')); + } + else { + rcube::raise_error(array('code' => 604, 'type' => 'db', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => "Failed to connect to memcached. Please check configuration"), + true, true); + } + } + else { + // set custom functions for PHP session management + session_set_save_handler( + array($this, 'open'), + array($this, 'close'), + array($this, 'db_read'), + array($this, 'db_write'), + array($this, 'db_destroy'), + array($this, 'db_gc')); + } + } + + + public function open($save_path, $session_name) + { + return true; + } + + + public function close() + { + return true; + } + + + /** + * Delete session data for the given key + * + * @param string Session ID + */ + public function destroy($key) + { + return $this->memcache ? $this->mc_destroy($key) : $this->db_destroy($key); + } + + + /** + * Read session data from database + * + * @param string Session ID + * @return string Session vars + */ + public function db_read($key) + { + $sql_result = $this->db->query( + "SELECT vars, ip, changed FROM ".$this->db->table_name('session') + ." WHERE sess_id = ?", $key); + + if ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $this->changed = strtotime($sql_arr['changed']); + $this->ip = $sql_arr['ip']; + $this->vars = base64_decode($sql_arr['vars']); + $this->key = $key; + + return !empty($this->vars) ? (string) $this->vars : ''; + } + + return null; + } + + + /** + * Save session data. + * handler for session_read() + * + * @param string Session ID + * @param string Serialized session vars + * @return boolean True on success + */ + public function db_write($key, $vars) + { + $ts = microtime(true); + $now = $this->db->fromunixtime((int)$ts); + + // no session row in DB (db_read() returns false) + if (!$this->key) { + $oldvars = null; + } + // use internal data from read() for fast requests (up to 0.5 sec.) + else if ($key == $this->key && (!$this->vars || $ts - $this->start < 0.5)) { + $oldvars = $this->vars; + } + else { // else read data again from DB + $oldvars = $this->db_read($key); + } + + if ($oldvars !== null) { + $newvars = $this->_fixvars($vars, $oldvars); + + if ($newvars !== $oldvars) { + $this->db->query( + sprintf("UPDATE %s SET vars=?, changed=%s WHERE sess_id=?", + $this->db->table_name('session'), $now), + base64_encode($newvars), $key); + } + else if ($ts - $this->changed > $this->lifetime / 2) { + $this->db->query("UPDATE ".$this->db->table_name('session')." SET changed=$now WHERE sess_id=?", $key); + } + } + else { + $this->db->query( + sprintf("INSERT INTO %s (sess_id, vars, ip, created, changed) ". + "VALUES (?, ?, ?, %s, %s)", + $this->db->table_name('session'), $now, $now), + $key, base64_encode($vars), (string)$this->ip); + } + + return true; + } + + + /** + * Merge vars with old vars and apply unsets + */ + private function _fixvars($vars, $oldvars) + { + if ($oldvars !== null) { + $a_oldvars = $this->unserialize($oldvars); + if (is_array($a_oldvars)) { + foreach ((array)$this->unsets as $k) + unset($a_oldvars[$k]); + + $newvars = $this->serialize(array_merge( + (array)$a_oldvars, (array)$this->unserialize($vars))); + } + else + $newvars = $vars; + } + + $this->unsets = array(); + return $newvars; + } + + + /** + * Handler for session_destroy() + * + * @param string Session ID + * + * @return boolean True on success + */ + public function db_destroy($key) + { + if ($key) { + $this->db->query(sprintf("DELETE FROM %s WHERE sess_id = ?", $this->db->table_name('session')), $key); + } + + return true; + } + + + /** + * Garbage collecting function + * + * @param string Session lifetime in seconds + * @return boolean True on success + */ + public function db_gc($maxlifetime) + { + // just delete all expired sessions + $this->db->query( + sprintf("DELETE FROM %s WHERE changed < %s", + $this->db->table_name('session'), $this->db->fromunixtime(time() - $maxlifetime))); + + $this->gc(); + + return true; + } + + + /** + * Read session data from memcache + * + * @param string Session ID + * @return string Session vars + */ + public function mc_read($key) + { + if ($value = $this->memcache->get($key)) { + $arr = unserialize($value); + $this->changed = $arr['changed']; + $this->ip = $arr['ip']; + $this->vars = $arr['vars']; + $this->key = $key; + + return !empty($this->vars) ? (string) $this->vars : ''; + } + + return null; + } + + + /** + * Save session data. + * handler for session_read() + * + * @param string Session ID + * @param string Serialized session vars + * @return boolean True on success + */ + public function mc_write($key, $vars) + { + $ts = microtime(true); + + // no session data in cache (mc_read() returns false) + if (!$this->key) + $oldvars = null; + // use internal data for fast requests (up to 0.5 sec.) + else if ($key == $this->key && (!$this->vars || $ts - $this->start < 0.5)) + $oldvars = $this->vars; + else // else read data again + $oldvars = $this->mc_read($key); + + $newvars = $oldvars !== null ? $this->_fixvars($vars, $oldvars) : $vars; + + if ($newvars !== $oldvars || $ts - $this->changed > $this->lifetime / 2) + return $this->memcache->set($key, serialize(array('changed' => time(), 'ip' => $this->ip, 'vars' => $newvars)), MEMCACHE_COMPRESSED, $this->lifetime); + + return true; + } + + + /** + * Handler for session_destroy() with memcache backend + * + * @param string Session ID + * + * @return boolean True on success + */ + public function mc_destroy($key) + { + if ($key) { + // #1488592: use 2nd argument + $this->memcache->delete($key, 0); + } + + return true; + } + + + /** + * Execute registered garbage collector routines + */ + public function gc() + { + foreach ($this->gc_handlers as $fct) { + call_user_func($fct); + } + } + + + /** + * Register additional garbage collector functions + * + * @param mixed Callback function + */ + public function register_gc_handler($func) + { + foreach ($this->gc_handlers as $handler) { + if ($handler == $func) { + return; + } + } + + $this->gc_handlers[] = $func; + } + + + /** + * Generate and set new session id + * + * @param boolean $destroy If enabled the current session will be destroyed + */ + public function regenerate_id($destroy=true) + { + session_regenerate_id($destroy); + + $this->vars = null; + $this->key = session_id(); + + return true; + } + + + /** + * Unset a session variable + * + * @param string Varibale name + * @return boolean True on success + */ + public function remove($var=null) + { + if (empty($var)) + return $this->destroy(session_id()); + + $this->unsets[] = $var; + unset($_SESSION[$var]); + + return true; + } + + + /** + * Kill this session + */ + public function kill() + { + $this->vars = null; + $this->ip = $_SERVER['REMOTE_ADDR']; // update IP (might have changed) + $this->destroy(session_id()); + rcube_utils::setcookie($this->cookiename, '-del-', time() - 60); + } + + + /** + * Re-read session data from storage backend + */ + public function reload() + { + if ($this->key && $this->memcache) + $data = $this->mc_read($this->key); + else if ($this->key) + $data = $this->db_read($this->key); + + if ($data) + session_decode($data); + } + + + /** + * Serialize session data + */ + private function serialize($vars) + { + $data = ''; + if (is_array($vars)) + foreach ($vars as $var=>$value) + $data .= $var.'|'.serialize($value); + else + $data = 'b:0;'; + return $data; + } + + + /** + * Unserialize session data + * http://www.php.net/manual/en/function.session-decode.php#56106 + */ + private function unserialize($str) + { + $str = (string)$str; + $endptr = strlen($str); + $p = 0; + + $serialized = ''; + $items = 0; + $level = 0; + + while ($p < $endptr) { + $q = $p; + while ($str[$q] != '|') + if (++$q >= $endptr) break 2; + + if ($str[$p] == '!') { + $p++; + $has_value = false; + } else { + $has_value = true; + } + + $name = substr($str, $p, $q - $p); + $q++; + + $serialized .= 's:' . strlen($name) . ':"' . $name . '";'; + + if ($has_value) { + for (;;) { + $p = $q; + switch (strtolower($str[$q])) { + case 'n': /* null */ + case 'b': /* boolean */ + case 'i': /* integer */ + case 'd': /* decimal */ + do $q++; + while ( ($q < $endptr) && ($str[$q] != ';') ); + $q++; + $serialized .= substr($str, $p, $q - $p); + if ($level == 0) break 2; + break; + case 'r': /* reference */ + $q+= 2; + for ($id = ''; ($q < $endptr) && ($str[$q] != ';'); $q++) $id .= $str[$q]; + $q++; + $serialized .= 'R:' . ($id + 1) . ';'; /* increment pointer because of outer array */ + if ($level == 0) break 2; + break; + case 's': /* string */ + $q+=2; + for ($length=''; ($q < $endptr) && ($str[$q] != ':'); $q++) $length .= $str[$q]; + $q+=2; + $q+= (int)$length + 2; + $serialized .= substr($str, $p, $q - $p); + if ($level == 0) break 2; + break; + case 'a': /* array */ + case 'o': /* object */ + do $q++; + while ( ($q < $endptr) && ($str[$q] != '{') ); + $q++; + $level++; + $serialized .= substr($str, $p, $q - $p); + break; + case '}': /* end of array|object */ + $q++; + $serialized .= substr($str, $p, $q - $p); + if (--$level == 0) break 2; + break; + default: + return false; + } + } + } else { + $serialized .= 'N;'; + $q += 2; + } + $items++; + $p = $q; + } + + return unserialize( 'a:' . $items . ':{' . $serialized . '}' ); + } + + + /** + * Setter for session lifetime + */ + public function set_lifetime($lifetime) + { + $this->lifetime = max(120, $lifetime); + + // valid time range is now - 1/2 lifetime to now + 1/2 lifetime + $now = time(); + $this->now = $now - ($now % ($this->lifetime / 2)); + } + + + /** + * Getter for remote IP saved with this session + */ + public function get_ip() + { + return $this->ip; + } + + + /** + * Setter for cookie encryption secret + */ + function set_secret($secret) + { + $this->secret = $secret; + } + + + /** + * Enable/disable IP check + */ + function set_ip_check($check) + { + $this->ip_check = $check; + } + + + /** + * Setter for the cookie name used for session cookie + */ + function set_cookiename($cookiename) + { + if ($cookiename) + $this->cookiename = $cookiename; + } + + + /** + * Check session authentication cookie + * + * @return boolean True if valid, False if not + */ + function check_auth() + { + $this->cookie = $_COOKIE[$this->cookiename]; + $result = $this->ip_check ? $_SERVER['REMOTE_ADDR'] == $this->ip : true; + + if (!$result) + $this->log("IP check failed for " . $this->key . "; expected " . $this->ip . "; got " . $_SERVER['REMOTE_ADDR']); + + if ($result && $this->_mkcookie($this->now) != $this->cookie) { + $this->log("Session auth check failed for " . $this->key . "; timeslot = " . date('Y-m-d H:i:s', $this->now)); + $result = false; + + // Check if using id from a previous time slot + for ($i = 1; $i <= 2; $i++) { + $prev = $this->now - ($this->lifetime / 2) * $i; + if ($this->_mkcookie($prev) == $this->cookie) { + $this->log("Send new auth cookie for " . $this->key . ": " . $this->cookie); + $this->set_auth_cookie(); + $result = true; + } + } + } + + if (!$result) + $this->log("Session authentication failed for " . $this->key . "; invalid auth cookie sent; timeslot = " . date('Y-m-d H:i:s', $prev)); + + return $result; + } + + + /** + * Set session authentication cookie + */ + function set_auth_cookie() + { + $this->cookie = $this->_mkcookie($this->now); + rcube_utils::setcookie($this->cookiename, $this->cookie, 0); + $_COOKIE[$this->cookiename] = $this->cookie; + } + + + /** + * Create session cookie from session data + * + * @param int Time slot to use + */ + function _mkcookie($timeslot) + { + $auth_string = "$this->key,$this->secret,$timeslot"; + return "S" . (function_exists('sha1') ? sha1($auth_string) : md5($auth_string)); + } + + /** + * Writes debug information to the log + */ + function log($line) + { + if ($this->logging) + rcube::write_log('session', $line); + } + +} diff --git a/program/lib/Roundcube/rcube_smtp.php b/program/lib/Roundcube/rcube_smtp.php new file mode 100644 index 000000000..96534c0b8 --- /dev/null +++ b/program/lib/Roundcube/rcube_smtp.php @@ -0,0 +1,470 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_smtp.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2010, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Provide SMTP functionality using socket connections | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + +// define headers delimiter +define('SMTP_MIME_CRLF', "\r\n"); + +/** + * Class to provide SMTP functionality using PEAR Net_SMTP + * + * @package Framework + * @subpackage Mail + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +class rcube_smtp +{ + + private $conn = null; + private $response; + private $error; + + + /** + * SMTP Connection and authentication + * + * @param string Server host + * @param string Server port + * @param string User name + * @param string Password + * + * @return bool Returns true on success, or false on error + */ + public function connect($host=null, $port=null, $user=null, $pass=null) + { + $rcube = rcube::get_instance(); + + // disconnect/destroy $this->conn + $this->disconnect(); + + // reset error/response var + $this->error = $this->response = null; + + // let plugins alter smtp connection config + $CONFIG = $rcube->plugins->exec_hook('smtp_connect', array( + 'smtp_server' => $host ? $host : $rcube->config->get('smtp_server'), + 'smtp_port' => $port ? $port : $rcube->config->get('smtp_port', 25), + 'smtp_user' => $user ? $user : $rcube->config->get('smtp_user'), + 'smtp_pass' => $pass ? $pass : $rcube->config->get('smtp_pass'), + 'smtp_auth_cid' => $rcube->config->get('smtp_auth_cid'), + 'smtp_auth_pw' => $rcube->config->get('smtp_auth_pw'), + 'smtp_auth_type' => $rcube->config->get('smtp_auth_type'), + 'smtp_helo_host' => $rcube->config->get('smtp_helo_host'), + 'smtp_timeout' => $rcube->config->get('smtp_timeout'), + 'smtp_auth_callbacks' => array(), + )); + + $smtp_host = rcube_utils::parse_host($CONFIG['smtp_server']); + // when called from Installer it's possible to have empty $smtp_host here + if (!$smtp_host) $smtp_host = 'localhost'; + $smtp_port = is_numeric($CONFIG['smtp_port']) ? $CONFIG['smtp_port'] : 25; + $smtp_host_url = parse_url($smtp_host); + + // overwrite port + if (isset($smtp_host_url['host']) && isset($smtp_host_url['port'])) + { + $smtp_host = $smtp_host_url['host']; + $smtp_port = $smtp_host_url['port']; + } + + // re-write smtp host + if (isset($smtp_host_url['host']) && isset($smtp_host_url['scheme'])) + $smtp_host = sprintf('%s://%s', $smtp_host_url['scheme'], $smtp_host_url['host']); + + // remove TLS prefix and set flag for use in Net_SMTP::auth() + if (preg_match('#^tls://#i', $smtp_host)) { + $smtp_host = preg_replace('#^tls://#i', '', $smtp_host); + $use_tls = true; + } + + if (!empty($CONFIG['smtp_helo_host'])) + $helo_host = $CONFIG['smtp_helo_host']; + else if (!empty($_SERVER['SERVER_NAME'])) + $helo_host = preg_replace('/:\d+$/', '', $_SERVER['SERVER_NAME']); + else + $helo_host = 'localhost'; + + // IDNA Support + $smtp_host = rcube_utils::idn_to_ascii($smtp_host); + + $this->conn = new Net_SMTP($smtp_host, $smtp_port, $helo_host); + + if ($rcube->config->get('smtp_debug')) + $this->conn->setDebug(true, array($this, 'debug_handler')); + + // register authentication methods + if (!empty($CONFIG['smtp_auth_callbacks']) && method_exists($this->conn, 'setAuthMethod')) { + foreach ($CONFIG['smtp_auth_callbacks'] as $callback) { + $this->conn->setAuthMethod($callback['name'], $callback['function'], + isset($callback['prepend']) ? $callback['prepend'] : true); + } + } + + // try to connect to server and exit on failure + $result = $this->conn->connect($smtp_timeout); + + if (PEAR::isError($result)) { + $this->response[] = "Connection failed: ".$result->getMessage(); + $this->error = array('label' => 'smtpconnerror', 'vars' => array('code' => $this->conn->_code)); + $this->conn = null; + return false; + } + + // workaround for timeout bug in Net_SMTP 1.5.[0-1] (#1487843) + if (method_exists($this->conn, 'setTimeout') + && ($timeout = ini_get('default_socket_timeout')) + ) { + $this->conn->setTimeout($timeout); + } + + $smtp_user = str_replace('%u', $rcube->get_user_name(), $CONFIG['smtp_user']); + $smtp_pass = str_replace('%p', $rcube->get_user_password(), $CONFIG['smtp_pass']); + $smtp_auth_type = empty($CONFIG['smtp_auth_type']) ? NULL : $CONFIG['smtp_auth_type']; + + if (!empty($CONFIG['smtp_auth_cid'])) { + $smtp_authz = $smtp_user; + $smtp_user = $CONFIG['smtp_auth_cid']; + $smtp_pass = $CONFIG['smtp_auth_pw']; + } + + // attempt to authenticate to the SMTP server + if ($smtp_user && $smtp_pass) + { + // IDNA Support + if (strpos($smtp_user, '@')) { + $smtp_user = rcube_utils::idn_to_ascii($smtp_user); + } + + $result = $this->conn->auth($smtp_user, $smtp_pass, $smtp_auth_type, $use_tls, $smtp_authz); + + if (PEAR::isError($result)) + { + $this->error = array('label' => 'smtpautherror', 'vars' => array('code' => $this->conn->_code)); + $this->response[] .= 'Authentication failure: ' . $result->getMessage() . ' (Code: ' . $result->getCode() . ')'; + $this->reset(); + $this->disconnect(); + return false; + } + } + + return true; + } + + + /** + * Function for sending mail + * + * @param string Sender e-Mail address + * + * @param mixed 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 mixed The message headers to send with the mail + * Either as an associative array or a finally + * formatted string + * @param mixed The full text of the message body, including any Mime parts + * or file handle + * @param array Delivery options (e.g. DSN request) + * + * @return bool Returns true on success, or false on error + */ + public function send_mail($from, $recipients, &$headers, &$body, $opts=null) + { + if (!is_object($this->conn)) + return false; + + // prepare message headers as string + if (is_array($headers)) + { + if (!($headerElements = $this->_prepare_headers($headers))) { + $this->reset(); + return false; + } + + list($from, $text_headers) = $headerElements; + } + else if (is_string($headers)) + $text_headers = $headers; + else + { + $this->reset(); + $this->response[] = "Invalid message headers"; + return false; + } + + // exit if no from address is given + if (!isset($from)) + { + $this->reset(); + $this->response[] = "No From address has been provided"; + return false; + } + + // RFC3461: Delivery Status Notification + if ($opts['dsn']) { + $exts = $this->conn->getServiceExtensions(); + + if (isset($exts['DSN'])) { + $from_params = 'RET=HDRS'; + $recipient_params = 'NOTIFY=SUCCESS,FAILURE'; + } + } + + // RFC2298.3: remove envelope sender address + if (preg_match('/Content-Type: multipart\/report/', $text_headers) + && preg_match('/report-type=disposition-notification/', $text_headers) + ) { + $from = ''; + } + + // set From: address + if (PEAR::isError($this->conn->mailFrom($from, $from_params))) + { + $err = $this->conn->getResponse(); + $this->error = array('label' => 'smtpfromerror', 'vars' => array( + 'from' => $from, 'code' => $this->conn->_code, 'msg' => $err[1])); + $this->response[] = "Failed to set sender '$from'"; + $this->reset(); + return false; + } + + // prepare list of recipients + $recipients = $this->_parse_rfc822($recipients); + if (PEAR::isError($recipients)) + { + $this->error = array('label' => 'smtprecipientserror'); + $this->reset(); + return false; + } + + // set mail recipients + foreach ($recipients as $recipient) + { + if (PEAR::isError($this->conn->rcptTo($recipient, $recipient_params))) { + $err = $this->conn->getResponse(); + $this->error = array('label' => 'smtptoerror', 'vars' => array( + 'to' => $recipient, 'code' => $this->conn->_code, 'msg' => $err[1])); + $this->response[] = "Failed to add recipient '$recipient'"; + $this->reset(); + return false; + } + } + + if (is_resource($body)) + { + // file handle + $data = $body; + $text_headers = preg_replace('/[\r\n]+$/', '', $text_headers); + } else { + // Concatenate headers and body so it can be passed by reference to SMTP_CONN->data + // so preg_replace in SMTP_CONN->quotedata will store a reference instead of a copy. + // We are still forced to make another copy here for a couple ticks so we don't really + // get to save a copy in the method call. + $data = $text_headers . "\r\n" . $body; + + // unset old vars to save data and so we can pass into SMTP_CONN->data by reference. + unset($text_headers, $body); + } + + // Send the message's headers and the body as SMTP data. + if (PEAR::isError($result = $this->conn->data($data, $text_headers))) + { + $err = $this->conn->getResponse(); + if (!in_array($err[0], array(354, 250, 221))) + $msg = sprintf('[%d] %s', $err[0], $err[1]); + else + $msg = $result->getMessage(); + + $this->error = array('label' => 'smtperror', 'vars' => array('msg' => $msg)); + $this->response[] = "Failed to send data"; + $this->reset(); + return false; + } + + $this->response[] = join(': ', $this->conn->getResponse()); + return true; + } + + + /** + * Reset the global SMTP connection + * @access public + */ + public function reset() + { + if (is_object($this->conn)) + $this->conn->rset(); + } + + + /** + * Disconnect the global SMTP connection + * @access public + */ + public function disconnect() + { + if (is_object($this->conn)) { + $this->conn->disconnect(); + $this->conn = null; + } + } + + + /** + * This is our own debug handler for the SMTP connection + * @access public + */ + public function debug_handler(&$smtp, $message) + { + rcube::write_log('smtp', preg_replace('/\r\n$/', '', $message)); + } + + + /** + * Get error message + * @access public + */ + public function get_error() + { + return $this->error; + } + + + /** + * Get server response messages array + * @access public + */ + public function get_response() + { + return $this->response; + } + + + /** + * 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'. + * + * @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. + * @access private + */ + private function _prepare_headers($headers) + { + $lines = array(); + $from = null; + + foreach ($headers as $key => $value) + { + if (strcasecmp($key, 'From') === 0) + { + $addresses = $this->_parse_rfc822($value); + + if (is_array($addresses)) + $from = $addresses[0]; + + // Reject envelope From: addresses with spaces. + if (strpos($from, ' ') !== false) + return false; + + $lines[] = $key . ': ' . $value; + } + else if (strcasecmp($key, 'Received') === 0) + { + $received = array(); + if (is_array($value)) + { + foreach ($value as $line) + $received[] = $key . ': ' . $line; + } + else + { + $received[] = $key . ': ' . $value; + } + + // 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, join(SMTP_MIME_CRLF, $lines) . SMTP_MIME_CRLF); + } + + /** + * 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-seperated list of recipients + * (RFC822 compliant), or an array of recipients, + * each RFC822 valid. + * + * @return array An array of forward paths (bare addresses). + * @access private + */ + private function _parse_rfc822($recipients) + { + // if we're passed an array, assume addresses are valid and implode them before parsing. + if (is_array($recipients)) + $recipients = implode(', ', $recipients); + + $addresses = array(); + $recipients = rcube_utils::explode_quoted_string(',', $recipients); + + reset($recipients); + while (list($k, $recipient) = each($recipients)) + { + $a = rcube_utils::explode_quoted_string(' ', $recipient); + while (list($k2, $word) = each($a)) + { + if (strpos($word, "@") > 0 && $word[strlen($word)-1] != '"') + { + $word = preg_replace('/^<|>$/', '', trim($word)); + if (in_array($word, $addresses)===false) + array_push($addresses, $word); + } + } + } + + return $addresses; + } + +} diff --git a/program/lib/Roundcube/rcube_spellchecker.php b/program/lib/Roundcube/rcube_spellchecker.php new file mode 100644 index 000000000..fce2cac75 --- /dev/null +++ b/program/lib/Roundcube/rcube_spellchecker.php @@ -0,0 +1,621 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_spellchecker.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2011, Kolab Systems AG | + | Copyright (C) 2008-2011, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Spellchecking using different backends | + | | + +-----------------------------------------------------------------------+ + | Author: Aleksander Machniak <machniak@kolabsys.com> | + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Helper class for spellchecking with Googielspell and PSpell support. + * + * @package Framework + * @subpackage Utils + */ +class rcube_spellchecker +{ + private $matches = array(); + private $engine; + private $lang; + private $rc; + private $error; + private $separator = '/[\s\r\n\t\(\)\/\[\]{}<>\\"]+|[:;?!,\.]([^\w]|$)/'; + private $options = array(); + private $dict; + private $have_dict; + + + // default settings + const GOOGLE_HOST = 'ssl://www.google.com'; + const GOOGLE_PORT = 443; + const MAX_SUGGESTIONS = 10; + + + /** + * Constructor + * + * @param string $lang Language code + */ + function __construct($lang = 'en') + { + $this->rc = rcube::get_instance(); + $this->engine = $this->rc->config->get('spellcheck_engine', 'googie'); + $this->lang = $lang ? $lang : 'en'; + + $this->options = array( + 'ignore_syms' => $this->rc->config->get('spellcheck_ignore_syms'), + 'ignore_nums' => $this->rc->config->get('spellcheck_ignore_nums'), + 'ignore_caps' => $this->rc->config->get('spellcheck_ignore_caps'), + 'dictionary' => $this->rc->config->get('spellcheck_dictionary'), + ); + } + + + /** + * Set content and check spelling + * + * @param string $text Text content for spellchecking + * @param bool $is_html Enables HTML-to-Text conversion + * + * @return bool True when no mispelling found, otherwise false + */ + function check($text, $is_html = false) + { + // convert to plain text + if ($is_html) { + $this->content = $this->html2text($text); + } + else { + $this->content = $text; + } + + if ($this->engine == 'pspell') { + $this->matches = $this->_pspell_check($this->content); + } + else { + $this->matches = $this->_googie_check($this->content); + } + + return $this->found() == 0; + } + + + /** + * Number of mispellings found (after check) + * + * @return int Number of mispellings + */ + function found() + { + return count($this->matches); + } + + + /** + * Returns suggestions for the specified word + * + * @param string $word The word + * + * @return array Suggestions list + */ + function get_suggestions($word) + { + if ($this->engine == 'pspell') { + return $this->_pspell_suggestions($word); + } + + return $this->_googie_suggestions($word); + } + + + /** + * Returns misspelled words + * + * @param string $text The content for spellchecking. If empty content + * used for check() method will be used. + * + * @return array List of misspelled words + */ + function get_words($text = null, $is_html=false) + { + if ($this->engine == 'pspell') { + return $this->_pspell_words($text, $is_html); + } + + return $this->_googie_words($text, $is_html); + } + + + /** + * Returns checking result in XML (Googiespell) format + * + * @return string XML content + */ + function get_xml() + { + // send output + $out = '<?xml version="1.0" encoding="'.RCUBE_CHARSET.'"?><spellresult charschecked="'.mb_strlen($this->content).'">'; + + foreach ($this->matches as $item) { + $out .= '<c o="'.$item[1].'" l="'.$item[2].'">'; + $out .= is_array($item[4]) ? implode("\t", $item[4]) : $item[4]; + $out .= '</c>'; + } + + $out .= '</spellresult>'; + + return $out; + } + + + /** + * Returns checking result (misspelled words with suggestions) + * + * @return array Spellchecking result. An array indexed by word. + */ + function get() + { + $result = array(); + + foreach ($this->matches as $item) { + if ($this->engine == 'pspell') { + $word = $item[0]; + } + else { + $word = mb_substr($this->content, $item[1], $item[2], RCUBE_CHARSET); + } + $result[$word] = is_array($item[4]) ? implode("\t", $item[4]) : $item[4]; + } + + return $result; + } + + + /** + * Returns error message + * + * @return string Error message + */ + function error() + { + return $this->error; + } + + + /** + * Checks the text using pspell + * + * @param string $text Text content for spellchecking + */ + private function _pspell_check($text) + { + // init spellchecker + $this->_pspell_init(); + + if (!$this->plink) { + return array(); + } + + // tokenize + $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE); + + $diff = 0; + $matches = array(); + + foreach ($text as $w) { + $word = trim($w[0]); + $pos = $w[1] - $diff; + $len = mb_strlen($word); + + // skip exceptions + if ($this->is_exception($word)) { + } + else if (!pspell_check($this->plink, $word)) { + $suggestions = pspell_suggest($this->plink, $word); + + if (sizeof($suggestions) > self::MAX_SUGGESTIONS) { + $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS); + } + + $matches[] = array($word, $pos, $len, null, $suggestions); + } + + $diff += (strlen($word) - $len); + } + + return $matches; + } + + + /** + * Returns the misspelled words + */ + private function _pspell_words($text = null, $is_html=false) + { + $result = array(); + + if ($text) { + // init spellchecker + $this->_pspell_init(); + + if (!$this->plink) { + return array(); + } + + // With PSpell we don't need to get suggestions to return misspelled words + if ($is_html) { + $text = $this->html2text($text); + } + + $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE); + + foreach ($text as $w) { + $word = trim($w[0]); + + // skip exceptions + if ($this->is_exception($word)) { + continue; + } + + if (!pspell_check($this->plink, $word)) { + $result[] = $word; + } + } + + return $result; + } + + foreach ($this->matches as $m) { + $result[] = $m[0]; + } + + return $result; + } + + + /** + * Returns suggestions for misspelled word + */ + private function _pspell_suggestions($word) + { + // init spellchecker + $this->_pspell_init(); + + if (!$this->plink) { + return array(); + } + + $suggestions = pspell_suggest($this->plink, $word); + + if (sizeof($suggestions) > self::MAX_SUGGESTIONS) + $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS); + + return is_array($suggestions) ? $suggestions : array(); + } + + + /** + * Initializes PSpell dictionary + */ + private function _pspell_init() + { + if (!$this->plink) { + if (!extension_loaded('pspell')) { + $this->error = "Pspell extension not available"; + rcube::raise_error(array( + 'code' => 500, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => $this->error), true, false); + + return; + } + + $this->plink = pspell_new($this->lang, null, null, RCUBE_CHARSET, PSPELL_FAST); + } + + if (!$this->plink) { + $this->error = "Unable to load Pspell engine for selected language"; + } + } + + + private function _googie_check($text) + { + // spell check uri is configured + $url = $this->rc->config->get('spellcheck_uri'); + + if ($url) { + $a_uri = parse_url($url); + $ssl = ($a_uri['scheme'] == 'https' || $a_uri['scheme'] == 'ssl'); + $port = $a_uri['port'] ? $a_uri['port'] : ($ssl ? 443 : 80); + $host = ($ssl ? 'ssl://' : '') . $a_uri['host']; + $path = $a_uri['path'] . ($a_uri['query'] ? '?'.$a_uri['query'] : '') . $this->lang; + } + else { + $host = self::GOOGLE_HOST; + $port = self::GOOGLE_PORT; + $path = '/tbproxy/spell?lang=' . $this->lang; + } + + // Google has some problem with spaces, use \n instead + $gtext = str_replace(' ', "\n", $text); + + $gtext = '<?xml version="1.0" encoding="utf-8" ?>' + .'<spellrequest textalreadyclipped="0" ignoredups="0" ignoredigits="1" ignoreallcaps="1">' + .'<text>' . $gtext . '</text>' + .'</spellrequest>'; + + $store = ''; + if ($fp = fsockopen($host, $port, $errno, $errstr, 30)) { + $out = "POST $path HTTP/1.0\r\n"; + $out .= "Host: " . str_replace('ssl://', '', $host) . "\r\n"; + $out .= "Content-Length: " . strlen($gtext) . "\r\n"; + $out .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $out .= "Connection: Close\r\n\r\n"; + $out .= $gtext; + fwrite($fp, $out); + + while (!feof($fp)) + $store .= fgets($fp, 128); + fclose($fp); + } + + if (!$store) { + $this->error = "Empty result from spelling engine"; + } + + preg_match_all('/<c o="([^"]*)" l="([^"]*)" s="([^"]*)">([^<]*)<\/c>/', $store, $matches, PREG_SET_ORDER); + + // skip exceptions (if appropriate options are enabled) + if (!empty($this->options['ignore_syms']) || !empty($this->options['ignore_nums']) + || !empty($this->options['ignore_caps']) || !empty($this->options['dictionary']) + ) { + foreach ($matches as $idx => $m) { + $word = mb_substr($text, $m[1], $m[2], RCUBE_CHARSET); + // skip exceptions + if ($this->is_exception($word)) { + unset($matches[$idx]); + } + } + } + + return $matches; + } + + + private function _googie_words($text = null, $is_html=false) + { + if ($text) { + if ($is_html) { + $text = $this->html2text($text); + } + + $matches = $this->_googie_check($text); + } + else { + $matches = $this->matches; + $text = $this->content; + } + + $result = array(); + + foreach ($matches as $m) { + $result[] = mb_substr($text, $m[1], $m[2], RCUBE_CHARSET); + } + + return $result; + } + + + private function _googie_suggestions($word) + { + if ($word) { + $matches = $this->_googie_check($word); + } + else { + $matches = $this->matches; + } + + if ($matches[0][4]) { + $suggestions = explode("\t", $matches[0][4]); + if (sizeof($suggestions) > self::MAX_SUGGESTIONS) { + $suggestions = array_slice($suggestions, 0, MAX_SUGGESTIONS); + } + + return $suggestions; + } + + return array(); + } + + + private function html2text($text) + { + $h2t = new html2text($text, false, true, 0); + return $h2t->get_text(); + } + + + /** + * Check if the specified word is an exception accoring to + * spellcheck options. + * + * @param string $word The word + * + * @return bool True if the word is an exception, False otherwise + */ + public function is_exception($word) + { + // Contain only symbols (e.g. "+9,0", "2:2") + if (!$word || preg_match('/^[0-9@#$%^&_+~*=:;?!,.-]+$/', $word)) + return true; + + // Contain symbols (e.g. "g@@gle"), all symbols excluding separators + if (!empty($this->options['ignore_syms']) && preg_match('/[@#$%^&_+~*=-]/', $word)) + return true; + + // Contain numbers (e.g. "g00g13") + if (!empty($this->options['ignore_nums']) && preg_match('/[0-9]/', $word)) + return true; + + // Blocked caps (e.g. "GOOGLE") + if (!empty($this->options['ignore_caps']) && $word == mb_strtoupper($word)) + return true; + + // Use exceptions from dictionary + if (!empty($this->options['dictionary'])) { + $this->load_dict(); + + // @TODO: should dictionary be case-insensitive? + if (!empty($this->dict) && in_array($word, $this->dict)) + return true; + } + + return false; + } + + + /** + * Add a word to dictionary + * + * @param string $word The word to add + */ + public function add_word($word) + { + $this->load_dict(); + + foreach (explode(' ', $word) as $word) { + // sanity check + if (strlen($word) < 512) { + $this->dict[] = $word; + $valid = true; + } + } + + if ($valid) { + $this->dict = array_unique($this->dict); + $this->update_dict(); + } + } + + + /** + * Remove a word from dictionary + * + * @param string $word The word to remove + */ + public function remove_word($word) + { + $this->load_dict(); + + if (($key = array_search($word, $this->dict)) !== false) { + unset($this->dict[$key]); + $this->update_dict(); + } + } + + + /** + * Update dictionary row in DB + */ + private function update_dict() + { + if (strcasecmp($this->options['dictionary'], 'shared') != 0) { + $userid = $this->rc->get_user_id(); + } + + $plugin = $this->rc->plugins->exec_hook('spell_dictionary_save', array( + 'userid' => $userid, 'language' => $this->lang, 'dictionary' => $this->dict)); + + if (!empty($plugin['abort'])) { + return; + } + + if ($this->have_dict) { + if (!empty($this->dict)) { + $this->rc->db->query( + "UPDATE ".$this->rc->db->table_name('dictionary') + ." SET data = ?" + ." WHERE user_id " . ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL") + ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?", + implode(' ', $plugin['dictionary']), $plugin['language']); + } + // don't store empty dict + else { + $this->rc->db->query( + "DELETE FROM " . $this->rc->db->table_name('dictionary') + ." WHERE user_id " . ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL") + ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?", + $plugin['language']); + } + } + else if (!empty($this->dict)) { + $this->rc->db->query( + "INSERT INTO " .$this->rc->db->table_name('dictionary') + ." (user_id, " . $this->rc->db->quoteIdentifier('language') . ", data) VALUES (?, ?, ?)", + $plugin['userid'], $plugin['language'], implode(' ', $plugin['dictionary'])); + } + } + + + /** + * Get dictionary from DB + */ + private function load_dict() + { + if (is_array($this->dict)) { + return $this->dict; + } + + if (strcasecmp($this->options['dictionary'], 'shared') != 0) { + $userid = $this->rc->get_user_id(); + } + + $plugin = $this->rc->plugins->exec_hook('spell_dictionary_get', array( + 'userid' => $userid, 'language' => $this->lang, 'dictionary' => array())); + + if (empty($plugin['abort'])) { + $dict = array(); + $this->rc->db->query( + "SELECT data FROM ".$this->rc->db->table_name('dictionary') + ." WHERE user_id ". ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL") + ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?", + $plugin['language']); + + if ($sql_arr = $this->rc->db->fetch_assoc($sql_result)) { + $this->have_dict = true; + if (!empty($sql_arr['data'])) { + $dict = explode(' ', $sql_arr['data']); + } + } + + $plugin['dictionary'] = array_merge((array)$plugin['dictionary'], $dict); + } + + if (!empty($plugin['dictionary']) && is_array($plugin['dictionary'])) { + $this->dict = $plugin['dictionary']; + } + else { + $this->dict = array(); + } + + return $this->dict; + } + +} diff --git a/program/lib/Roundcube/rcube_storage.php b/program/lib/Roundcube/rcube_storage.php new file mode 100644 index 000000000..245d911c0 --- /dev/null +++ b/program/lib/Roundcube/rcube_storage.php @@ -0,0 +1,992 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_storage.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | Copyright (C) 2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Mail Storage Engine | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Abstract class for accessing mail messages storage server + * + * @package Framework + * @subpackage Storage + * @author Thomas Bruederli <roundcube@gmail.com> + * @author Aleksander Machniak <alec@alec.pl> + */ +abstract class rcube_storage +{ + /** + * Instance of connection object e.g. rcube_imap_generic + * + * @var mixed + */ + public $conn; + + protected $folder = 'INBOX'; + protected $default_charset = 'ISO-8859-1'; + protected $default_folders = array('INBOX'); + protected $search_set; + protected $options = array('auth_method' => 'check'); + protected $page_size = 10; + protected $threading = false; + + /** + * All (additional) headers used (in any way) by Roundcube + * Not listed here: DATE, FROM, TO, CC, REPLY-TO, SUBJECT, CONTENT-TYPE, LIST-POST + * (used for messages listing) are hardcoded in rcube_imap_generic::fetchHeaders() + * + * @var array + */ + protected $all_headers = array( + 'IN-REPLY-TO', + 'BCC', + 'MESSAGE-ID', + 'CONTENT-TRANSFER-ENCODING', + 'REFERENCES', + 'X-DRAFT-INFO', + 'MAIL-FOLLOWUP-TO', + 'MAIL-REPLY-TO', + 'RETURN-PATH', + 'DELIVERED-TO', + ); + + const UNKNOWN = 0; + const NOPERM = 1; + const READONLY = 2; + const TRYCREATE = 3; + const INUSE = 4; + const OVERQUOTA = 5; + const ALREADYEXISTS = 6; + const NONEXISTENT = 7; + const CONTACTADMIN = 8; + + + /** + * Connect to the server + * + * @param string $host Host to connect + * @param string $user Username for IMAP account + * @param string $pass Password for IMAP account + * @param integer $port Port to connect to + * @param string $use_ssl SSL schema (either ssl or tls) or null if plain connection + * + * @return boolean TRUE on success, FALSE on failure + */ + abstract function connect($host, $user, $pass, $port = 143, $use_ssl = null); + + + /** + * Close connection. Usually done on script shutdown + */ + abstract function close(); + + + /** + * Checks connection state. + * + * @return boolean TRUE on success, FALSE on failure + */ + abstract function is_connected(); + + + /** + * Check connection state, connect if not connected. + * + * @return bool Connection state. + */ + abstract function check_connection(); + + + /** + * Returns code of last error + * + * @return int Error code + */ + abstract function get_error_code(); + + + /** + * Returns message of last error + * + * @return string Error message + */ + abstract function get_error_str(); + + + /** + * Returns code of last command response + * + * @return int Response code (class constant) + */ + abstract function get_response_code(); + + + /** + * Set connection and class options + * + * @param array $opt Options array + */ + public function set_options($opt) + { + $this->options = array_merge($this->options, (array)$opt); + } + + + /** + * Activate/deactivate debug mode. + * + * @param boolean $dbg True if conversation with the server should be logged + */ + abstract function set_debug($dbg = true); + + + /** + * Set default message charset. + * + * This will be used for message decoding if a charset specification is not available + * + * @param string $cs Charset string + */ + public function set_charset($cs) + { + $this->default_charset = $cs; + } + + + /** + * This list of folders will be listed above all other folders + * + * @param array $arr Indexed list of folder names + */ + public function set_default_folders($arr) + { + if (is_array($arr)) { + $this->default_folders = $arr; + + // add inbox if not included + if (!in_array('INBOX', $this->default_folders)) { + array_unshift($this->default_folders, 'INBOX'); + } + } + } + + + /** + * Set internal folder reference. + * All operations will be perfomed on this folder. + * + * @param string $folder Folder name + */ + public function set_folder($folder) + { + if ($this->folder === $folder) { + return; + } + + $this->folder = $folder; + } + + + /** + * Returns the currently used folder name + * + * @return string Name of the folder + */ + public function get_folder() + { + return $this->folder; + } + + + /** + * Set internal list page number. + * + * @param int $page Page number to list + */ + public function set_page($page) + { + $this->list_page = (int) $page; + } + + + /** + * Gets internal list page number. + * + * @return int Page number + */ + public function get_page() + { + return $this->list_page; + } + + + /** + * Set internal page size + * + * @param int $size Number of messages to display on one page + */ + public function set_pagesize($size) + { + $this->page_size = (int) $size; + } + + + /** + * Get internal page size + * + * @return int Number of messages to display on one page + */ + public function get_pagesize() + { + return $this->page_size; + } + + + /** + * Save a search result for future message listing methods. + * + * @param mixed $set Search set in driver specific format + */ + abstract function set_search_set($set); + + + /** + * Return the saved search set. + * + * @return array Search set in driver specific format, NULL if search wasn't initialized + */ + abstract function get_search_set(); + + + /** + * Returns the storage server's (IMAP) capability + * + * @param string $cap Capability name + * + * @return mixed Capability value or TRUE if supported, FALSE if not + */ + abstract function get_capability($cap); + + + /** + * Sets threading flag to the best supported THREAD algorithm. + * Enable/Disable threaded mode. + * + * @param boolean $enable TRUE to enable and FALSE + * + * @return mixed Threading algorithm or False if THREAD is not supported + */ + public function set_threading($enable = false) + { + $this->threading = false; + + if ($enable && ($caps = $this->get_capability('THREAD'))) { + $methods = array('REFS', 'REFERENCES', 'ORDEREDSUBJECT'); + $methods = array_intersect($methods, $caps); + + $this->threading = array_shift($methods); + } + + return $this->threading; + } + + + /** + * Get current threading flag. + * + * @return mixed Threading algorithm or False if THREAD is not supported or disabled + */ + public function get_threading() + { + return $this->threading; + } + + + /** + * Checks the PERMANENTFLAGS capability of the current folder + * and returns true if the given flag is supported by the server. + * + * @param string $flag Permanentflag name + * + * @return boolean True if this flag is supported + */ + abstract function check_permflag($flag); + + + /** + * Returns the delimiter that is used by the server + * for folder hierarchy separation. + * + * @return string Delimiter string + */ + abstract function get_hierarchy_delimiter(); + + + /** + * Get namespace + * + * @param string $name Namespace array index: personal, other, shared, prefix + * + * @return array Namespace data + */ + abstract function get_namespace($name = null); + + + /** + * Get messages count for a specific folder. + * + * @param string $folder Folder name + * @param string $mode Mode for count [ALL|THREADS|UNSEEN|RECENT] + * @param boolean $force Force reading from server and update cache + * @param boolean $status Enables storing folder status info (max UID/count), + * required for folder_status() + * + * @return int Number of messages + */ + abstract function count($folder = null, $mode = 'ALL', $force = false, $status = true); + + + /** + * Public method for listing headers. + * + * @param string $folder Folder name + * @param int $page Current page to list + * @param string $sort_field Header field to sort by + * @param string $sort_order Sort order [ASC|DESC] + * @param int $slice Number of slice items to extract from result array + * + * @return array Indexed array with message header objects + */ + abstract function list_messages($folder = null, $page = null, $sort_field = null, $sort_order = null, $slice = 0); + + + /** + * Return sorted list of message UIDs + * + * @param string $folder Folder to get index from + * @param string $sort_field Sort column + * @param string $sort_order Sort order [ASC, DESC] + * + * @return rcube_result_index|rcube_result_thread List of messages (UIDs) + */ + abstract function index($folder = null, $sort_field = null, $sort_order = null); + + + /** + * Invoke search request to the server. + * + * @param string $folder Folder name to search in + * @param string $str Search criteria + * @param string $charset Search charset + * @param string $sort_field Header field to sort by + * + * @todo: Search criteria should be provided in non-IMAP format, eg. array + */ + abstract function search($folder = null, $str = 'ALL', $charset = null, $sort_field = null); + + + /** + * Direct (real and simple) search request (without result sorting and caching). + * + * @param string $folder Folder name to search in + * @param string $str Search string + * + * @return rcube_result_index Search result (UIDs) + */ + abstract function search_once($folder = null, $str = 'ALL'); + + + /** + * Refresh saved search set + * + * @return array Current search set + */ + abstract function refresh_search(); + + + /* -------------------------------- + * messages management + * --------------------------------*/ + + /** + * Fetch message headers and body structure from the server and build + * an object structure similar to the one generated by PEAR::Mail_mimeDecode + * + * @param int $uid Message UID to fetch + * @param string $folder Folder to read from + * + * @return object rcube_message_header Message data + */ + abstract function get_message($uid, $folder = null); + + + /** + * Return message headers object of a specific message + * + * @param int $id Message sequence ID or UID + * @param string $folder Folder to read from + * @param bool $force True to skip cache + * + * @return rcube_message_header Message headers + */ + abstract function get_message_headers($uid, $folder = null, $force = false); + + + /** + * Fetch message body of a specific message from the server + * + * @param int $uid Message UID + * @param string $part Part number + * @param rcube_message_part $o_part Part object created by get_structure() + * @param mixed $print True to print part, ressource to write part contents in + * @param resource $fp File pointer to save the message part + * @param boolean $skip_charset_conv Disables charset conversion + * + * @return string Message/part body if not printed + */ + abstract function get_message_part($uid, $part = 1, $o_part = null, $print = null, $fp = null, $skip_charset_conv = false); + + + /** + * Fetch message body of a specific message from the server + * + * @param int $uid Message UID + * + * @return string $part Message/part body + * @see rcube_imap::get_message_part() + */ + public function get_body($uid, $part = 1) + { + $headers = $this->get_message_headers($uid); + return rcube_charset::convert($this->get_message_part($uid, $part, null), + $headers->charset ? $headers->charset : $this->default_charset); + } + + + /** + * Returns the whole message source as string (or saves to a file) + * + * @param int $uid Message UID + * @param resource $fp File pointer to save the message + * + * @return string Message source string + */ + abstract function get_raw_body($uid, $fp = null); + + + /** + * Returns the message headers as string + * + * @param int $uid Message UID + * + * @return string Message headers string + */ + abstract function get_raw_headers($uid); + + + /** + * Sends the whole message source to stdout + * + * @param int $uid Message UID + * @param bool $formatted Enables line-ending formatting + */ + abstract function print_raw_body($uid, $formatted = true); + + + /** + * Set message flag to one or several messages + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $flag Flag to set: SEEN, UNDELETED, DELETED, RECENT, ANSWERED, DRAFT, MDNSENT + * @param string $folder Folder name + * @param boolean $skip_cache True to skip message cache clean up + * + * @return bool Operation status + */ + abstract function set_flag($uids, $flag, $folder = null, $skip_cache = false); + + + /** + * Remove message flag for one or several messages + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $flag Flag to unset: SEEN, DELETED, RECENT, ANSWERED, DRAFT, MDNSENT + * @param string $folder Folder name + * + * @return bool Operation status + * @see set_flag + */ + public function unset_flag($uids, $flag, $folder = null) + { + return $this->set_flag($uids, 'UN'.$flag, $folder); + } + + + /** + * Append a mail message (source) to a specific folder. + * + * @param string $folder Target folder + * @param string $message The message source string or filename + * @param string $headers Headers string if $message contains only the body + * @param boolean $is_file True if $message is a filename + * @param array $flags Message flags + * @param mixed $date Message internal date + * + * @return int|bool Appended message UID or True on success, False on error + */ + abstract function save_message($folder, &$message, $headers = '', $is_file = false, $flags = array(), $date = null); + + + /** + * Move message(s) from one folder to another. + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $to Target folder + * @param string $from Source folder + * + * @return boolean True on success, False on error + */ + abstract function move_message($uids, $to, $from = null); + + + /** + * Copy message(s) from one mailbox to another. + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $to Target folder + * @param string $from Source folder + * + * @return boolean True on success, False on error + */ + abstract function copy_message($uids, $to, $from = null); + + + /** + * Mark message(s) as deleted and expunge. + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $folder Source folder + * + * @return boolean True on success, False on error + */ + abstract function delete_message($uids, $folder = null); + + + /** + * Expunge message(s) and clear the cache. + * + * @param mixed $uids Message UIDs as array or comma-separated string, or '*' + * @param string $folder Folder name + * @param boolean $clear_cache False if cache should not be cleared + * + * @return boolean True on success, False on error + */ + abstract function expunge_message($uids, $folder = null, $clear_cache = true); + + + /** + * Parse message UIDs input + * + * @param mixed $uids UIDs array or comma-separated list or '*' or '1:*' + * + * @return array Two elements array with UIDs converted to list and ALL flag + */ + protected function parse_uids($uids) + { + if ($uids === '*' || $uids === '1:*') { + if (empty($this->search_set)) { + $uids = '1:*'; + $all = true; + } + // get UIDs from current search set + else { + $uids = join(',', $this->search_set->get()); + } + } + else { + if (is_array($uids)) { + $uids = join(',', $uids); + } + + if (preg_match('/[^0-9,]/', $uids)) { + $uids = ''; + } + } + + return array($uids, (bool) $all); + } + + + /* -------------------------------- + * folder managment + * --------------------------------*/ + + /** + * Get a list of subscribed folders. + * + * @param string $root Optional root folder + * @param string $name Optional name pattern + * @param string $filter Optional filter + * @param string $rights Optional ACL requirements + * @param bool $skip_sort Enable to return unsorted list (for better performance) + * + * @return array List of folders + */ + abstract function list_folders_subscribed($root = '', $name = '*', $filter = null, $rights = null, $skip_sort = false); + + + /** + * Get a list of all folders available on the server. + * + * @param string $root IMAP root dir + * @param string $name Optional name pattern + * @param mixed $filter Optional filter + * @param string $rights Optional ACL requirements + * @param bool $skip_sort Enable to return unsorted list (for better performance) + * + * @return array Indexed array with folder names + */ + abstract function list_folders($root = '', $name = '*', $filter = null, $rights = null, $skip_sort = false); + + + /** + * Subscribe to a specific folder(s) + * + * @param array $folders Folder name(s) + * + * @return boolean True on success + */ + abstract function subscribe($folders); + + + /** + * Unsubscribe folder(s) + * + * @param array $folders Folder name(s) + * + * @return boolean True on success + */ + abstract function unsubscribe($folders); + + + /** + * Create a new folder on the server. + * + * @param string $folder New folder name + * @param boolean $subscribe True if the newvfolder should be subscribed + * + * @return boolean True on success, False on error + */ + abstract function create_folder($folder, $subscribe = false); + + + /** + * Set a new name to an existing folder + * + * @param string $folder Folder to rename + * @param string $new_name New folder name + * + * @return boolean True on success, False on error + */ + abstract function rename_folder($folder, $new_name); + + + /** + * Remove a folder from the server. + * + * @param string $folder Folder name + * + * @return boolean True on success, False on error + */ + abstract function delete_folder($folder); + + + /** + * Send expunge command and clear the cache. + * + * @param string $folder Folder name + * @param boolean $clear_cache False if cache should not be cleared + * + * @return boolean True on success, False on error + */ + public function expunge_folder($folder = null, $clear_cache = true) + { + return $this->expunge_message('*', $folder, $clear_cache); + } + + + /** + * Remove all messages in a folder.. + * + * @param string $folder Folder name + * + * @return boolean True on success, False on error + */ + public function clear_folder($folder = null) + { + return $this->delete_message('*', $folder); + } + + + /** + * Checks if folder exists and is subscribed + * + * @param string $folder Folder name + * @param boolean $subscription Enable subscription checking + * + * @return boolean True if folder exists, False otherwise + */ + abstract function folder_exists($folder, $subscription = false); + + + /** + * Get folder size (size of all messages in a folder) + * + * @param string $folder Folder name + * + * @return int Folder size in bytes, False on error + */ + abstract function folder_size($folder); + + + /** + * Returns the namespace where the folder is in + * + * @param string $folder Folder name + * + * @return string One of 'personal', 'other' or 'shared' + */ + abstract function folder_namespace($folder); + + + /** + * Gets folder attributes (from LIST response, e.g. \Noselect, \Noinferiors). + * + * @param string $folder Folder name + * @param bool $force Set to True if attributes should be refreshed + * + * @return array Options list + */ + abstract function folder_attributes($folder, $force = false); + + + /** + * Gets connection (and current folder) data: UIDVALIDITY, EXISTS, RECENT, + * PERMANENTFLAGS, UIDNEXT, UNSEEN + * + * @param string $folder Folder name + * + * @return array Data + */ + abstract function folder_data($folder); + + + /** + * Returns extended information about the folder. + * + * @param string $folder Folder name + * + * @return array Data + */ + abstract function folder_info($folder); + + + /** + * Returns current status of a folder + * + * @param string $folder Folder name + * + * @return int Folder status + */ + abstract function folder_status($folder = null); + + + /** + * Synchronizes messages cache. + * + * @param string $folder Folder name + */ + abstract function folder_sync($folder); + + + /** + * Modify folder name according to namespace. + * For output it removes prefix of the personal namespace if it's possible. + * For input it adds the prefix. Use it before creating a folder in root + * of the folders tree. + * + * @param string $folder Folder name + * @param string $mode Mode name (out/in) + * + * @return string Folder name + */ + abstract function mod_folder($folder, $mode = 'out'); + + + /** + * Create all folders specified as default + */ + public function create_default_folders() + { + // create default folders if they do not exist + foreach ($this->default_folders as $folder) { + if (!$this->folder_exists($folder)) { + $this->create_folder($folder, true); + } + else if (!$this->folder_exists($folder, true)) { + $this->subscribe($folder); + } + } + } + + + /** + * Get mailbox quota information. + * + * @return mixed Quota info or False if not supported + */ + abstract function get_quota(); + + + /* ----------------------------------------- + * ACL and METADATA methods + * ----------------------------------------*/ + + /** + * Changes the ACL on the specified folder (SETACL) + * + * @param string $folder Folder name + * @param string $user User name + * @param string $acl ACL string + * + * @return boolean True on success, False on failure + */ + abstract function set_acl($folder, $user, $acl); + + + /** + * Removes any <identifier,rights> pair for the + * specified user from the ACL for the specified + * folder (DELETEACL). + * + * @param string $folder Folder name + * @param string $user User name + * + * @return boolean True on success, False on failure + */ + abstract function delete_acl($folder, $user); + + + /** + * Returns the access control list for a folder (GETACL). + * + * @param string $folder Folder name + * + * @return array User-rights array on success, NULL on error + */ + abstract function get_acl($folder); + + + /** + * Returns information about what rights can be granted to the + * user (identifier) in the ACL for the folder (LISTRIGHTS). + * + * @param string $folder Folder name + * @param string $user User name + * + * @return array List of user rights + */ + abstract function list_rights($folder, $user); + + + /** + * Returns the set of rights that the current user has to a folder (MYRIGHTS). + * + * @param string $folder Folder name + * + * @return array MYRIGHTS response on success, NULL on error + */ + abstract function my_rights($folder); + + + /** + * Sets metadata/annotations (SETMETADATA/SETANNOTATION) + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entry-value array (use NULL value as NIL) + * + * @return boolean True on success, False on failure + */ + abstract function set_metadata($folder, $entries); + + + /** + * Unsets metadata/annotations (SETMETADATA/SETANNOTATION) + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entry names array + * + * @return boolean True on success, False on failure + */ + abstract function delete_metadata($folder, $entries); + + + /** + * Returns folder metadata/annotations (GETMETADATA/GETANNOTATION). + * + * @param string $folder Folder name (empty for server metadata) + * @param array $entries Entries + * @param array $options Command options (with MAXSIZE and DEPTH keys) + * + * @return array Metadata entry-value hash array on success, NULL on error + */ + abstract function get_metadata($folder, $entries, $options = array()); + + + /* ----------------------------------------- + * Cache related functions + * ----------------------------------------*/ + + /** + * Clears the cache. + * + * @param string $key Cache key name or pattern + * @param boolean $prefix_mode Enable it to clear all keys starting + * with prefix specified in $key + */ + abstract function clear_cache($key = null, $prefix_mode = false); + + + /** + * Returns cached value + * + * @param string $key Cache key + * + * @return mixed Cached value + */ + abstract function get_cache($key); + + + /** + * Delete outdated cache entries + */ + abstract function expunge_cache(); + +} // end class rcube_storage diff --git a/program/lib/Roundcube/rcube_string_replacer.php b/program/lib/Roundcube/rcube_string_replacer.php new file mode 100644 index 000000000..584b9f68c --- /dev/null +++ b/program/lib/Roundcube/rcube_string_replacer.php @@ -0,0 +1,191 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_string_replacer.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2009-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Handle string replacements based on preg_replace_callback | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Helper class for string replacements based on preg_replace_callback + * + * @package Framework + * @subpackage Utils + */ +class rcube_string_replacer +{ + public static $pattern = '/##str_replacement\[([0-9]+)\]##/'; + public $mailto_pattern; + public $link_pattern; + private $values = array(); + + + function __construct() + { + // Simplified domain expression for UTF8 characters handling + // Support unicode/punycode in top-level domain part + $utf_domain = '[^?&@"\'\\/()\s\r\t\n]+\\.?([^\\x00-\\x2f\\x3b-\\x40\\x5b-\\x60\\x7b-\\x7f]{2,}|xn--[a-zA-Z0-9]{2,})'; + $url1 = '.:;,'; + $url2 = 'a-zA-Z0-9%=#$@+?!&\\/_~\\[\\]{}\*-'; + + $this->link_pattern = "/([\w]+:\/\/|\W[Ww][Ww][Ww]\.|^[Ww][Ww][Ww]\.)($utf_domain([$url1]?[$url2]+)*)/"; + $this->mailto_pattern = "/(" + ."[-\w!\#\$%&\'*+~\/^`|{}=]+(?:\.[-\w!\#\$%&\'*+~\/^`|{}=]+)*" // local-part + ."@$utf_domain" // domain-part + ."(\?[$url1$url2]+)?" // e.g. ?subject=test... + .")/"; + } + + /** + * Add a string to the internal list + * + * @param string String value + * @return int Index of value for retrieval + */ + public function add($str) + { + $i = count($this->values); + $this->values[$i] = $str; + return $i; + } + + /** + * Build replacement string + */ + public function get_replacement($i) + { + return '##str_replacement['.$i.']##'; + } + + /** + * Callback function used to build HTML links around URL strings + * + * @param array Matches result from preg_replace_callback + * @return int Index of saved string value + */ + public function link_callback($matches) + { + $i = -1; + $scheme = strtolower($matches[1]); + + if (preg_match('!^(http|ftp|file)s?://!i', $scheme)) { + $url = $matches[1] . $matches[2]; + } + else if (preg_match('/^(\W*)(www\.)$/i', $matches[1], $m)) { + $url = $m[2] . $matches[2]; + $url_prefix = 'http://'; + $prefix = $m[1]; + } + + if ($url) { + $suffix = $this->parse_url_brackets($url); + $i = $this->add($prefix . html::a(array( + 'href' => $url_prefix . $url, + 'target' => '_blank' + ), rcube::Q($url)) . $suffix); + } + + // Return valid link for recognized schemes, otherwise, return the unmodified string for unrecognized schemes. + return $i >= 0 ? $this->get_replacement($i) : $matches[0]; + } + + /** + * Callback function used to build mailto: links around e-mail strings + * + * @param array Matches result from preg_replace_callback + * @return int Index of saved string value + */ + public function mailto_callback($matches) + { + $href = $matches[1]; + $suffix = $this->parse_url_brackets($href); + $i = $this->add(html::a('mailto:' . $href, rcube::Q($href)) . $suffix); + + return $i >= 0 ? $this->get_replacement($i) : ''; + } + + /** + * Look up the index from the preg_replace matches array + * and return the substitution value. + * + * @param array Matches result from preg_replace_callback + * @return string Value at index $matches[1] + */ + public function replace_callback($matches) + { + return $this->values[$matches[1]]; + } + + /** + * Replace all defined (link|mailto) patterns with replacement string + * + * @param string $str Text + * + * @return string Text + */ + public function replace($str) + { + // search for patterns like links and e-mail addresses + $str = preg_replace_callback($this->link_pattern, array($this, 'link_callback'), $str); + $str = preg_replace_callback($this->mailto_pattern, array($this, 'mailto_callback'), $str); + + return $str; + } + + /** + * Replace substituted strings with original values + */ + public function resolve($str) + { + return preg_replace_callback(self::$pattern, array($this, 'replace_callback'), $str); + } + + /** + * Fixes bracket characters in URL handling + */ + public static function parse_url_brackets(&$url) + { + // #1487672: special handling of square brackets, + // URL regexp allows [] characters in URL, for example: + // "http://example.com/?a[b]=c". However we need to handle + // properly situation when a bracket is placed at the end + // of the link e.g. "[http://example.com]" + if (preg_match('/(\\[|\\])/', $url)) { + $in = false; + for ($i=0, $len=strlen($url); $i<$len; $i++) { + if ($url[$i] == '[') { + if ($in) + break; + $in = true; + } + else if ($url[$i] == ']') { + if (!$in) + break; + $in = false; + } + } + + if ($i<$len) { + $suffix = substr($url, $i); + $url = substr($url, 0, $i); + } + } + + return $suffix; + } + +} diff --git a/program/lib/Roundcube/rcube_user.php b/program/lib/Roundcube/rcube_user.php new file mode 100644 index 000000000..b027506ac --- /dev/null +++ b/program/lib/Roundcube/rcube_user.php @@ -0,0 +1,739 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_user.inc | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2005-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | This class represents a system user linked and provides access | + | to the related database records. | + | | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Class representing a system user + * + * @package Framework + * @subpackage Core + */ +class rcube_user +{ + public $ID; + public $data; + public $language; + + /** + * Holds database connection. + * + * @var rcube_db + */ + private $db; + + /** + * Framework object. + * + * @var rcube + */ + private $rc; + + /** + * Internal identities cache + * + * @var array + */ + private $identities = array(); + + const SEARCH_ADDRESSBOOK = 1; + const SEARCH_MAIL = 2; + + /** + * Object constructor + * + * @param int $id User id + * @param array $sql_arr SQL result set + */ + function __construct($id = null, $sql_arr = null) + { + $this->rc = rcube::get_instance(); + $this->db = $this->rc->get_dbh(); + + if ($id && !$sql_arr) { + $sql_result = $this->db->query( + "SELECT * FROM ".$this->db->table_name('users')." WHERE user_id = ?", $id); + $sql_arr = $this->db->fetch_assoc($sql_result); + } + + if (!empty($sql_arr)) { + $this->ID = $sql_arr['user_id']; + $this->data = $sql_arr; + $this->language = $sql_arr['language']; + } + } + + + /** + * Build a user name string (as e-mail address) + * + * @param string $part Username part (empty or 'local' or 'domain', 'mail') + * @return string Full user name or its part + */ + function get_username($part = null) + { + if ($this->data['username']) { + // return real name + if (!$part) { + return $this->data['username']; + } + + list($local, $domain) = explode('@', $this->data['username']); + + // at least we should always have the local part + if ($part == 'local') { + return $local; + } + // if no domain was provided... + if (empty($domain)) { + $domain = $this->rc->config->mail_domain($this->data['mail_host']); + } + + if ($part == 'domain') { + return $domain; + } + + if (!empty($domain)) + return $local . '@' . $domain; + else + return $local; + } + + return false; + } + + + /** + * Get the preferences saved for this user + * + * @return array Hash array with prefs + */ + function get_prefs() + { + if (!empty($this->language)) + $prefs = array('language' => $this->language); + + if ($this->ID) { + // Preferences from session (write-master is unavailable) + if (!empty($_SESSION['preferences'])) { + // Check last write attempt time, try to write again (every 5 minutes) + if ($_SESSION['preferences_time'] < time() - 5 * 60) { + $saved_prefs = unserialize($_SESSION['preferences']); + $this->rc->session->remove('preferences'); + $this->rc->session->remove('preferences_time'); + $this->save_prefs($saved_prefs); + } + else { + $this->data['preferences'] = $_SESSION['preferences']; + } + } + + if ($this->data['preferences']) { + $prefs += (array)unserialize($this->data['preferences']); + } + } + + return $prefs; + } + + + /** + * Write the given user prefs to the user's record + * + * @param array $a_user_prefs User prefs to save + * @return boolean True on success, False on failure + */ + function save_prefs($a_user_prefs) + { + if (!$this->ID) + return false; + + $config = $this->rc->config; + $old_prefs = (array)$this->get_prefs(); + + // merge (partial) prefs array with existing settings + $save_prefs = $a_user_prefs + $old_prefs; + unset($save_prefs['language']); + + // don't save prefs with default values if they haven't been changed yet + foreach ($a_user_prefs as $key => $value) { + if ($value === null || (!isset($old_prefs[$key]) && ($value == $config->get($key)))) + unset($save_prefs[$key]); + } + + $save_prefs = serialize($save_prefs); + + $this->db->query( + "UPDATE ".$this->db->table_name('users'). + " SET preferences = ?". + ", language = ?". + " WHERE user_id = ?", + $save_prefs, + $_SESSION['language'], + $this->ID); + + $this->language = $_SESSION['language']; + + // Update success + if ($this->db->affected_rows() !== false) { + $config->set_user_prefs($a_user_prefs); + $this->data['preferences'] = $save_prefs; + + if (isset($_SESSION['preferences'])) { + $this->rc->session->remove('preferences'); + $this->rc->session->remove('preferences_time'); + } + return true; + } + // Update error, but we are using replication (we have read-only DB connection) + // and we are storing session not in the SQL database + // we can store preferences in session and try to write later (see get_prefs()) + else if ($this->db->is_replicated() && $config->get('session_storage', 'db') != 'db') { + $_SESSION['preferences'] = $save_prefs; + $_SESSION['preferences_time'] = time(); + $config->set_user_prefs($a_user_prefs); + $this->data['preferences'] = $save_prefs; + } + + return false; + } + + + /** + * Get default identity of this user + * + * @param int $id Identity ID. If empty, the default identity is returned + * @return array Hash array with all cols of the identity record + */ + function get_identity($id = null) + { + $id = (int)$id; + // cache identities for better performance + if (!array_key_exists($id, $this->identities)) { + $result = $this->list_identities($id ? 'AND identity_id = ' . $id : ''); + $this->identities[$id] = $result[0]; + } + + return $this->identities[$id]; + } + + + /** + * Return a list of all identities linked with this user + * + * @param string $sql_add Optional WHERE clauses + * @param bool $formatted Format identity email and name + * + * @return array List of identities + */ + function list_identities($sql_add = '', $formatted = false) + { + $result = array(); + + $sql_result = $this->db->query( + "SELECT * FROM ".$this->db->table_name('identities'). + " WHERE del <> 1 AND user_id = ?". + ($sql_add ? " ".$sql_add : ""). + " ORDER BY ".$this->db->quoteIdentifier('standard')." DESC, name ASC, identity_id ASC", + $this->ID); + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + if ($formatted) { + $ascii_email = format_email($sql_arr['email']); + $utf8_email = format_email(rcube_utils::idn_to_utf8($ascii_email)); + + $sql_arr['email_ascii'] = $ascii_email; + $sql_arr['email'] = $utf8_email; + $sql_arr['ident'] = format_email_recipient($ascii_email, $ident['name']); + } + + $result[] = $sql_arr; + } + + return $result; + } + + + /** + * Update a specific identity record + * + * @param int $iid Identity ID + * @param array $data Hash array with col->value pairs to save + * @return boolean True if saved successfully, false if nothing changed + */ + function update_identity($iid, $data) + { + if (!$this->ID) + return false; + + $query_cols = $query_params = array(); + + foreach ((array)$data as $col => $value) { + $query_cols[] = $this->db->quoteIdentifier($col) . ' = ?'; + $query_params[] = $value; + } + $query_params[] = $iid; + $query_params[] = $this->ID; + + $sql = "UPDATE ".$this->db->table_name('identities'). + " SET changed = ".$this->db->now().", ".join(', ', $query_cols). + " WHERE identity_id = ?". + " AND user_id = ?". + " AND del <> 1"; + + call_user_func_array(array($this->db, 'query'), + array_merge(array($sql), $query_params)); + + $this->identities = array(); + + return $this->db->affected_rows(); + } + + + /** + * Create a new identity record linked with this user + * + * @param array $data Hash array with col->value pairs to save + * @return int The inserted identity ID or false on error + */ + function insert_identity($data) + { + if (!$this->ID) + return false; + + unset($data['user_id']); + + $insert_cols = $insert_values = array(); + foreach ((array)$data as $col => $value) { + $insert_cols[] = $this->db->quoteIdentifier($col); + $insert_values[] = $value; + } + $insert_cols[] = 'user_id'; + $insert_values[] = $this->ID; + + $sql = "INSERT INTO ".$this->db->table_name('identities'). + " (changed, ".join(', ', $insert_cols).")". + " VALUES (".$this->db->now().", ".join(', ', array_pad(array(), sizeof($insert_values), '?')).")"; + + call_user_func_array(array($this->db, 'query'), + array_merge(array($sql), $insert_values)); + + $this->identities = array(); + + return $this->db->insert_id('identities'); + } + + + /** + * Mark the given identity as deleted + * + * @param int $iid Identity ID + * @return boolean True if deleted successfully, false if nothing changed + */ + function delete_identity($iid) + { + if (!$this->ID) + return false; + + $sql_result = $this->db->query( + "SELECT count(*) AS ident_count FROM ".$this->db->table_name('identities'). + " WHERE user_id = ? AND del <> 1", + $this->ID); + + $sql_arr = $this->db->fetch_assoc($sql_result); + + // we'll not delete last identity + if ($sql_arr['ident_count'] <= 1) + return -1; + + $this->db->query( + "UPDATE ".$this->db->table_name('identities'). + " SET del = 1, changed = ".$this->db->now(). + " WHERE user_id = ?". + " AND identity_id = ?", + $this->ID, + $iid); + + $this->identities = array(); + + return $this->db->affected_rows(); + } + + + /** + * Make this identity the default one for this user + * + * @param int $iid The identity ID + */ + function set_default($iid) + { + if ($this->ID && $iid) { + $this->db->query( + "UPDATE ".$this->db->table_name('identities'). + " SET ".$this->db->quoteIdentifier('standard')." = '0'". + " WHERE user_id = ?". + " AND identity_id <> ?". + " AND del <> 1", + $this->ID, + $iid); + + unset($this->identities[0]); + } + } + + + /** + * Update user's last_login timestamp + */ + function touch() + { + if ($this->ID) { + $this->db->query( + "UPDATE ".$this->db->table_name('users'). + " SET last_login = ".$this->db->now(). + " WHERE user_id = ?", + $this->ID); + } + } + + + /** + * Clear the saved object state + */ + function reset() + { + $this->ID = null; + $this->data = null; + } + + + /** + * Find a user record matching the given name and host + * + * @param string $user IMAP user name + * @param string $host IMAP host name + * @return rcube_user New user instance + */ + static function query($user, $host) + { + $dbh = rcube::get_instance()->get_dbh(); + $config = rcube::get_instance()->config; + + // query for matching user name + $sql_result = $dbh->query("SELECT * FROM " . $dbh->table_name('users') + ." WHERE mail_host = ? AND username = ?", $host, $user); + + $sql_arr = $dbh->fetch_assoc($sql_result); + + // username not found, try aliases from identities + if (empty($sql_arr) && $config->get('user_aliases') && strpos($user, '@')) { + $sql_result = $dbh->limitquery("SELECT u.*" + ." FROM " . $dbh->table_name('users') . " u" + ." JOIN " . $dbh->table_name('identities') . " i ON (i.user_id = u.user_id)" + ." WHERE email = ? AND del <> 1", 0, 1, $user); + + $sql_arr = $dbh->fetch_assoc($sql_result); + } + + // user already registered -> overwrite username + if ($sql_arr) + return new rcube_user($sql_arr['user_id'], $sql_arr); + else + return false; + } + + + /** + * Create a new user record and return a rcube_user instance + * + * @param string $user IMAP user name + * @param string $host IMAP host + * @return rcube_user New user instance + */ + static function create($user, $host) + { + $user_name = ''; + $user_email = ''; + $rcube = rcube::get_instance(); + $dbh = $rcube->get_dbh(); + + // try to resolve user in virtuser table and file + if ($email_list = self::user2email($user, false, true)) { + $user_email = is_array($email_list[0]) ? $email_list[0]['email'] : $email_list[0]; + } + + $data = $rcube->plugins->exec_hook('user_create', array( + 'host' => $host, + 'user' => $user, + 'user_name' => $user_name, + 'user_email' => $user_email, + 'email_list' => $email_list, + 'language' => $_SESSION['language'], + )); + + // plugin aborted this operation + if ($data['abort']) { + return false; + } + + $dbh->query( + "INSERT INTO ".$dbh->table_name('users'). + " (created, last_login, username, mail_host, language)". + " VALUES (".$dbh->now().", ".$dbh->now().", ?, ?, ?)", + strip_newlines($data['user']), + strip_newlines($data['host']), + strip_newlines($data['language'])); + + if ($user_id = $dbh->insert_id('users')) { + // create rcube_user instance to make plugin hooks work + $user_instance = new rcube_user($user_id, array( + 'user_id' => $user_id, + 'username' => $data['user'], + 'mail_host' => $data['host'], + 'language' => $data['language'], + )); + $rcube->user = $user_instance; + $mail_domain = $rcube->config->mail_domain($data['host']); + $user_name = $data['user_name']; + $user_email = $data['user_email']; + $email_list = $data['email_list']; + + if (empty($email_list)) { + if (empty($user_email)) { + $user_email = strpos($data['user'], '@') ? $user : sprintf('%s@%s', $data['user'], $mail_domain); + } + $email_list[] = strip_newlines($user_email); + } + // identities_level check + else if (count($email_list) > 1 && $rcube->config->get('identities_level', 0) > 1) { + $email_list = array($email_list[0]); + } + + if (empty($user_name)) { + $user_name = $data['user']; + } + + // create new identities records + $standard = 1; + foreach ($email_list as $row) { + $record = array(); + + if (is_array($row)) { + if (empty($row['email'])) { + continue; + } + $record = $row; + } + else { + $record['email'] = $row; + } + + if (empty($record['name'])) { + $record['name'] = $user_name != $record['email'] ? $user_name : ''; + } + + $record['name'] = strip_newlines($record['name']); + $record['user_id'] = $user_id; + $record['standard'] = $standard; + + $plugin = $rcube->plugins->exec_hook('identity_create', + array('login' => true, 'record' => $record)); + + if (!$plugin['abort'] && $plugin['record']['email']) { + $rcube->user->insert_identity($plugin['record']); + } + $standard = 0; + } + } + else { + rcube::raise_error(array( + 'code' => 500, + 'type' => 'php', + 'line' => __LINE__, + 'file' => __FILE__, + 'message' => "Failed to create new user"), true, false); + } + + return $user_id ? $user_instance : false; + } + + + /** + * Resolve username using a virtuser plugins + * + * @param string $email E-mail address to resolve + * @return string Resolved IMAP username + */ + static function email2user($email) + { + $rcube = rcube::get_instance(); + $plugin = $rcube->plugins->exec_hook('email2user', + array('email' => $email, 'user' => NULL)); + + return $plugin['user']; + } + + + /** + * Resolve e-mail address from virtuser plugins + * + * @param string $user User name + * @param boolean $first If true returns first found entry + * @param boolean $extended If true returns email as array (email and name for identity) + * @return mixed Resolved e-mail address string or array of strings + */ + static function user2email($user, $first=true, $extended=false) + { + $rcube = rcube::get_instance(); + $plugin = $rcube->plugins->exec_hook('user2email', + array('email' => NULL, 'user' => $user, + 'first' => $first, 'extended' => $extended)); + + return empty($plugin['email']) ? NULL : $plugin['email']; + } + + + /** + * Return a list of saved searches linked with this user + * + * @param int $type Search type + * + * @return array List of saved searches indexed by search ID + */ + function list_searches($type) + { + $plugin = $this->rc->plugins->exec_hook('saved_search_list', array('type' => $type)); + + if ($plugin['abort']) { + return (array) $plugin['result']; + } + + $result = array(); + + $sql_result = $this->db->query( + "SELECT search_id AS id, ".$this->db->quoteIdentifier('name') + ." FROM ".$this->db->table_name('searches') + ." WHERE user_id = ?" + ." AND ".$this->db->quoteIdentifier('type')." = ?" + ." ORDER BY ".$this->db->quoteIdentifier('name'), + (int) $this->ID, (int) $type); + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $sql_arr['data'] = unserialize($sql_arr['data']); + $result[$sql_arr['id']] = $sql_arr; + } + + return $result; + } + + + /** + * Return saved search data. + * + * @param int $id Row identifier + * + * @return array Data + */ + function get_search($id) + { + $plugin = $this->rc->plugins->exec_hook('saved_search_get', array('id' => $id)); + + if ($plugin['abort']) { + return $plugin['result']; + } + + $sql_result = $this->db->query( + "SELECT ".$this->db->quoteIdentifier('name') + .", ".$this->db->quoteIdentifier('data') + .", ".$this->db->quoteIdentifier('type') + ." FROM ".$this->db->table_name('searches') + ." WHERE user_id = ?" + ." AND search_id = ?", + (int) $this->ID, (int) $id); + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + return array( + 'id' => $id, + 'name' => $sql_arr['name'], + 'type' => $sql_arr['type'], + 'data' => unserialize($sql_arr['data']), + ); + } + + return null; + } + + + /** + * Deletes given saved search record + * + * @param int $sid Search ID + * + * @return boolean True if deleted successfully, false if nothing changed + */ + function delete_search($sid) + { + if (!$this->ID) + return false; + + $this->db->query( + "DELETE FROM ".$this->db->table_name('searches') + ." WHERE user_id = ?" + ." AND search_id = ?", + (int) $this->ID, $sid); + + return $this->db->affected_rows(); + } + + + /** + * Create a new saved search record linked with this user + * + * @param array $data Hash array with col->value pairs to save + * + * @return int The inserted search ID or false on error + */ + function insert_search($data) + { + if (!$this->ID) + return false; + + $insert_cols[] = 'user_id'; + $insert_values[] = (int) $this->ID; + $insert_cols[] = $this->db->quoteIdentifier('type'); + $insert_values[] = (int) $data['type']; + $insert_cols[] = $this->db->quoteIdentifier('name'); + $insert_values[] = $data['name']; + $insert_cols[] = $this->db->quoteIdentifier('data'); + $insert_values[] = serialize($data['data']); + + $sql = "INSERT INTO ".$this->db->table_name('searches') + ." (".join(', ', $insert_cols).")" + ." VALUES (".join(', ', array_pad(array(), sizeof($insert_values), '?')).")"; + + call_user_func_array(array($this->db, 'query'), + array_merge(array($sql), $insert_values)); + + return $this->db->insert_id('searches'); + } + +} diff --git a/program/lib/Roundcube/rcube_utils.php b/program/lib/Roundcube/rcube_utils.php new file mode 100644 index 000000000..500f2c371 --- /dev/null +++ b/program/lib/Roundcube/rcube_utils.php @@ -0,0 +1,924 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_utils.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2012, The Roundcube Dev Team | + | Copyright (C) 2011-2012, Kolab Systems AG | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Utility class providing common functions | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Utility class providing common functions + * + * @package Framework + * @subpackage Utils + */ +class rcube_utils +{ + // define constants for input reading + const INPUT_GET = 0x0101; + const INPUT_POST = 0x0102; + const INPUT_GPC = 0x0103; + + /** + * Helper method to set a cookie with the current path and host settings + * + * @param string Cookie name + * @param string Cookie value + * @param string Expiration time + */ + public static function setcookie($name, $value, $exp = 0) + { + if (headers_sent()) { + return; + } + + $cookie = session_get_cookie_params(); + $secure = $cookie['secure'] || self::https_check(); + + setcookie($name, $value, $exp, $cookie['path'], $cookie['domain'], $secure, true); + } + + /** + * E-mail address validation. + * + * @param string $email Email address + * @param boolean $dns_check True to check dns + * + * @return boolean True on success, False if address is invalid + */ + public static function check_email($email, $dns_check=true) + { + // Check for invalid characters + if (preg_match('/[\x00-\x1F\x7F-\xFF]/', $email)) { + return false; + } + + // Check for length limit specified by RFC 5321 (#1486453) + if (strlen($email) > 254) { + return false; + } + + $email_array = explode('@', $email); + + // Check that there's one @ symbol + if (count($email_array) < 2) { + return false; + } + + $domain_part = array_pop($email_array); + $local_part = implode('@', $email_array); + + // from PEAR::Validate + $regexp = '&^(?: + ("\s*(?:[^"\f\n\r\t\v\b\s]+\s*)+")| #1 quoted name + ([-\w!\#\$%\&\'*+~/^`|{}=]+(?:\.[-\w!\#\$%\&\'*+~/^`|{}=]+)*)) #2 OR dot-atom (RFC5322) + $&xi'; + + if (!preg_match($regexp, $local_part)) { + return false; + } + + // Validate domain part + if (preg_match('/^\[((IPv6:[0-9a-f:.]+)|([0-9.]+))\]$/i', $domain_part, $matches)) { + return self::check_ip(preg_replace('/^IPv6:/i', '', $matches[1])); // valid IPv4 or IPv6 address + } + else { + // If not an IP address + $domain_array = explode('.', $domain_part); + // Not enough parts to be a valid domain + if (sizeof($domain_array) < 2) { + return false; + } + + foreach ($domain_array as $part) { + if (!preg_match('/^(([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])|([A-Za-z0-9]))$/', $part)) { + return false; + } + } + + // last domain part + if (preg_match('/[^a-zA-Z]/', array_pop($domain_array))) { + return false; + } + + $rcube = rcube::get_instance(); + + if (!$dns_check || !$rcube->config->get('email_dns_check')) { + return true; + } + + if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && version_compare(PHP_VERSION, '5.3.0', '<')) { + $lookup = array(); + @exec("nslookup -type=MX " . escapeshellarg($domain_part) . " 2>&1", $lookup); + foreach ($lookup as $line) { + if (strpos($line, 'MX preference')) { + return true; + } + } + return false; + } + + // find MX record(s) + if (!function_exists('getmxrr') || getmxrr($domain_part, $mx_records)) { + return true; + } + + // find any DNS record + if (!function_exists('checkdnsrr') || checkdnsrr($domain_part, 'ANY')) { + return true; + } + } + + return false; + } + + + /** + * Validates IPv4 or IPv6 address + * + * @param string $ip IP address in v4 or v6 format + * + * @return bool True if the address is valid + */ + public static function check_ip($ip) + { + // IPv6, but there's no build-in IPv6 support + if (strpos($ip, ':') !== false && !defined('AF_INET6')) { + $parts = explode(':', $domain_part); + $count = count($parts); + + if ($count > 8 || $count < 2) { + return false; + } + + foreach ($parts as $idx => $part) { + $length = strlen($part); + if (!$length) { + // there can be only one :: + if ($found_empty) { + return false; + } + $found_empty = true; + } + // last part can be an IPv4 address + else if ($idx == $count - 1) { + if (!preg_match('/^[0-9a-f]{1,4}$/i', $part)) { + return @inet_pton($part) !== false; + } + } + else if (!preg_match('/^[0-9a-f]{1,4}$/i', $part)) { + return false; + } + } + + return true; + } + + return @inet_pton($ip) !== false; + } + + + /** + * Check whether the HTTP referer matches the current request + * + * @return boolean True if referer is the same host+path, false if not + */ + public static function check_referer() + { + $uri = parse_url($_SERVER['REQUEST_URI']); + $referer = parse_url(self::request_header('Referer')); + return $referer['host'] == self::request_header('Host') && $referer['path'] == $uri['path']; + } + + + /** + * Replacing specials characters to a specific encoding type + * + * @param string Input string + * @param string Encoding type: text|html|xml|js|url + * @param string Replace mode for tags: show|replace|remove + * @param boolean Convert newlines + * + * @return string The quoted string + */ + public static function rep_specialchars_output($str, $enctype = '', $mode = '', $newlines = true) + { + static $html_encode_arr = false; + static $js_rep_table = false; + static $xml_rep_table = false; + + if (!is_string($str)) { + $str = strval($str); + } + + // encode for HTML output + if ($enctype == 'html') { + if (!$html_encode_arr) { + $html_encode_arr = get_html_translation_table(HTML_SPECIALCHARS); + unset($html_encode_arr['?']); + } + + $encode_arr = $html_encode_arr; + + // don't replace quotes and html tags + if ($mode == 'show' || $mode == '') { + $ltpos = strpos($str, '<'); + if ($ltpos !== false && strpos($str, '>', $ltpos) !== false) { + unset($encode_arr['"']); + unset($encode_arr['<']); + unset($encode_arr['>']); + unset($encode_arr['&']); + } + } + else if ($mode == 'remove') { + $str = strip_tags($str); + } + + $out = strtr($str, $encode_arr); + + return $newlines ? nl2br($out) : $out; + } + + // if the replace tables for XML and JS are not yet defined + if ($js_rep_table === false) { + $js_rep_table = $xml_rep_table = array(); + $xml_rep_table['&'] = '&'; + + // can be increased to support more charsets + for ($c=160; $c<256; $c++) { + $xml_rep_table[chr($c)] = "&#$c;"; + } + + $xml_rep_table['"'] = '"'; + $js_rep_table['"'] = '\\"'; + $js_rep_table["'"] = "\\'"; + $js_rep_table["\\"] = "\\\\"; + // Unicode line and paragraph separators (#1486310) + $js_rep_table[chr(hexdec(E2)).chr(hexdec(80)).chr(hexdec(A8))] = '
'; + $js_rep_table[chr(hexdec(E2)).chr(hexdec(80)).chr(hexdec(A9))] = '
'; + } + + // encode for javascript use + if ($enctype == 'js') { + return preg_replace(array("/\r?\n/", "/\r/", '/<\\//'), array('\n', '\n', '<\\/'), strtr($str, $js_rep_table)); + } + + // encode for plaintext + if ($enctype == 'text') { + return str_replace("\r\n", "\n", $mode=='remove' ? strip_tags($str) : $str); + } + + if ($enctype == 'url') { + return rawurlencode($str); + } + + // encode for XML + if ($enctype == 'xml') { + return strtr($str, $xml_rep_table); + } + + // no encoding given -> return original string + return $str; + } + + + /** + * Read input value and convert it for internal use + * Performs stripslashes() and charset conversion if necessary + * + * @param string Field name to read + * @param int Source to get value from (GPC) + * @param boolean Allow HTML tags in field value + * @param string Charset to convert into + * + * @return string Field value or NULL if not available + */ + public static function get_input_value($fname, $source, $allow_html=FALSE, $charset=NULL) + { + $value = NULL; + + if ($source == self::INPUT_GET) { + if (isset($_GET[$fname])) { + $value = $_GET[$fname]; + } + } + else if ($source == self::INPUT_POST) { + if (isset($_POST[$fname])) { + $value = $_POST[$fname]; + } + } + else if ($source == self::INPUT_GPC) { + if (isset($_POST[$fname])) { + $value = $_POST[$fname]; + } + else if (isset($_GET[$fname])) { + $value = $_GET[$fname]; + } + else if (isset($_COOKIE[$fname])) { + $value = $_COOKIE[$fname]; + } + } + + return self::parse_input_value($value, $allow_html, $charset); + } + + + /** + * Parse/validate input value. See self::get_input_value() + * Performs stripslashes() and charset conversion if necessary + * + * @param string Input value + * @param boolean Allow HTML tags in field value + * @param string Charset to convert into + * + * @return string Parsed value + */ + public static function parse_input_value($value, $allow_html=FALSE, $charset=NULL) + { + global $OUTPUT; + + if (empty($value)) { + return $value; + } + + if (is_array($value)) { + foreach ($value as $idx => $val) { + $value[$idx] = self::parse_input_value($val, $allow_html, $charset); + } + return $value; + } + + // strip single quotes if magic_quotes_sybase is enabled + if (ini_get('magic_quotes_sybase')) { + $value = str_replace("''", "'", $value); + } + // strip slashes if magic_quotes enabled + else if (get_magic_quotes_gpc() || get_magic_quotes_runtime()) { + $value = stripslashes($value); + } + + // remove HTML tags if not allowed + if (!$allow_html) { + $value = strip_tags($value); + } + + $output_charset = is_object($OUTPUT) ? $OUTPUT->get_charset() : null; + + // remove invalid characters (#1488124) + if ($output_charset == 'UTF-8') { + $value = rcube_charset::clean($value); + } + + // convert to internal charset + if ($charset && $output_charset) { + $value = rcube_charset::convert($value, $output_charset, $charset); + } + + return $value; + } + + + /** + * Convert array of request parameters (prefixed with _) + * to a regular array with non-prefixed keys. + * + * @param int $mode Source to get value from (GPC) + * @param string $ignore PCRE expression to skip parameters by name + * + * @return array Hash array with all request parameters + */ + public static function request2param($mode = null, $ignore = 'task|action') + { + $out = array(); + $src = $mode == self::INPUT_GET ? $_GET : ($mode == self::INPUT_POST ? $_POST : $_REQUEST); + + foreach ($src as $key => $value) { + $fname = $key[0] == '_' ? substr($key, 1) : $key; + if ($ignore && !preg_match('/^(' . $ignore . ')$/', $fname)) { + $out[$fname] = self::get_input_value($key, $mode); + } + } + + return $out; + } + + + /** + * Convert the given string into a valid HTML identifier + * Same functionality as done in app.js with rcube_webmail.html_identifier() + */ + public static function html_identifier($str, $encode=false) + { + if ($encode) { + return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); + } + else { + return asciiwords($str, true, '_'); + } + } + + + /** + * Replace all css definitions with #container [def] + * and remove css-inlined scripting + * + * @param string CSS source code + * @param string Container ID to use as prefix + * + * @return string Modified CSS source + */ + public static function mod_css_styles($source, $container_id, $allow_remote=false) + { + $last_pos = 0; + $replacements = new rcube_string_replacer; + + // ignore the whole block if evil styles are detected + $source = self::xss_entity_decode($source); + $stripped = preg_replace('/[^a-z\(:;]/i', '', $source); + $evilexpr = 'expression|behavior|javascript:|import[^a]' . (!$allow_remote ? '|url\(' : ''); + if (preg_match("/$evilexpr/i", $stripped)) { + return '/* evil! */'; + } + + // cut out all contents between { and } + while (($pos = strpos($source, '{', $last_pos)) && ($pos2 = strpos($source, '}', $pos))) { + $styles = substr($source, $pos+1, $pos2-($pos+1)); + + // check every line of a style block... + if ($allow_remote) { + $a_styles = preg_split('/;[\r\n]*/', $styles, -1, PREG_SPLIT_NO_EMPTY); + foreach ($a_styles as $line) { + $stripped = preg_replace('/[^a-z\(:;]/i', '', $line); + // ... and only allow strict url() values + $regexp = '!url\s*\([ "\'](https?:)//[a-z0-9/._+-]+["\' ]\)!Uims'; + if (stripos($stripped, 'url(') && !preg_match($regexp, $line)) { + $a_styles = array('/* evil! */'); + break; + } + } + $styles = join(";\n", $a_styles); + } + + $key = $replacements->add($styles); + $source = substr($source, 0, $pos+1) + . $replacements->get_replacement($key) + . substr($source, $pos2, strlen($source)-$pos2); + $last_pos = $pos+2; + } + + // remove html comments and add #container to each tag selector. + // also replace body definition because we also stripped off the <body> tag + $styles = preg_replace( + array( + '/(^\s*<!--)|(-->\s*$)/', + '/(^\s*|,\s*|\}\s*)([a-z0-9\._#\*][a-z0-9\.\-_]*)/im', + '/'.preg_quote($container_id, '/').'\s+body/i', + ), + array( + '', + "\\1#$container_id \\2", + $container_id, + ), + $source); + + // put block contents back in + $styles = $replacements->resolve($styles); + + return $styles; + } + + + /** + * Generate CSS classes from mimetype and filename extension + * + * @param string $mimetype Mimetype + * @param string $filename Filename + * + * @return string CSS classes separated by space + */ + public static function file2class($mimetype, $filename) + { + list($primary, $secondary) = explode('/', $mimetype); + + $classes = array($primary ? $primary : 'unknown'); + if ($secondary) { + $classes[] = $secondary; + } + if (preg_match('/\.([a-z0-9]+)$/i', $filename, $m)) { + $classes[] = $m[1]; + } + + return strtolower(join(" ", $classes)); + } + + + /** + * Decode escaped entities used by known XSS exploits. + * See http://downloads.securityfocus.com/vulnerabilities/exploits/26800.eml for examples + * + * @param string CSS content to decode + * + * @return string Decoded string + */ + public static function xss_entity_decode($content) + { + $out = html_entity_decode(html_entity_decode($content)); + $out = preg_replace_callback('/\\\([0-9a-f]{4})/i', + array(self, 'xss_entity_decode_callback'), $out); + $out = preg_replace('#/\*.*\*/#Ums', '', $out); + + return $out; + } + + + /** + * preg_replace_callback callback for xss_entity_decode + * + * @param array $matches Result from preg_replace_callback + * + * @return string Decoded entity + */ + public static function xss_entity_decode_callback($matches) + { + return chr(hexdec($matches[1])); + } + + + /** + * Check if we can process not exceeding memory_limit + * + * @param integer Required amount of memory + * + * @return boolean True if memory won't be exceeded, False otherwise + */ + public static function mem_check($need) + { + $mem_limit = parse_bytes(ini_get('memory_limit')); + $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB + + return $mem_limit > 0 && $memory + $need > $mem_limit ? false : true; + } + + + /** + * Check if working in SSL mode + * + * @param integer $port HTTPS port number + * @param boolean $use_https Enables 'use_https' option checking + * + * @return boolean + */ + public static function https_check($port=null, $use_https=true) + { + global $RCMAIL; + + if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) != 'off') { + return true; + } + if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https') { + return true; + } + if ($port && $_SERVER['SERVER_PORT'] == $port) { + return true; + } + if ($use_https && isset($RCMAIL) && $RCMAIL->config->get('use_https')) { + return true; + } + + return false; + } + + + /** + * Replaces hostname variables. + * + * @param string $name Hostname + * @param string $host Optional IMAP hostname + * + * @return string Hostname + */ + public static function parse_host($name, $host = '') + { + // %n - host + $n = preg_replace('/:\d+$/', '', $_SERVER['SERVER_NAME']); + // %t - host name without first part, e.g. %n=mail.domain.tld, %t=domain.tld + $t = preg_replace('/^[^\.]+\./', '', $n); + // %d - domain name without first part + $d = preg_replace('/^[^\.]+\./', '', $_SERVER['HTTP_HOST']); + // %h - IMAP host + $h = $_SESSION['storage_host'] ? $_SESSION['storage_host'] : $host; + // %z - IMAP domain without first part, e.g. %h=imap.domain.tld, %z=domain.tld + $z = preg_replace('/^[^\.]+\./', '', $h); + // %s - domain name after the '@' from e-mail address provided at login screen. Returns FALSE if an invalid email is provided + if (strpos($name, '%s') !== false) { + $user_email = self::get_input_value('_user', self::INPUT_POST); + $user_email = self::idn_convert($user_email, true); + $matches = preg_match('/(.*)@([a-z0-9\.\-\[\]\:]+)/i', $user_email, $s); + if ($matches < 1 || filter_var($s[1]."@".$s[2], FILTER_VALIDATE_EMAIL) === false) { + return false; + } + } + + $name = str_replace(array('%n', '%t', '%d', '%h', '%z', '%s'), array($n, $t, $d, $h, $z, $s[2]), $name); + return $name; + } + + + /** + * Returns remote IP address and forwarded addresses if found + * + * @return string Remote IP address(es) + */ + public static function remote_ip() + { + $address = $_SERVER['REMOTE_ADDR']; + + // append the NGINX X-Real-IP header, if set + if (!empty($_SERVER['HTTP_X_REAL_IP'])) { + $remote_ip[] = 'X-Real-IP: ' . $_SERVER['HTTP_X_REAL_IP']; + } + // append the X-Forwarded-For header, if set + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $remote_ip[] = 'X-Forwarded-For: ' . $_SERVER['HTTP_X_FORWARDED_FOR']; + } + + if (!empty($remote_ip)) { + $address .= '(' . implode(',', $remote_ip) . ')'; + } + + return $address; + } + + + /** + * Read a specific HTTP request header. + * + * @param string $name Header name + * + * @return mixed Header value or null if not available + */ + public static function request_header($name) + { + if (function_exists('getallheaders')) { + $hdrs = array_change_key_case(getallheaders(), CASE_UPPER); + $key = strtoupper($name); + } + else { + $key = 'HTTP_' . strtoupper(strtr($name, '-', '_')); + $hdrs = array_change_key_case($_SERVER, CASE_UPPER); + } + + return $hdrs[$key]; + } + + /** + * Explode quoted string + * + * @param string Delimiter expression string for preg_match() + * @param string Input string + * + * @return array String items + */ + public static function explode_quoted_string($delimiter, $string) + { + $result = array(); + $strlen = strlen($string); + + for ($q=$p=$i=0; $i < $strlen; $i++) { + if ($string[$i] == "\"" && $string[$i-1] != "\\") { + $q = $q ? false : true; + } + else if (!$q && preg_match("/$delimiter/", $string[$i])) { + $result[] = substr($string, $p, $i - $p); + $p = $i + 1; + } + } + + $result[] = (string) substr($string, $p); + + return $result; + } + + + /** + * Improved equivalent to strtotime() + * + * @param string $date Date string + * + * @return int Unix timestamp + */ + public static function strtotime($date) + { + // check for MS Outlook vCard date format YYYYMMDD + if (preg_match('/^([12][90]\d\d)([01]\d)(\d\d)$/', trim($date), $matches)) { + return mktime(0,0,0, intval($matches[2]), intval($matches[3]), intval($matches[1])); + } + else if (is_numeric($date)) { + return $date; + } + + // support non-standard "GMTXXXX" literal + $date = preg_replace('/GMT\s*([+-][0-9]+)/', '\\1', $date); + + // if date parsing fails, we have a date in non-rfc format. + // remove token from the end and try again + while ((($ts = @strtotime($date)) === false) || ($ts < 0)) { + $d = explode(' ', $date); + array_pop($d); + if (!$d) { + break; + } + $date = implode(' ', $d); + } + + return $ts; + } + + + /* + * Idn_to_ascii wrapper. + * Intl/Idn modules version of this function doesn't work with e-mail address + */ + public static function idn_to_ascii($str) + { + return self::idn_convert($str, true); + } + + + /* + * Idn_to_ascii wrapper. + * Intl/Idn modules version of this function doesn't work with e-mail address + */ + public static function idn_to_utf8($str) + { + return self::idn_convert($str, false); + } + + + public static function idn_convert($input, $is_utf=false) + { + if ($at = strpos($input, '@')) { + $user = substr($input, 0, $at); + $domain = substr($input, $at+1); + } + else { + $domain = $input; + } + + $domain = $is_utf ? idn_to_ascii($domain) : idn_to_utf8($domain); + + if ($domain === false) { + return ''; + } + + return $at ? $user . '@' . $domain : $domain; + } + + /** + * Split the given string into word tokens + * + * @param string Input to tokenize + * @return array List of tokens + */ + public static function tokenize_string($str) + { + return explode(" ", preg_replace( + array('/[\s;\/+-]+/i', '/(\d)[-.\s]+(\d)/', '/\s\w{1,3}\s/u'), + array(' ', '\\1\\2', ' '), + $str)); + } + + /** + * Normalize the given string for fulltext search. + * Currently only optimized for Latin-1 characters; to be extended + * + * @param string Input string (UTF-8) + * @param boolean True to return list of words as array + * @return mixed Normalized string or a list of normalized tokens + */ + public static function normalize_string($str, $as_array = false) + { + // split by words + $arr = self::tokenize_string($str); + + foreach ($arr as $i => $part) { + if (utf8_encode(utf8_decode($part)) == $part) { // is latin-1 ? + $arr[$i] = utf8_encode(strtr(strtolower(strtr(utf8_decode($part), + 'ÇçäâàåéêëèïîìÅÉöôòüûùÿøØáíóúñÑÁÂÀãÃÊËÈÍÎÏÓÔõÕÚÛÙýÝ', + 'ccaaaaeeeeiiiaeooouuuyooaiounnaaaaaeeeiiioooouuuyy')), + array('ß' => 'ss', 'ae' => 'a', 'oe' => 'o', 'ue' => 'u'))); + } + else + $arr[$i] = mb_strtolower($part); + } + + return $as_array ? $arr : join(" ", $arr); + } + + /** + * Parse commandline arguments into a hash array + * + * @param array $aliases Argument alias names + * + * @return array Argument values hash + */ + public static function get_opt($aliases = array()) + { + $args = array(); + + for ($i=1; $i < count($_SERVER['argv']); $i++) { + $arg = $_SERVER['argv'][$i]; + $value = true; + $key = null; + + if ($arg[0] == '-') { + $key = preg_replace('/^-+/', '', $arg); + $sp = strpos($arg, '='); + if ($sp > 0) { + $key = substr($key, 0, $sp - 2); + $value = substr($arg, $sp+1); + } + else if (strlen($_SERVER['argv'][$i+1]) && $_SERVER['argv'][$i+1][0] != '-') { + $value = $_SERVER['argv'][++$i]; + } + + $args[$key] = is_string($value) ? preg_replace(array('/^["\']/', '/["\']$/'), '', $value) : $value; + } + else { + $args[] = $arg; + } + + if ($alias = $aliases[$key]) { + $args[$alias] = $args[$key]; + } + } + + return $args; + } + + /** + * Safe password prompt for command line + * from http://blogs.sitepoint.com/2009/05/01/interactive-cli-password-prompt-in-php/ + * + * @return string Password + */ + public static function prompt_silent($prompt = "Password:") + { + if (preg_match('/^win/i', PHP_OS)) { + $vbscript = sys_get_temp_dir() . 'prompt_password.vbs'; + $vbcontent = 'wscript.echo(InputBox("' . addslashes($prompt) . '", "", "password here"))'; + file_put_contents($vbscript, $vbcontent); + + $command = "cscript //nologo " . escapeshellarg($vbscript); + $password = rtrim(shell_exec($command)); + unlink($vbscript); + + return $password; + } + else { + $command = "/usr/bin/env bash -c 'echo OK'"; + if (rtrim(shell_exec($command)) !== 'OK') { + echo $prompt; + $pass = trim(fgets(STDIN)); + echo chr(8)."\r" . $prompt . str_repeat("*", strlen($pass))."\n"; + return $pass; + } + + $command = "/usr/bin/env bash -c 'read -s -p \"" . addslashes($prompt) . "\" mypassword && echo \$mypassword'"; + $password = rtrim(shell_exec($command)); + echo "\n"; + return $password; + } + } + + + /** + * Find out if the string content means true or false + * + * @param string $str Input value + * + * @return boolean Boolean value + */ + public static function get_boolean($str) + { + $str = strtolower($str); + + return !in_array($str, array('false', '0', 'no', 'off', 'nein', ''), true); + } + +} diff --git a/program/lib/Roundcube/rcube_vcard.php b/program/lib/Roundcube/rcube_vcard.php new file mode 100644 index 000000000..45ee601e5 --- /dev/null +++ b/program/lib/Roundcube/rcube_vcard.php @@ -0,0 +1,793 @@ +<?php + +/* + +-----------------------------------------------------------------------+ + | program/include/rcube_vcard.php | + | | + | This file is part of the Roundcube Webmail client | + | Copyright (C) 2008-2012, The Roundcube Dev Team | + | | + | Licensed under the GNU General Public License version 3 or | + | any later version with exceptions for skins & plugins. | + | See the README file for a full license statement. | + | | + | PURPOSE: | + | Logical representation of a vcard address record | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli <roundcube@gmail.com> | + | Author: Aleksander Machniak <alec@alec.pl> | + +-----------------------------------------------------------------------+ +*/ + + +/** + * Logical representation of a vcard-based address record + * Provides functions to parse and export vCard data format + * + * @package Framework + * @subpackage Addressbook + */ +class rcube_vcard +{ + private static $values_decoded = false; + private $raw = array( + 'FN' => array(), + 'N' => array(array('','','','','')), + ); + private static $fieldmap = array( + 'phone' => 'TEL', + 'birthday' => 'BDAY', + 'website' => 'URL', + 'notes' => 'NOTE', + 'email' => 'EMAIL', + 'address' => 'ADR', + 'jobtitle' => 'TITLE', + 'department' => 'X-DEPARTMENT', + 'gender' => 'X-GENDER', + 'maidenname' => 'X-MAIDENNAME', + 'anniversary' => 'X-ANNIVERSARY', + 'assistant' => 'X-ASSISTANT', + 'manager' => 'X-MANAGER', + 'spouse' => 'X-SPOUSE', + 'edit' => 'X-AB-EDIT', + ); + private $typemap = array('IPHONE' => 'mobile', 'CELL' => 'mobile', 'WORK,FAX' => 'workfax'); + private $phonetypemap = array('HOME1' => 'HOME', 'BUSINESS1' => 'WORK', 'BUSINESS2' => 'WORK2', 'BUSINESSFAX' => 'WORK,FAX', 'MOBILE' => 'CELL'); + private $addresstypemap = array('BUSINESS' => 'WORK'); + private $immap = array('X-JABBER' => 'jabber', 'X-ICQ' => 'icq', 'X-MSN' => 'msn', 'X-AIM' => 'aim', 'X-YAHOO' => 'yahoo', 'X-SKYPE' => 'skype', 'X-SKYPE-USERNAME' => 'skype'); + + public $business = false; + public $displayname; + public $surname; + public $firstname; + public $middlename; + public $nickname; + public $organization; + public $email = array(); + + public static $eol = "\r\n"; + + /** + * Constructor + */ + public function __construct($vcard = null, $charset = RCUBE_CHARSET, $detect = false, $fieldmap = array()) + { + if (!empty($fielmap)) + $this->extend_fieldmap($fieldmap); + + if (!empty($vcard)) + $this->load($vcard, $charset, $detect); + } + + + /** + * Load record from (internal, unfolded) vcard 3.0 format + * + * @param string vCard string to parse + * @param string Charset of string values + * @param boolean True if loading a 'foreign' vcard and extra heuristics for charset detection is required + */ + public function load($vcard, $charset = RCUBE_CHARSET, $detect = false) + { + self::$values_decoded = false; + $this->raw = self::vcard_decode($vcard); + + // resolve charset parameters + if ($charset == null) { + $this->raw = self::charset_convert($this->raw); + } + // vcard has encoded values and charset should be detected + else if ($detect && self::$values_decoded && + ($detected_charset = self::detect_encoding(self::vcard_encode($this->raw))) && $detected_charset != RCUBE_CHARSET) { + $this->raw = self::charset_convert($this->raw, $detected_charset); + } + + // consider FN empty if the same as the primary e-mail address + if ($this->raw['FN'][0][0] == $this->raw['EMAIL'][0][0]) + $this->raw['FN'][0][0] = ''; + + // find well-known address fields + $this->displayname = $this->raw['FN'][0][0]; + $this->surname = $this->raw['N'][0][0]; + $this->firstname = $this->raw['N'][0][1]; + $this->middlename = $this->raw['N'][0][2]; + $this->nickname = $this->raw['NICKNAME'][0][0]; + $this->organization = $this->raw['ORG'][0][0]; + $this->business = ($this->raw['X-ABSHOWAS'][0][0] == 'COMPANY') || (join('', (array)$this->raw['N'][0]) == '' && !empty($this->organization)); + + foreach ((array)$this->raw['EMAIL'] as $i => $raw_email) + $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email; + + // make the pref e-mail address the first entry in $this->email + $pref_index = $this->get_type_index('EMAIL', 'pref'); + if ($pref_index > 0) { + $tmp = $this->email[0]; + $this->email[0] = $this->email[$pref_index]; + $this->email[$pref_index] = $tmp; + } + } + + + /** + * Return vCard data as associative array to be unsed in Roundcube address books + * + * @return array Hash array with key-value pairs + */ + public function get_assoc() + { + $out = array('name' => $this->displayname); + $typemap = $this->typemap; + + // copy name fields to output array + foreach (array('firstname','surname','middlename','nickname','organization') as $col) { + if (strlen($this->$col)) + $out[$col] = $this->$col; + } + + if ($this->raw['N'][0][3]) + $out['prefix'] = $this->raw['N'][0][3]; + if ($this->raw['N'][0][4]) + $out['suffix'] = $this->raw['N'][0][4]; + + // convert from raw vcard data into associative data for Roundcube + foreach (array_flip(self::$fieldmap) as $tag => $col) { + foreach ((array)$this->raw[$tag] as $i => $raw) { + if (is_array($raw)) { + $k = -1; + $key = $col; + $subtype = ''; + + if (!empty($raw['type'])) { + $combined = join(',', self::array_filter((array)$raw['type'], 'internet,pref', true)); + $combined = strtoupper($combined); + + if ($typemap[$combined]) { + $subtype = $typemap[$combined]; + } + else if ($typemap[$raw['type'][++$k]]) { + $subtype = $typemap[$raw['type'][$k]]; + } + else { + $subtype = strtolower($raw['type'][$k]); + } + + while ($k < count($raw['type']) && ($subtype == 'internet' || $subtype == 'pref')) + $subtype = $typemap[$raw['type'][++$k]] ? $typemap[$raw['type'][$k]] : strtolower($raw['type'][$k]); + } + + // read vcard 2.1 subtype + if (!$subtype) { + foreach ($raw as $k => $v) { + if (!is_numeric($k) && $v === true && ($k = strtolower($k)) + && !in_array($k, array('pref','internet','voice','base64')) + ) { + $k_uc = strtoupper($k); + $subtype = $typemap[$k_uc] ? $typemap[$k_uc] : $k; + break; + } + } + } + + // force subtype if none set + if (!$subtype && preg_match('/^(email|phone|address|website)/', $key)) + $subtype = 'other'; + + if ($subtype) + $key .= ':' . $subtype; + + // split ADR values into assoc array + if ($tag == 'ADR') { + list(,, $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']) = $raw; + $out[$key][] = $value; + } + else + $out[$key][] = $raw[0]; + } + else { + $out[$col][] = $raw; + } + } + } + + // handle special IM fields as used by Apple + foreach ($this->immap as $tag => $type) { + foreach ((array)$this->raw[$tag] as $i => $raw) { + $out['im:'.$type][] = $raw[0]; + } + } + + // copy photo data + if ($this->raw['PHOTO']) + $out['photo'] = $this->raw['PHOTO'][0][0]; + + return $out; + } + + + /** + * Convert the data structure into a vcard 3.0 string + */ + public function export($folded = true) + { + $vcard = self::vcard_encode($this->raw); + return $folded ? self::rfc2425_fold($vcard) : $vcard; + } + + + /** + * Clear the given fields in the loaded vcard data + * + * @param array List of field names to be reset + */ + public function reset($fields = null) + { + if (!$fields) + $fields = array_merge(array_values(self::$fieldmap), array_keys($this->immap), array('FN','N','ORG','NICKNAME','EMAIL','ADR','BDAY')); + + foreach ($fields as $f) + unset($this->raw[$f]); + + if (!$this->raw['N']) + $this->raw['N'] = array(array('','','','','')); + if (!$this->raw['FN']) + $this->raw['FN'] = array(); + + $this->email = array(); + } + + + /** + * Setter for address record fields + * + * @param string Field name + * @param string Field value + * @param string Type/section name + */ + public function set($field, $value, $type = 'HOME') + { + $field = strtolower($field); + $type_uc = strtoupper($type); + + switch ($field) { + case 'name': + case 'displayname': + $this->raw['FN'][0][0] = $this->displayname = $value; + break; + + case 'surname': + $this->raw['N'][0][0] = $this->surname = $value; + break; + + case 'firstname': + $this->raw['N'][0][1] = $this->firstname = $value; + break; + + case 'middlename': + $this->raw['N'][0][2] = $this->middlename = $value; + break; + + case 'prefix': + $this->raw['N'][0][3] = $value; + break; + + case 'suffix': + $this->raw['N'][0][4] = $value; + break; + + case 'nickname': + $this->raw['NICKNAME'][0][0] = $this->nickname = $value; + break; + + case 'organization': + $this->raw['ORG'][0][0] = $this->organization = $value; + break; + + case 'photo': + if (strpos($value, 'http:') === 0) { + // TODO: fetch file from URL and save it locally? + $this->raw['PHOTO'][0] = array(0 => $value, 'url' => true); + } + else { + $this->raw['PHOTO'][0] = array(0 => $value, 'base64' => (bool) preg_match('![^a-z0-9/=+-]!i', $value)); + } + break; + + case 'email': + $this->raw['EMAIL'][] = array(0 => $value, 'type' => array_filter(array('INTERNET', $type_uc))); + $this->email[] = $value; + break; + + case 'im': + // save IM subtypes into extension fields + $typemap = array_flip($this->immap); + if ($field = $typemap[strtolower($type)]) + $this->raw[$field][] = array(0 => $value); + break; + + case 'birthday': + case 'anniversary': + if (($val = rcube_utils::strtotime($value)) && ($fn = self::$fieldmap[$field])) + $this->raw[$fn][] = array(0 => date('Y-m-d', $val), 'value' => array('date')); + break; + + case 'address': + if ($this->addresstypemap[$type_uc]) + $type = $this->addresstypemap[$type_uc]; + + $value = $value[0] ? $value : array('', '', $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']); + + // fall through if not empty + if (!strlen(join('', $value))) + break; + + default: + if ($field == 'phone' && $this->phonetypemap[$type_uc]) + $type = $this->phonetypemap[$type_uc]; + + if (($tag = self::$fieldmap[$field]) && (is_array($value) || strlen($value))) { + $index = count($this->raw[$tag]); + $this->raw[$tag][$index] = (array)$value; + if ($type) { + $typemap = array_flip($this->typemap); + $this->raw[$tag][$index]['type'] = explode(',', ($typemap[$type_uc] ? $typemap[$type_uc] : $type)); + } + } + break; + } + } + + /** + * Setter for individual vcard properties + * + * @param string VCard tag name + * @param array Value-set of this vcard property + * @param boolean Set to true if the value-set should be appended instead of replacing any existing value-set + */ + public function set_raw($tag, $value, $append = false) + { + $index = $append ? count($this->raw[$tag]) : 0; + $this->raw[$tag][$index] = (array)$value; + } + + + /** + * Find index with the '$type' attribute + * + * @param string Field name + * @return int Field index having $type set + */ + private function get_type_index($field, $type = 'pref') + { + $result = 0; + if ($this->raw[$field]) { + foreach ($this->raw[$field] as $i => $data) { + if (is_array($data['type']) && in_array_nocase('pref', $data['type'])) + $result = $i; + } + } + + return $result; + } + + + /** + * Convert a whole vcard (array) to UTF-8. + * If $force_charset is null, each member value that has a charset parameter will be converted + */ + private static function charset_convert($card, $force_charset = null) + { + foreach ($card as $key => $node) { + foreach ($node as $i => $subnode) { + if (is_array($subnode) && (($charset = $force_charset) || ($subnode['charset'] && ($charset = $subnode['charset'][0])))) { + foreach ($subnode as $j => $value) { + if (is_numeric($j) && is_string($value)) + $card[$key][$i][$j] = rcube_charset::convert($value, $charset); + } + unset($card[$key][$i]['charset']); + } + } + } + + return $card; + } + + + /** + * Extends fieldmap definition + */ + public function extend_fieldmap($map) + { + if (is_array($map)) + self::$fieldmap = array_merge($map, self::$fieldmap); + } + + + /** + * Factory method to import a vcard file + * + * @param string vCard file content + * @return array List of rcube_vcard objects + */ + public static function import($data) + { + $out = array(); + + // check if charsets are specified (usually vcard version < 3.0 but this is not reliable) + if (preg_match('/charset=/i', substr($data, 0, 2048))) + $charset = null; + // detect charset and convert to utf-8 + else if (($charset = self::detect_encoding($data)) && $charset != RCUBE_CHARSET) { + $data = rcube_charset::convert($data, $charset); + $data = preg_replace(array('/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'), '', $data); // also remove BOM + $charset = RCUBE_CHARSET; + } + + $vcard_block = ''; + $in_vcard_block = false; + + foreach (preg_split("/[\r\n]+/", $data) as $i => $line) { + if ($in_vcard_block && !empty($line)) + $vcard_block .= $line . "\n"; + + $line = trim($line); + + if (preg_match('/^END:VCARD$/i', $line)) { + // parse vcard + $obj = new rcube_vcard(self::cleanup($vcard_block), $charset, true, self::$fieldmap); + if (!empty($obj->displayname) || !empty($obj->email)) + $out[] = $obj; + + $in_vcard_block = false; + } + else if (preg_match('/^BEGIN:VCARD$/i', $line)) { + $vcard_block = $line . "\n"; + $in_vcard_block = true; + } + } + + return $out; + } + + + /** + * Normalize vcard data for better parsing + * + * @param string vCard block + * @return string Cleaned vcard block + */ + private static function cleanup($vcard) + { + // Convert special types (like Skype) to normal type='skype' classes with this simple regex ;) + $vcard = preg_replace( + '/item(\d+)\.(TEL|EMAIL|URL)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s', + '\2;type=\5\3:\4', + $vcard); + + // convert Apple X-ABRELATEDNAMES into X-* fields for better compatibility + $vcard = preg_replace_callback( + '/item(\d+)\.(X-ABRELATEDNAMES)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s', + array('self', 'x_abrelatednames_callback'), + $vcard); + + // Remove cruft like item1.X-AB*, item1.ADR instead of ADR, and empty lines + $vcard = preg_replace(array('/^item\d*\.X-AB.*$/m', '/^item\d*\./m', "/\n+/"), array('', '', "\n"), $vcard); + + // convert X-WAB-GENDER to X-GENDER + if (preg_match('/X-WAB-GENDER:(\d)/', $vcard, $matches)) { + $value = $matches[1] == '2' ? 'male' : 'female'; + $vcard = preg_replace('/X-WAB-GENDER:\d/', 'X-GENDER:' . $value, $vcard); + } + + // if N doesn't have any semicolons, add some + $vcard = preg_replace('/^(N:[^;\R]*)$/m', '\1;;;;', $vcard); + + return $vcard; + } + + private static function x_abrelatednames_callback($matches) + { + return 'X-' . strtoupper($matches[5]) . $matches[3] . ':'. $matches[4]; + } + + private static function rfc2425_fold_callback($matches) + { + // chunk_split string and avoid lines breaking multibyte characters + $c = 71; + $out .= substr($matches[1], 0, $c); + for ($n = $c; $c < strlen($matches[1]); $c++) { + // break if length > 75 or mutlibyte character starts after position 71 + if ($n > 75 || ($n > 71 && ord($matches[1][$c]) >> 6 == 3)) { + $out .= "\r\n "; + $n = 0; + } + $out .= $matches[1][$c]; + $n++; + } + + return $out; + } + + public static function rfc2425_fold($val) + { + return preg_replace_callback('/([^\n]{72,})/', array('self', 'rfc2425_fold_callback'), $val); + } + + + /** + * Decodes a vcard block (vcard 3.0 format, unfolded) + * into an array structure + * + * @param string vCard block to parse + * @return array Raw data structure + */ + private static function vcard_decode($vcard) + { + // Perform RFC2425 line unfolding and split lines + $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard); + $lines = explode("\n", $vcard); + $data = array(); + + for ($i=0; $i < count($lines); $i++) { + if (!preg_match('/^([^:]+):(.+)$/', $lines[$i], $line)) + continue; + + if (preg_match('/^(BEGIN|END)$/i', $line[1])) + continue; + + // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:" + if (($data['VERSION'][0] == "2.1") && preg_match('/^([^;]+);([^:]+)/', $line[1], $regs2) && !preg_match('/^TYPE=/i', $regs2[2])) { + $line[1] = $regs2[1]; + foreach (explode(';', $regs2[2]) as $prop) + $line[1] .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop); + } + + if (preg_match_all('/([^\\;]+);?/', $line[1], $regs2)) { + $entry = array(); + $field = strtoupper($regs2[1][0]); + $enc = null; + + foreach($regs2[1] as $attrid => $attr) { + if ((list($key, $value) = explode('=', $attr)) && $value) { + $value = trim($value); + if ($key == 'ENCODING') { + $value = strtoupper($value); + // add next line(s) to value string if QP line end detected + if ($value == 'QUOTED-PRINTABLE') { + while (preg_match('/=$/', $lines[$i])) + $line[2] .= "\n" . $lines[++$i]; + } + $enc = $value; + } + else { + $lc_key = strtolower($key); + $entry[$lc_key] = array_merge((array)$entry[$lc_key], (array)self::vcard_unquote($value, ',')); + } + } + else if ($attrid > 0) { + $entry[strtolower($key)] = true; // true means attr without =value + } + } + + // decode value + if ($enc || !empty($entry['base64'])) { + // save encoding type (#1488432) + if ($enc == 'B') { + $entry['encoding'] = 'B'; + // should we use vCard 3.0 instead? + // $entry['base64'] = true; + } + $line[2] = self::decode_value($line[2], $enc ? $enc : 'base64'); + } + + if ($enc != 'B' && empty($entry['base64'])) { + $line[2] = self::vcard_unquote($line[2]); + } + + $entry = array_merge($entry, (array) $line[2]); + $data[$field][] = $entry; + } + } + + unset($data['VERSION']); + return $data; + } + + + /** + * Decode a given string with the encoding rule from ENCODING attributes + * + * @param string String to decode + * @param string Encoding type (quoted-printable and base64 supported) + * @return string Decoded 8bit value + */ + private static function decode_value($value, $encoding) + { + switch (strtolower($encoding)) { + case 'quoted-printable': + self::$values_decoded = true; + return quoted_printable_decode($value); + + case 'base64': + case 'b': + self::$values_decoded = true; + return base64_decode($value); + + default: + return $value; + } + } + + + /** + * Encodes an entry for storage in our database (vcard 3.0 format, unfolded) + * + * @param array Raw data structure to encode + * @return string vCard encoded string + */ + static function vcard_encode($data) + { + foreach((array)$data as $type => $entries) { + /* valid N has 5 properties */ + while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5) + $entries[0][] = ""; + + // make sure FN is not empty (required by RFC2426) + if ($type == "FN" && empty($entries)) + $entries[0] = $data['EMAIL'][0][0]; + + foreach((array)$entries as $entry) { + $attr = ''; + if (is_array($entry)) { + $value = array(); + foreach($entry as $attrname => $attrvalues) { + if (is_int($attrname)) { + if (!empty($entry['base64']) || $entry['encoding'] == 'B') { + $attrvalues = base64_encode($attrvalues); + } + $value[] = $attrvalues; + } + else if (is_bool($attrvalues)) { + if ($attrvalues) { + $attr .= strtoupper(";$attrname"); // true means just tag, not tag=value, as in PHOTO;BASE64:... + } + } + else { + foreach((array)$attrvalues as $attrvalue) + $attr .= strtoupper(";$attrname=") . self::vcard_quote($attrvalue, ','); + } + } + } + else { + $value = $entry; + } + + // skip empty entries + if (self::is_empty($value)) + continue; + + $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . self::$eol; + } + } + + return 'BEGIN:VCARD' . self::$eol . 'VERSION:3.0' . self::$eol . $vcard . 'END:VCARD'; + } + + + /** + * Join indexed data array to a vcard quoted string + * + * @param array Field data + * @param string Separator + * @return string Joined and quoted string + */ + private static function vcard_quote($s, $sep = ';') + { + if (is_array($s)) { + foreach($s as $part) { + $r[] = self::vcard_quote($part, $sep); + } + return(implode($sep, (array)$r)); + } + else { + return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', ',' => '\,', ';' => '\;')); + } + } + + + /** + * Split quoted string + * + * @param string vCard string to split + * @param string Separator char/string + * @return array List with splited values + */ + private static function vcard_unquote($s, $sep = ';') + { + // break string into parts separated by $sep, but leave escaped $sep alone + if (count($parts = explode($sep, strtr($s, array("\\$sep" => "\007")))) > 1) { + foreach($parts as $s) { + $result[] = self::vcard_unquote(strtr($s, array("\007" => "\\$sep")), $sep); + } + return $result; + } + else { + return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\N' => "\n", '\,' => ',', '\;' => ';', '\:' => ':')); + } + } + + + /** + * Check if vCard entry is empty: empty string or an array with + * all entries empty. + * + * @param mixed $value Attribute value (string or array) + * + * @return bool True if the value is empty, False otherwise + */ + private static function is_empty($value) + { + foreach ((array)$value as $v) { + if (((string)$v) !== '') { + return false; + } + } + + return true; + } + + /** + * Extract array values by a filter + * + * @param array Array to filter + * @param keys Array or comma separated list of values to keep + * @param boolean Invert key selection: remove the listed values + * @return array The filtered array + */ + private static function array_filter($arr, $values, $inverse = false) + { + if (!is_array($values)) + $values = explode(',', $values); + + $result = array(); + $keep = array_flip((array)$values); + foreach ($arr as $key => $val) + if ($inverse != isset($keep[strtolower($val)])) + $result[$key] = $val; + + return $result; + } + + /** + * Returns UNICODE type based on BOM (Byte Order Mark) + * + * @param string Input string to test + * @return string Detected encoding + */ + private static function detect_encoding($string) + { + $fallback = rcube::get_instance()->config->get('default_charset', 'ISO-8859-1'); // fallback to Latin-1 + + return rcube_charset::detect($string, $fallback); + } + +} |