diff --git a/program/lib/Roundcube/bootstrap.php b/program/lib/Roundcube/bootstrap.php
index 84367df73..0e2b7f583 100644
--- a/program/lib/Roundcube/bootstrap.php
+++ b/program/lib/Roundcube/bootstrap.php
@@ -1,450 +1,453 @@
 <?php
 
 /**
  +-----------------------------------------------------------------------+
  | This file is part of the Roundcube webmail client                     |
  |                                                                       |
  | Copyright (C) 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 = [
     'error_reporting' => E_ALL & ~E_NOTICE & ~E_STRICT,
     'display_errors'  => false,
     'log_errors'      => true,
     // 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,
 ];
 
 // check these additional ini settings if not called via CLI
 if (php_sapi_name() != 'cli') {
     $config += [
         'suhosin.session.encrypt' => false,
         'file_uploads'            => true,
         'session.auto_start'      => false,
         'zlib.output_compression' => false,
     ];
 }
 
 foreach ($config as $optname => $optval) {
     $ini_optval = filter_var(ini_get($optname), is_bool($optval) ? FILTER_VALIDATE_BOOLEAN : FILTER_VALIDATE_INT);
     if ($optval != $ini_optval && @ini_set($optname, $optval) === false) {
         $optval = !is_bool($optval) ? $optval : ($optval ? 'On' : 'Off');
         $error  = "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).";
 
         if (defined('STDERR')) fwrite(STDERR, $error); else echo $error;
         exit(1);
     }
 }
 
 // framework constants
 define('RCUBE_VERSION', '1.5-git');
 define('RCUBE_CHARSET', 'UTF-8');
 define('RCUBE_TEMP_FILE_PREFIX', 'RCMTEMP');
 
 if (!defined('RCUBE_LIB_DIR')) {
     define('RCUBE_LIB_DIR', __DIR__ . '/');
 }
 
 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
 mb_internal_encoding(RCUBE_CHARSET);
 mb_regex_encoding(RCUBE_CHARSET);
 
 // make sure the Roundcube lib directory is in the include_path
 $rcube_path = realpath(RCUBE_LIB_DIR . '..');
 $sep        = PATH_SEPARATOR;
 $regexp     = "!(^|$sep)" . preg_quote($rcube_path, '!') . "($sep|\$)!";
 $path       = ini_get('include_path');
 
 if (!preg_match($regexp, $path)) {
     set_include_path($path . PATH_SEPARATOR . $rcube_path);
 }
 
 // Register autoloader
 spl_autoload_register('rcube_autoload');
 
 // set PEAR error handling (will also load the PEAR main class)
 if (class_exists('PEAR')) {
     PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, function($err) { rcube::raise_error($err, true); });
 }
 
 /**
  * Similar function as in_array() but case-insensitive with multibyte support.
  *
  * @param string $needle   Needle value
  * @param array  $heystack Array to search in
  *
  * @return bool True if found, False if not
  */
 function in_array_nocase($needle, $haystack)
 {
     if (!is_string($needle) || !is_array($haystack)) {
         return false;
     }
 
     // use much faster method for ascii
     if (is_ascii($needle)) {
         foreach ((array) $haystack as $value) {
             if (is_string($value) && strcasecmp($value, $needle) === 0) {
                 return true;
             }
         }
     }
     else {
         $needle = mb_strtolower($needle);
         foreach ((array) $haystack as $value) {
             if (is_string($value) && $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);
     }
 
     $bytes = 0;
 
     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
  *
  * @param string $str A string
  *
  * @return string A string ending with a slash
  */
 function slashify($str)
 {
     return unslashify($str) . '/';
 }
 
 /**
  * Remove slashes at the end of the string
  *
  * @param string $str A string
  *
  * @return string A string ending with no slash
  */
 function unslashify($str)
 {
     return rtrim($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;
 
         $prefix = mb_substr($str, 0, $first_part_length);
         $suffix = mb_substr($str, $second_starting_location);
         $str    = $prefix . $placeholder . $suffix;
     }
 
     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 = [];
 
     if (!empty($array) && is_array($array)) {
         foreach ($array as $key => $child) {
             $keys[] = $key;
             foreach (array_keys_recursive($child) as $val) {
                 $keys[] = $val;
             }
         }
     }
 
     return $keys;
 }
 
 /**
  * Get first element from an array
  *
  * @param array $array Input array
  *
  * @return mixed First element if found, Null otherwise
  */
 function array_first($array)
 {
     if (is_array($array)) {
         reset($array);
         foreach ($array as $element) {
             return $element;
         }
     }
 }
 
 /**
  * Remove all non-ascii and non-word chars except ., -, _
  *
  * @param string $str          A string
  * @param bool   $css_id       The result may be used as CSS identifier
  * @param string $replace_with Replacement character
  *
- * @return string Clean string
+ * @return string|null Clean string or null if $str is null
  */
 function asciiwords($str, $css_id = false, $replace_with = '')
 {
+    if ($str == null) {
+        return null;
+    }
     $allowed = 'a-z0-9\_\-' . (!$css_id ? '\.' : '');
     return preg_replace("/[^$allowed]+/i", $replace_with, (string) $str);
 }
 
 /**
  * Check if a string contains only ascii characters
  *
  * @param string $str           String to check
  * @param bool   $control_chars Includes control characters
  *
  * @return bool True if the string contains ASCII-only, False otherwise
  */
 function is_ascii($str, $control_chars = true)
 {
     $regexp = $control_chars ? '/[^\x00-\x7F]/' : '/[^\x20-\x7E]/';
     return preg_match($regexp, (string) $str) ? false : true;
 }
 
 /**
  * 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;
 }
 
 /**
  * Fix version number so it can be used correctly in version_compare()
  *
  * @param string $version Version number string
  *
  * @param return Version number string
  */
 function version_parse($version)
 {
     return str_replace(
         ['-stable', '-git'],
         ['.0', '.99'],
         $version
     );
 }
 
 /**
  * Use PHP5 autoload for dynamic class loading
  *
  * @param string $classname Class name
  *
  * @return bool True when the class file has been found
  *
  * @todo Make Zend, PEAR etc play with this
  * @todo Make our classes conform to a more straight forward CS.
  */
 function rcube_autoload($classname)
 {
     if (strpos($classname, 'rcube') === 0) {
         $classname = preg_replace('/^rcube_(cache|db|session|spellchecker)_/', '\\1/', $classname);
         $classname = 'Roundcube/' . $classname;
     }
     else if (strpos($classname, 'html_') === 0 || $classname === 'html') {
         $classname = 'Roundcube/html';
     }
     else if (strpos($classname, 'Mail_') === 0) {
         $classname = 'Mail/' . substr($classname, 5);
     }
     else if (strpos($classname, 'Net_') === 0) {
         $classname = 'Net/' . substr($classname, 4);
     }
     else if (strpos($classname, 'Auth_') === 0) {
         $classname = 'Auth/' . substr($classname, 5);
     }
 
     // Translate PHP namespaces into directories,
     // i.e. use \Sabre\VObject; $vcf = VObject\Reader::read(...)
     //      -> Sabre/VObject/Reader.php
     $classname = str_replace('\\', '/', $classname);
 
     if ($fp = @fopen("$classname.php", 'r', true)) {
         fclose($fp);
         include_once "$classname.php";
         return true;
     }
 
     return false;
 }
diff --git a/program/lib/Roundcube/rcube.php b/program/lib/Roundcube/rcube.php
index 1913ed79e..9ca6a711e 100644
--- a/program/lib/Roundcube/rcube.php
+++ b/program/lib/Roundcube/rcube.php
@@ -1,1846 +1,1846 @@
 <?php
 
 /**
  +-----------------------------------------------------------------------+
  | This file is part of the Roundcube Webmail client                     |
  |                                                                       |
  | Copyright (C) The Roundcube Dev Team                                  |
  | Copyright (C) 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
 {
     // Init options
     const INIT_WITH_DB      = 1;
     const INIT_WITH_PLUGINS = 2;
 
     // Request status
     const REQUEST_VALID       = 0;
     const REQUEST_ERROR_URL   = 1;
     const REQUEST_ERROR_TOKEN = 2;
 
     const DEBUG_LINE_LENGTH = 4096;
 
     /** @var rcube_config Stores instance of rcube_config */
     public $config;
 
     /** @var rcube_db Instance of database class */
     public $db;
 
     /** @var Memcache Instance of Memcache class */
     public $memcache;
 
     /** @var Memcached Instance of Memcached class */
     public $memcached;
 
     /** @var Redis Instance of Redis class */
     public $redis;
 
     /** @var rcube_session Instance of rcube_session class */
     public $session;
 
     /** @var rcube_smtp Instance of rcube_smtp class */
     public $smtp;
 
     /** @var rcube_storage Instance of rcube_storage class */
     public $storage;
 
     /** @var rcube_output Instance of rcube_output class */
     public $output;
 
     /** @var rcube_plugin_api Instance of rcube_plugin_api */
     public $plugins;
 
     /** @var rcube_user Instance of rcube_user class */
     public $user;
 
     /** @var int Request status */
     public $request_status = 0;
 
     /** @var array Localization */
     protected $texts;
 
     /** @var rcube_cache[] Initialized cache objects */
     protected $caches = [];
 
     /** @var array Registered shutdown functions */
     protected $shutdown_functions = [];
 
     /** @var rcube Singleton instance of rcube */
     static protected $instance;
 
 
     /**
      * This implements the 'singleton' design pattern
      *
      * @param int    $mode Options to initialize with this instance. See rcube::INIT_WITH_* constants
      * @param string $env  Environment name to run (e.g. live, dev, test)
      *
      * @return rcube The one and only instance
      */
     static function get_instance($mode = 0, $env = '')
     {
         if (!self::$instance) {
             self::$instance = new rcube($env);
             self::$instance->init($mode);
         }
 
         return self::$instance;
     }
 
     /**
      * Private constructor
      *
      * @param string $env Environment name to run (e.g. live, dev, test)
      */
     protected function __construct($env = '')
     {
         // load configuration
         $this->config  = new rcube_config($env);
         $this->plugins = new rcube_dummy_plugin_api;
 
         register_shutdown_function([$this, 'shutdown']);
     }
 
     /**
      * Initial startup function
      *
      * @param int $mode Options to initialize with this instance. See rcube::INIT_WITH_* constants
      */
     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) {
             $this->db = rcube_db::factory(
                 $this->config->get('db_dsnw'),
                 $this->config->get('db_dsnr'),
                 $this->config->get('db_persistent')
             );
 
             $this->db->set_debug((bool)$this->config->get('sql_debug'));
         }
 
         return $this->db;
     }
 
     /**
      * Get global handle for memcache access
      *
      * @return Memcache The memcache engine
      */
     public function get_memcache()
     {
         if (!isset($this->memcache)) {
             $this->memcache = rcube_cache_memcache::engine();
         }
 
         return $this->memcache;
     }
 
     /**
      * Get global handle for memcached access
      *
      * @return Memcached The memcached engine
      */
     public function get_memcached()
     {
         if (!isset($this->memcached)) {
             $this->memcached = rcube_cache_memcached::engine();
         }
 
         return $this->memcached;
     }
 
     /**
      * Get global handle for redis access
      *
      * @return Redis The redis engine
      */
     public function get_redis()
     {
         if (!isset($this->redis)) {
             $this->redis = rcube_cache_redis::engine();
         }
 
         return $this->redis;
     }
 
     /**
      * Initialize and get user cache object
      *
      * @param string $name    Cache identifier
      * @param string $type    Cache type ('db', 'apc', 'memcache', 'redis')
      * @param string $ttl     Expiration time for cache items
      * @param bool   $packed  Enables/disables data serialization
      * @param bool   $indexed Use indexed cache
      *
      * @return rcube_cache|null User cache object
      */
     public function get_cache($name, $type = 'db', $ttl = 0, $packed = true, $indexed = false)
     {
         if (!isset($this->caches[$name]) && ($userid = $this->get_user_id())) {
             $this->caches[$name] = rcube_cache::factory($type, $userid, $name, $ttl, $packed, $indexed);
         }
 
-        return $this->caches[$name];
+        return $this->caches[$name] ?? null;
     }
 
     /**
      * Initialize and get shared cache object
      *
      * @param string $name   Cache identifier
      * @param bool   $packed Enables/disables data serialization
      *
      * @return rcube_cache Shared cache object
      */
     public function get_cache_shared($name, $packed = true)
     {
         $shared_name = "shared_$name";
 
         if (!array_key_exists($shared_name, $this->caches)) {
             $opt  = strtolower($name) . '_cache';
             $type = $this->config->get($opt);
             $ttl  = $this->config->get($opt . '_ttl');
 
             if (!$type) {
                 // cache is disabled
                 return $this->caches[$shared_name] = null;
             }
 
             if ($ttl === null) {
                 $ttl = $this->config->get('shared_cache_ttl', '10d');
             }
 
             $this->caches[$shared_name] = rcube_cache::factory($type, null, $name, $ttl, $packed);
         }
 
         return $this->caches[$shared_name];
     }
 
     /**
      * Initialize HTTP client
      *
      * @param array $options Configuration options
      *
      * @return \GuzzleHttp\Client HTTP client
      */
     public function get_http_client($options = [])
     {
         return new \GuzzleHttp\Client($options + $this->config->get('http_client'));
     }
 
     /**
      * Create SMTP object and connect to server
      *
      * @param boolean $connect 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([
                     'code' => 700, '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;
 
         // set class options
         $options = [
             '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"),
             'disabled_caps'  => $this->config->get("{$driver}_disabled_caps"),
             'socket_options' => $this->config->get("{$driver}_conn_options"),
             'timeout'        => (int) $this->config->get("{$driver}_timeout"),
             'skip_deleted'   => (bool) $this->config->get('skip_deleted'),
             'driver'         => $driver,
         ];
 
         if (!empty($_SESSION['storage_host'])) {
             $options['language'] = $_SESSION['language'];
             $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();
 
         // subscribe to 'storage_connected' hook for session logging
         if ($this->config->get('imap_log_session', false)) {
             $this->plugins->register_hook('storage_connected', [$this, 'storage_log_session']);
         }
     }
 
     /**
      * Set storage parameters.
      */
     protected function set_storage_prop()
     {
         $storage = $this->get_storage();
 
         // set pagesize from config
         $pagesize = $this->config->get('mail_pagesize');
         if (!$pagesize) {
             $pagesize = $this->config->get('pagesize', 50);
         }
 
         $storage->set_pagesize($pagesize);
         $storage->set_charset($this->config->get('default_charset', RCUBE_CHARSET));
 
         // enable caching of mail data
         $driver         = $this->config->get('storage_driver', 'imap');
         $storage_cache  = $this->config->get("{$driver}_cache");
         $messages_cache = $this->config->get('messages_cache');
 
         // for backward compatibility
         if ($storage_cache === null && $messages_cache === null && $this->config->get('enable_caching')) {
             $storage_cache  = 'db';
             $messages_cache = true;
         }
 
         if ($storage_cache) {
             $storage->set_caching($storage_cache);
         }
 
         if ($messages_cache) {
             $storage->set_messages_caching(true);
         }
     }
 
     /**
      * Set special folders type association.
      * This must be done AFTER connecting to the server!
      */
     protected function set_special_folders()
     {
         $storage = $this->get_storage();
         $folders = $storage->get_special_folders(true);
         $prefs   = [];
 
         // check SPECIAL-USE flags on IMAP folders
         foreach ($folders as $type => $folder) {
             $idx = $type . '_mbox';
             if ($folder !== $this->config->get($idx)) {
                 $prefs[$idx] = $folder;
             }
         }
 
         // Some special folders differ, update user preferences
         if (!empty($prefs) && $this->user) {
             $this->user->save_prefs($prefs);
         }
 
         // create default folders (on login)
         if ($this->config->get('create_default_folders')) {
             $storage->create_default_folders();
         }
     }
 
     /**
      * Callback for IMAP connection events to log session identifiers
      *
      * @param array $args Callback arguments
      */
     public function storage_log_session($args)
     {
         if (!empty($args['session']) && session_id()) {
             $this->write_log('imap_session', $args['session']);
         }
     }
 
     /**
      * Create session object and start the session.
      */
     public function session_init()
     {
         // Ignore in CLI mode or when session started (Installer?)
         if (empty($_SERVER['REMOTE_ADDR']) || session_id()) {
             return;
         }
 
         $storage       = $this->config->get('session_storage', 'db');
         $sess_name     = $this->config->get('session_name');
         $sess_domain   = $this->config->get('session_domain');
         $sess_path     = $this->config->get('session_path');
         $sess_samesite = $this->config->get('session_samesite');
         $lifetime      = $this->config->get('session_lifetime', 0) * 60;
         $is_secure     = $this->config->get('use_https') || rcube_utils::https_check();
 
         // 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 samesite attribute
         // requires PHP >= 7.3.0, see https://wiki.php.net/rfc/same-site-cookie for more info
         if (version_compare(PHP_VERSION, '7.3.0', '>=') && $sess_samesite) {
             ini_set('session.cookie_samesite', $sess_samesite);
         }
         // set session garbage collecting time according to session_lifetime
         if ($lifetime) {
             ini_set('session.gc_maxlifetime', $lifetime * 2);
         }
 
         // set session cookie lifetime so it never expires (#5961)
         ini_set('session.cookie_lifetime', 0);
         ini_set('session.cookie_secure', $is_secure);
         ini_set('session.name', $sess_name ?: 'roundcube_sessid');
         ini_set('session.use_cookies', 1);
         ini_set('session.use_only_cookies', 1);
         ini_set('session.cookie_httponly', 1);
 
         // Make sure session garbage collector is enabled when using custom handlers (#6560)
         // Note: Use session.gc_divisor to control accuracy
         if ($storage != 'php' && !ini_get('session.gc_probability')) {
             ini_set('session.gc_probability', 1);
         }
 
         // Start the session
         $this->session = rcube_session::factory($this->config);
         $this->session->register_gc_handler([$this, 'gc']);
         $this->session->start();
     }
 
     /**
      * Garbage collector - cache/temp cleaner
      */
     public function gc()
     {
         rcube_cache::gc();
         $this->get_storage()->cache_gc();
         $this->gc_temp();
     }
 
     /**
      * Garbage collector function for temp files.
      * Removes temporary files older than temp_dir_ttl.
      */
     public function gc_temp()
     {
         $tmp = unslashify($this->config->get('temp_dir'));
 
         // expire in 48 hours by default
         $temp_dir_ttl = $this->config->get('temp_dir_ttl', '48h');
         $temp_dir_ttl = get_offset_sec($temp_dir_ttl);
         if ($temp_dir_ttl < 6*3600) {
             $temp_dir_ttl = 6*3600;   // 6 hours sensible lower bound.
         }
 
         $expire = time() - $temp_dir_ttl;
 
         if ($tmp && ($dir = opendir($tmp))) {
             while (($fname = readdir($dir)) !== false) {
                 if (strpos($fname, RCUBE_TEMP_FILE_PREFIX) !== 0) {
                     continue;
                 }
 
                 if (@filemtime("$tmp/$fname") < $expire) {
                     @unlink("$tmp/$fname");
                 }
             }
 
             closedir($dir);
         }
     }
 
     /**
      * Runs garbage collector with probability based on
      * session settings. This is intended for environments
      * without a session.
      */
     public function gc_run()
     {
         $probability = (int) ini_get('session.gc_probability');
         $divisor     = (int) ini_get('session.gc_divisor');
 
         if ($divisor > 0 && $probability > 0) {
             $random = mt_rand(1, $divisor);
             if ($random <= $probability) {
                 $this->gc();
             }
         }
     }
 
     /**
      * 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 = ['name' => $attrib];
         }
 
         $name = (string) $attrib['name'];
 
         // attrib contain text values: use them from now
         $slang = !empty($_SESSION['language']) ? strtolower($_SESSION['language']) : 'en_us';
         if (isset($attrib[$slang])) {
             $this->texts[$name] = $attrib[$slang];
         }
         else if ($slang != 'en_us' && isset($attrib['en_us'])) {
             $this->texts[$name] = $attrib['en_us'];
         }
 
         // check for text with domain
         if ($domain && isset($this->texts["$domain.$name"])) {
             $text = $this->texts["$domain.$name"];
         }
         else if (isset($this->texts[$name])) {
             $text = $this->texts[$name];
         }
 
         // text does not exist
         if (!isset($text)) {
             return "[$name]";
         }
 
         // replace vars in text
         if (!empty($attrib['vars']) && 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);
             }
         }
 
         // replace \n with real line break
         $text = strtr($text, ['\n' => "\n"]);
 
         // case folding
         if ((!empty($attrib['uppercase']) && strtolower($attrib['uppercase']) == 'first') || !empty($attrib['ucfirst'])) {
             $case_mode = MB_CASE_TITLE;
         }
         else if (!empty($attrib['uppercase'])) {
             $case_mode = MB_CASE_UPPER;
         }
         else if (!empty($attrib['lowercase'])) {
             $case_mode = MB_CASE_LOWER;
         }
 
         if (isset($case_mode)) {
             $text = mb_convert_case($text, $case_mode);
         }
 
         return $text;
     }
 
     /**
      * 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 bool 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 && isset($this->texts[$domain.'.'.$name])) {
             $ref_domain = $domain;
             return true;
         }
 
         return false;
     }
 
     /**
      * Load a localization package
      *
      * @param string $lang  Language ID
      * @param array  $add   Additional text labels/messages
      * @param array  $merge Additional text labels/messages to merge
      */
     public function load_language($lang = null, $add = [], $merge = [])
     {
         $sess_lang = !empty($_SESSION['language']) ? $_SESSION['language'] : 'en_US';
         $lang      = $this->language_prop($lang ?: $sess_lang);
 
         // load localized texts
         if (empty($this->texts) || $lang != $sess_lang) {
             // get english labels (these should be complete)
             $files = [
                 RCUBE_LOCALIZATION_DIR . 'en_US/labels.inc',
                 RCUBE_LOCALIZATION_DIR . 'en_US/messages.inc',
             ];
 
             // include user language files
             if ($lang != 'en' && $lang != 'en_US' && is_dir(RCUBE_LOCALIZATION_DIR . $lang)) {
                 $files[] = RCUBE_LOCALIZATION_DIR . $lang . '/labels.inc';
                 $files[] = RCUBE_LOCALIZATION_DIR . $lang . '/messages.inc';
             }
 
             $this->texts = [];
 
             foreach ($files as $file) {
                 $this->texts = self::read_localization_file($file, $this->texts);
             }
 
             $_SESSION['language'] = $lang;
         }
 
         // append additional texts (from plugin)
         if (is_array($add) && !empty($add)) {
             $this->texts += $add;
         }
 
         // merge additional texts (from plugin)
         if (is_array($merge) && !empty($merge)) {
             $this->texts = array_merge($this->texts, $merge);
         }
     }
 
     /**
      * Read localized texts from an additional location (plugins, skins).
      * Then you can use the result as 2nd arg to load_language().
      *
      * @param string      $dir  Directory to search in
      * @param string|null $lang Language code to read
      *
      * @return array Localization labels/messages
      */
     public function read_localization($dir, $lang = null)
     {
         if ($lang == null) {
             $lang = $_SESSION['language'];
         }
         $langs  = array_unique(['en_US', $lang]);
         $locdir = slashify($dir);
         $texts  = [];
 
         // Language aliases used to find localization in similar lang, see below
         $aliases = [
             'de_CH' => 'de_DE',
             'es_AR' => 'es_ES',
             'fa_AF' => 'fa_IR',
             'nl_BE' => 'nl_NL',
             'pt_BR' => 'pt_PT',
             'zh_CN' => 'zh_TW',
         ];
 
         foreach ($langs as $lng) {
             $fpath = $locdir . $lng . '.inc';
             $_texts = self::read_localization_file($fpath);
 
             if (!empty($_texts)) {
                 $texts = array_merge($texts, $_texts);
             }
             // Fallback to a localization in similar language (#1488401)
             else if ($lng != 'en_US') {
                 $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';
                     $texts = self::read_localization_file($fpath, $texts);
                 }
             }
         }
 
         return $texts;
     }
 
 
     /**
      * Load localization file
      *
      * @param string $file  File location
      * @param array  $texts Additional texts to merge with
      *
      * @return array Localization labels/messages
      */
     public static function read_localization_file($file, $texts = [])
     {
         if (is_file($file) && is_readable($file)) {
             $labels   = [];
             $messages = [];
 
             // use buffering to handle empty lines/spaces after closing PHP tag
             ob_start();
             include $file;
             ob_end_clean();
 
             if (!empty($labels)) {
                 $texts = array_merge($texts, $labels);
             }
 
             if (!empty($messages)) {
                 $texts = array_merge($texts, $messages);
             }
         }
 
         return $texts;
     }
 
     /**
      * Check the given string and return a valid language code
      *
      * @param string $lang 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') && !empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
             $accept_langs = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
             $lang         = $accept_langs[0];
 
             if (preg_match('/^([a-z]+)[_-]([a-z]+)$/i', $lang, $m)) {
                 $lang = $m[1] . '_' . strtoupper($m[2]);
             }
         }
 
         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 ($lang && !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 (!$lang || !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 = [];
 
         if (!count($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 (isset($rcube_languages[$name])) {
                         $sa_languages[$name] = $rcube_languages[$name];
                     }
                 }
 
                 closedir($dh);
             }
         }
 
         return $sa_languages;
     }
 
     /**
      * Encrypt a string
      *
      * @param string $clear  Clear text input
      * @param string $key    Encryption key to retrieve from the configuration, defaults to 'des_key'
      * @param bool   $base64 Whether or not to base64_encode() the result before returning
      *
      * @return string|false Encrypted text, false on error
      */
     public function encrypt($clear, $key = 'des_key', $base64 = true)
     {
         if (!is_string($clear) || !strlen($clear)) {
             return '';
         }
 
         $ckey   = $this->config->get_crypto_key($key);
         $method = $this->config->get_crypto_method();
         $opts   = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : true;
         $iv     = rcube_utils::random_bytes(openssl_cipher_iv_length($method), true);
         $cipher = openssl_encrypt($clear, $method, $ckey, $opts, $iv);
 
         if ($cipher === false) {
             self::raise_error([
                     'file'    => __FILE__,
                     'line'    => __LINE__,
                     'message' => "Failed to encrypt data with configured cipher method: $method!"
                 ], true, false);
 
             return false;
         }
 
         $cipher = $iv . $cipher;
 
         return $base64 ? base64_encode($cipher) : $cipher;
     }
 
     /**
      * Decrypt a string
      *
      * @param string $cipher Encrypted text
      * @param string $key    Encryption key to retrieve from the configuration, defaults to 'des_key'
      * @param bool   $base64 Whether or not input is base64-encoded
      *
      * @return string|false Decrypted text, false on error
      */
     public function decrypt($cipher, $key = 'des_key', $base64 = true)
     {
         if (strlen($cipher) == 0) {
             return false;
         }
 
         if ($base64) {
             $cipher = base64_decode($cipher);
             if ($cipher === false) {
                 return false;
             }
         }
 
         $ckey    = $this->config->get_crypto_key($key);
         $method  = $this->config->get_crypto_method();
         $opts    = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : true;
         $iv_size = openssl_cipher_iv_length($method);
         $iv      = substr($cipher, 0, $iv_size);
 
         // session corruption? (#1485970)
         if (strlen($iv) < $iv_size) {
             return false;
         }
 
         $cipher = substr($cipher, $iv_size);
         $clear  = openssl_decrypt($cipher, $method, $ckey, $opts, $iv);
 
         return $clear;
     }
 
     /**
      * Returns session token for secure URLs
      *
      * @param bool $generate Generate token if not exists in session yet
      *
      * @return string|bool Token string, False when disabled
      */
     public function get_secure_url_token($generate = false)
     {
         if ($len = $this->config->get('use_secure_urls')) {
-            if (empty($_SESSION['secure_token']) && $generate) {
+            if (empty($_SESSION['secure_token'] ?? null) && $generate) {
                 // generate x characters long token
                 $length = $len > 1 ? $len : 16;
                 $token  = rcube_utils::random_bytes($length);
 
                 $plugin = $this->plugins->exec_hook('secure_token', ['value' => $token, 'length' => $length]);
 
                 $_SESSION['secure_token'] = $plugin['value'];
             }
 
-            return $_SESSION['secure_token'];
+            return $_SESSION['secure_token'] ?? false;
         }
 
         return false;
     }
 
     /**
      * Generate a unique token to be used in a form request
      *
      * @return string The request token
      */
     public function get_request_token()
     {
         if (empty($_SESSION['request_token'])) {
             $plugin = $this->plugins->exec_hook('request_token', ['value' => rcube_utils::random_bytes(32)]);
 
             $_SESSION['request_token'] = $plugin['value'];
         }
 
         return $_SESSION['request_token'];
     }
 
     /**
      * Check if the current request contains a valid token.
      * Empty requests aren't checked until use_secure_urls is set.
      *
      * @param int $mode Request method
      *
      * @return bool True if request token is valid false if not
      */
     public function check_request($mode = rcube_utils::INPUT_POST)
     {
         // check secure token in URL if enabled
         if ($token = $this->get_secure_url_token()) {
             foreach (explode('/', preg_replace('/[?#&].*$/', '', $_SERVER['REQUEST_URI'])) as $tok) {
                 if ($tok == $token) {
                     return true;
                 }
             }
 
             $this->request_status = self::REQUEST_ERROR_URL;
 
             return false;
         }
 
         $sess_tok = $this->get_request_token();
 
         // ajax requests
         if (rcube_utils::request_header('X-Roundcube-Request') === $sess_tok) {
             return true;
         }
 
         // skip empty requests
         if (($mode == rcube_utils::INPUT_POST && empty($_POST))
             || ($mode == rcube_utils::INPUT_GET && empty($_GET))
         ) {
             return true;
         }
 
         // default method of securing requests
         $token = rcube_utils::get_input_value('_token', $mode);
 
         if (empty($_COOKIE[ini_get('session.name')]) || $token !== $sess_tok) {
             $this->request_status = self::REQUEST_ERROR_TOKEN;
             return false;
         }
 
         return true;
     }
 
     /**
      * Build a valid URL to this instance of Roundcube
      *
      * @param mixed $p 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);
         }
 
         // write session data as soon as possible and before
         // closing database connection, don't do this before
         // registered shutdown functions, they may need the session
         // Note: this will run registered gc handlers (ie. cache gc)
         if (!empty($_SERVER['REMOTE_ADDR']) && is_object($this->session)) {
             $this->session->write_close();
         }
 
         if (is_object($this->smtp)) {
             $this->smtp->disconnect();
         }
 
         foreach ($this->caches as $cache) {
             if (is_object($cache)) {
                 $cache->close();
             }
         }
 
         if (is_object($this->storage)) {
             $this->storage->close();
         }
 
         if ($this->config->get('log_driver') == 'syslog') {
             closelog();
         }
     }
 
     /**
      * 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 Function callback
      */
     public function add_shutdown_function($function)
     {
         $this->shutdown_functions[] = $function;
     }
 
     /**
      * When you're going to sleep the script execution for a longer time
      * it is good to close all external connections (sql, memcache, redis, SMTP, IMAP).
      *
      * No action is required on wake up, all connections will be
      * re-established automatically.
      */
     public function sleep()
     {
         foreach ($this->caches as $cache) {
             if (is_object($cache)) {
                 $cache->close();
             }
         }
 
         if ($this->storage) {
             $this->storage->close();
         }
 
         if ($this->db) {
             $this->db->closeConnection();
         }
 
         if ($this->memcache) {
             $this->memcache->close();
         }
 
         if ($this->memcached) {
             $this->memcached->quit();
         }
 
         if ($this->smtp) {
             $this->smtp->disconnect();
         }
 
         if ($this->redis) {
             $this->redis->close();
         }
     }
 
     /**
      * Quote a given string.
      * Shortcut function for rcube_utils::rep_specialchars_output()
      *
      * @param string $str      A string to quote
      * @param string $mode     Replace mode for tags: show|remove|strict
      * @param bool   $newlines Convert newlines
      *
      * @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()
      *
      * @param string $str A string to quote
      *
      * @return string JS-quoted string
      */
     public static function JQ($str)
     {
         return rcube_utils::rep_specialchars_output($str, 'js');
     }
 
     /**
      * Quote a given string, remove new-line characters, use strict mode.
      * Shortcut function for rcube_utils::rep_specialchars_output()
      *
      * @param string $str A string to quote
      *
      * @return string HTML-quoted string
      */
     public static function SQ($str)
     {
         return rcube_utils::rep_specialchars_output($str, 'html', 'strict', false);
     }
 
     /**
      * Construct shell command, execute it and return output as string.
      * Keywords {keyword} are replaced with arguments
      *
      * @param string $cmd        Format string with {keywords} to be replaced
      * @param mixed  $values,... (zero, one or more arrays can be passed)
      *
      * @return string Output of command. Shell errors not detectable
      */
     public static function exec(/* $cmd, $values1 = [], ... */)
     {
         $args   = func_get_args();
         $cmd    = array_shift($args);
         $values = $replacements = [];
 
         // 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 = [];
 
             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] = implode(' ', $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', ['args' => $args]);
             if ($plugin['abort']) {
                 return;
             }
 
             $args = $plugin['args'];
         }
 
         $msg = [];
         foreach ($args as $arg) {
             $msg[] = !is_string($arg) ? var_export($arg, true) : $arg;
         }
 
         self::write_log('console', implode(";\n", $msg));
     }
 
     /**
      * Append a line to a logfile in the logs directory.
      * Date will be added automatically to the line.
      *
      * @param string $name Name of the log file
      * @param mixed  $line Line to append
      *
      * @return bool True on success, False on failure
      */
     public static function write_log($name, $line)
     {
         if (!is_string($line)) {
             $line = var_export($line, true);
         }
 
         $date_format = $log_driver = $session_key = null;
         if (self::$instance) {
             $date_format = self::$instance->config->get('log_date_format');
             $log_driver  = self::$instance->config->get('log_driver');
             $session_key = intval(self::$instance->config->get('log_session_id', 8));
         }
 
         $date = rcube_utils::date_format($date_format);
 
         // trigger logging hook
         if (is_object(self::$instance) && is_object(self::$instance->plugins)) {
             $log = self::$instance->plugins->exec_hook('write_log',
                 ['name' => $name, 'date' => $date, 'line' => $line]
             );
 
             $name = $log['name'];
             $line = $log['line'];
             $date = $log['date'];
 
             if (!empty($log['abort'])) {
                 return true;
             }
         }
 
         // add session ID to the log
         if ($session_key > 0 && ($sess = session_id())) {
             $line = '<' . substr($sess, 0, $session_key) . '> ' . $line;
         }
 
         if ($log_driver == 'syslog') {
             $prio = $name == 'errors' ? LOG_ERR : LOG_INFO;
             return syslog($prio, $line);
         }
 
         // write message with file name when configured to log to STDOUT
         if ($log_driver == 'stdout') {
             $stdout = "php://stdout";
             $line = "$name: $line\n";
             return file_put_contents($stdout, $line, FILE_APPEND) !== false;
         }
 
         // log_driver == 'file' is assumed here
 
         $line = sprintf("[%s]: %s\n", $date, $line);
 
         // per-user logging is activated
         if (self::$instance && self::$instance->config->get('per_user_logging')
             && self::$instance->get_user_id()
             && !in_array($name, ['userlogins', 'sendmail'])
         ) {
             $log_dir = self::$instance->get_user_log_dir();
             if (empty($log_dir) && $name !== 'errors') {
                 return false;
             }
         }
 
         if (empty($log_dir)) {
             if (!empty($log['dir'])) {
                 $log_dir = $log['dir'];
             }
             else if (self::$instance) {
                 $log_dir = self::$instance->config->get('log_dir');
             }
         }
 
         if (empty($log_dir)) {
             $log_dir = RCUBE_INSTALL_PATH . 'logs';
         }
 
         if (self::$instance) {
             $name .= self::$instance->config->get('log_file_ext', '.log');
         }
         else {
             $name .= '.log';
         }
 
         return file_put_contents("$log_dir/$name", $line, FILE_APPEND) !== false;
     }
 
     /**
      * Throw system error (and show error page).
      *
      * @param array $arg Named parameters
      *      - code:    Error code (required)
      *      - type:    Error type [php|db|imap|javascript]
      *      - message: Error message
      *      - file:    File where error occurred
      *      - line:    Line where error occurred
      * @param bool $log       True to log the error
      * @param bool $terminate Terminate script execution
      */
     public static function raise_error($arg = [], $log = false, $terminate = false)
     {
         // handle PHP exceptions
         if ($arg instanceof Exception) {
             $arg = [
                 'code' => $arg->getCode(),
                 'line' => $arg->getLine(),
                 'file' => $arg->getFile(),
                 'message' => $arg->getMessage(),
             ];
         }
         else if (is_object($arg) && is_a($arg, 'PEAR_Error')) {
             $info = $arg->getUserInfo();
             $arg  = [
                 'code'    => $arg->getCode(),
                 'message' => $arg->getMessage() . ($info ? ': ' . $info : ''),
             ];
         }
         else if (is_string($arg)) {
             $arg = ['message' => $arg];
         }
 
         if (empty($arg['code'])) {
             $arg['code'] = 500;
         }
 
         $cli = php_sapi_name() == 'cli';
 
         $arg['cli'] = $cli;
         $arg['log'] = $log;
         $arg['terminate'] = $terminate;
 
         // send error to external error tracking tool
         if (self::$instance) {
             $arg = self::$instance->plugins->exec_hook('raise_error', $arg);
         }
 
         // installer
         if (!$cli && class_exists('rcmail_install', false)) {
             $rci = rcmail_install::get_instance();
             $rci->raise_error($arg);
             return;
         }
 
         if (!isset($arg['message'])) {
             $arg['message'] = '';
         }
 
         if (($log || $terminate) && !$cli && $arg['message']) {
             $arg['fatal'] = $terminate;
             self::log_bug($arg);
         }
 
         if ($cli) {
             fwrite(STDERR, 'ERROR: ' . trim($arg['message']) . "\n");
         }
         else if ($terminate && is_object(self::$instance->output)) {
             self::$instance->output->raise_error($arg['code'], $arg['message']);
         }
         else if ($terminate) {
             header("HTTP/1.0 500 Internal Error");
         }
 
         // terminate script
         if ($terminate) {
             if (defined('ROUNDCUBE_TEST_MODE') && ROUNDCUBE_TEST_MODE) {
                 throw new Exception('Error raised');
             }
             exit(1);
         }
     }
 
     /**
      * Log an error
      *
      * @param array $arg_arr Named parameters
      * @see self::raise_error()
      */
     public static function log_bug($arg_arr)
     {
         $program = !empty($arg_arr['type']) ? strtoupper($arg_arr['type']) : 'PHP';
         $uri     = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
 
         // write error to local log file
         if ($_SERVER['REQUEST_METHOD'] == 'POST') {
             $post_query = [];
             foreach (['_task', '_action'] as $arg) {
                 if (isset($_POST[$arg]) && !isset($_GET[$arg])) {
                     $post_query[$arg] = $_POST[$arg];
                 }
             }
 
             if (!empty($post_query)) {
                 $uri .= (strpos($uri, '?') != false ? '&' : '?')
                     . http_build_query($post_query, '', '&');
             }
         }
 
         $log_entry = sprintf("%s Error: %s%s (%s %s)",
             $program,
             $arg_arr['message'],
             !empty($arg_arr['file']) ? sprintf(' in %s on line %d', $arg_arr['file'], $arg_arr['line']) : '',
             $_SERVER['REQUEST_METHOD'],
             $uri
         );
 
         if (!self::write_log('errors', $log_entry)) {
             // send error to PHPs error handler if write_log didn't succeed
             trigger_error($arg_arr['message'], E_USER_WARNING);
         }
     }
 
     /**
      * Write debug info to the log
      *
      * @param string $engine Engine type - file name (memcache, apc, redis)
      * @param string $data   Data string to log
      * @param bool   $result Operation result
      */
     public static function debug($engine, $data, $result = null)
     {
         static $debug_counter;
 
         $line = '[' . (++$debug_counter[$engine]) . '] ' . $data;
 
         if (($len = strlen($line)) > self::DEBUG_LINE_LENGTH) {
             $diff = $len - self::DEBUG_LINE_LENGTH;
             $line = substr($line, 0, self::DEBUG_LINE_LENGTH) . "... [truncated $diff bytes]";
         }
 
         if ($result !== null) {
             $line .= ' [' . ($result ? 'TRUE' : 'FALSE') . ']';
         }
 
         self::write_log($engine, $line);
     }
 
     /**
      * 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));
     }
 
     /**
      * Setter for system user object
      *
      * @param rcube_user Current user instance
      */
     public function set_user($user)
     {
         if (is_object($user)) {
             $this->user = $user;
 
             // overwrite config with user preferences
             $this->config->set_user_prefs((array)$this->user->get_prefs());
         }
     }
 
     /**
      * 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'];
         }
     }
 
     /**
      * 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 (!empty($this->user_email)) {
             return $this->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 (!empty($this->password)) {
             return $this->password;
         }
 
         if (isset($_SESSION['password'])) {
             return $this->decrypt($_SESSION['password']);
         }
     }
 
     /**
      * Get the per-user log directory
      *
      * @return string|false Per-user log directory if it exists and is writable, False otherwise
      */
     protected function get_user_log_dir()
     {
         $log_dir      = $this->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs');
         $user_name    = $this->get_user_name();
         $user_log_dir = $log_dir . '/' . $user_name;
 
         return !empty($user_name) && is_writable($user_log_dir) ? $user_log_dir : false;
     }
 
     /**
      * Getter for logged user language code.
      *
      * @return string User language code
      */
     public function get_user_language()
     {
         if (is_object($this->user)) {
             return $this->user->language;
         }
         else if (isset($_SESSION['language'])) {
             return $_SESSION['language'];
         }
     }
 
     /**
      * Unique Message-ID generator.
      *
      * @param string $sender Optional sender e-mail address
      *
      * @return string Message-ID
      */
     public function gen_message_id($sender = null)
     {
         $local_part  = md5(uniqid('rcube'.mt_rand(), true));
         $domain_part = '';
 
         if ($sender && preg_match('/@([^\s]+\.[a-z0-9-]+)/', $sender, $m)) {
             $domain_part = $m[1];
         }
         else {
             $domain_part = $this->user->get_username('domain');
         }
 
         // Try to find FQDN, some spamfilters doesn't like 'localhost' (#1486924)
         if (!preg_match('/\.[a-z0-9-]+$/i', $domain_part)) {
             foreach ([$_SERVER['HTTP_HOST'], $_SERVER['SERVER_NAME']] as $host) {
                 $host = preg_replace('/:[0-9]+$/', '', $host);
                 if ($host && preg_match('/\.[a-z]+$/i', $host)) {
                     $domain_part = $host;
                     break;
                 }
             }
         }
 
         return sprintf('<%s@%s>', $local_part, $domain_part);
     }
 
     /**
      * Send the given message using the configured method.
      *
      * @param Mail_Mime    &$message    Reference to Mail_MIME object
      * @param string       $from        Sender address string
      * @param array|string $mailto      Either a comma-separated list of recipients (RFC822 compliant),
      *                                  or an array of recipients, each RFC822 valid
      * @param array|string &$error      SMTP error array or (deprecated) string
      * @param string       &$body_file  Location of file with saved message body,
      *                                  used when delay_file_io is enabled
      * @param array        $options     SMTP options (e.g. DSN request)
      * @param bool         $disconnect  Close SMTP connection ASAP
      *
      * @return bool Send status.
      */
     public function deliver_message(&$message, $from, $mailto, &$error,
         &$body_file = null, $options = null, $disconnect = false)
     {
         $plugin = $this->plugins->exec_hook('message_before_send', [
                 'message' => $message,
                 'from'    => $from,
                 'mailto'  => $mailto,
                 'options' => $options,
         ]);
 
         if ($plugin['abort']) {
             if (!empty($plugin['error'])) {
                 $error = $plugin['error'];
             }
             if (!empty($plugin['body_file'])) {
                 $body_file = $plugin['body_file'];
             }
 
             return isset($plugin['result']) ? $plugin['result'] : false;
         }
 
         $from    = $plugin['from'];
         $mailto  = $plugin['mailto'];
         $options = $plugin['options'];
         $message = $plugin['message'];
         $headers = $message->headers();
 
         // generate list of recipients
         $a_recipients = (array) $mailto;
 
         if (!empty($headers['Cc'])) {
             $a_recipients[] = $headers['Cc'];
         }
         if (!empty($headers['Bcc'])) {
             $a_recipients[] = $headers['Bcc'];
         }
 
         // remove Bcc header and get the whole head of the message as string
         $smtp_headers = $message->txtHeaders(['Bcc' => null], true);
 
         if ($message->getParam('delay_file_io')) {
             // use common temp dir
             $body_file   = rcube_utils::temp_filename('msg');
             $mime_result = $message->saveMessageBody($body_file);
 
             if (is_a($mime_result, 'PEAR_Error')) {
                 self::raise_error([
                         'code' => 650, 'file' => __FILE__, 'line' => __LINE__,
                         'message' => "Could not create message: ".$mime_result->getMessage()
                     ],
                     true, false
                 );
                 return false;
             }
 
             $msg_body = fopen($body_file, 'r');
         }
         else {
             $msg_body = $message->get();
         }
 
         // initialize SMTP connection
         if (!is_object($this->smtp)) {
             $this->smtp_init(true);
         }
 
         // send message
         $sent     = $this->smtp->send_mail($from, $a_recipients, $smtp_headers, $msg_body, $options);
         $response = $this->smtp->get_response();
         $error    = $this->smtp->get_error();
 
         if (!$sent) {
             self::raise_error([
                     'code' => 800, 'type' => 'smtp',
                     'line' => __LINE__, 'file' => __FILE__,
                     'message' => implode("\n", $response)
                 ], true, false);
 
             // allow plugins to catch sending errors with the same parameters as in 'message_before_send'
             $plugin = $this->plugins->exec_hook('message_send_error', $plugin + ['error' => $error]);
             $error = $plugin['error'];
         }
         else {
             $this->plugins->exec_hook('message_sent', ['headers' => $headers, 'body' => $msg_body, 'message' => $message]);
 
             // remove MDN/DSN headers after sending
             unset($headers['Return-Receipt-To'], $headers['Disposition-Notification-To']);
 
             if ($this->config->get('smtp_log')) {
                 // get all recipient addresses
                 $mailto = implode(',', $a_recipients);
                 $mailto = rcube_mime::decode_address_list($mailto, null, false, null, true);
 
                 self::write_log('sendmail', sprintf("User %s [%s]; Message %s for %s; %s",
                     $this->user->get_username(),
                     rcube_utils::remote_addr(),
                     $headers['Message-ID'],
                     implode(', ', $mailto),
                     !empty($response) ? implode('; ', $response) : '')
                 );
             }
         }
 
         if (is_resource($msg_body)) {
             fclose($msg_body);
         }
 
         if ($disconnect) {
             $this->smtp->disconnect();
         }
 
         // Add Bcc header back
         if (!empty($headers['Bcc'])) {
             $message->headers(['Bcc' => $headers['Bcc']], true);
         }
 
         return $sent;
     }
 }
 
 
 /**
  * Lightweight plugin API class serving as a dummy if plugins are not enabled
  *
  * @package    Framework
  * @subpackage Core
  */
 class rcube_dummy_plugin_api
 {
     /**
      * Triggers a plugin hook.
      *
      * @param string $hook Hook name
      * @param array  $args Hook arguments
      *
      * @return array Hook arguments
      * @see rcube_plugin_api::exec_hook()
      */
     public function exec_hook($hook, $args = [])
     {
         return $args;
     }
 }
diff --git a/program/lib/Roundcube/rcube_imap_cache.php b/program/lib/Roundcube/rcube_imap_cache.php
index e2320deea..90da252eb 100644
--- a/program/lib/Roundcube/rcube_imap_cache.php
+++ b/program/lib/Roundcube/rcube_imap_cache.php
@@ -1,1263 +1,1263 @@
 <?php
 
 /**
  +-----------------------------------------------------------------------+
  | This file is part of the Roundcube Webmail client                     |
  |                                                                       |
  | Copyright (C) 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
  */
 class rcube_imap_cache
 {
     const MODE_INDEX   = 1;
     const MODE_MESSAGE = 2;
 
     /**
      * Instance of rcube_imap
      *
      * @var rcube_imap
      */
     private $imap;
 
     /**
      * Instance of rcube_db
      *
      * @var rcube_db
      */
     private $db;
 
     /**
      * User ID
      *
      * @var int
      */
     private $userid;
 
     /**
      * Expiration time in seconds
      *
      * @var int
      */
     private $ttl;
 
     /**
      * Maximum cached message size
      *
      * @var int
      */
     private $threshold;
 
     /**
      * Internal (in-memory) cache
      *
      * @var array
      */
     private $icache = [];
 
     private $skip_deleted = false;
     private $mode;
     private $index_table;
     private $thread_table;
     private $messages_table;
 
     /**
      * 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 = [
         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',
         65536   => 'HASATTACHMENT',
         131072  => 'HASNOATTACHMENT',
     ];
 
 
     /**
      * Object constructor.
      *
      * @param rcube_db   $db           DB handler
      * @param rcube_imap $imap         IMAP handler
      * @param int        $userid       User identifier
      * @param bool       $skip_deleted skip_deleted flag
      * @param string     $ttl          Expiration time of memcache/apc items
      * @param int        $threshold    Maximum cached message size
      */
     function __construct($db, $imap, $userid, $skip_deleted, $ttl = 0, $threshold = 0)
     {
         // convert ttl string to seconds
         $ttl = get_offset_sec($ttl);
         if ($ttl > 2592000) $ttl = 2592000;
 
         $this->db           = $db;
         $this->imap         = $imap;
         $this->userid       = $userid;
         $this->skip_deleted = $skip_deleted;
         $this->ttl          = $ttl;
         $this->threshold    = $threshold;
 
         // cache all possible information by default
         $this->mode = self::MODE_INDEX | self::MODE_MESSAGE;
 
         // database tables
         $this->index_table    = $db->table_name('cache_index', true);
         $this->thread_table   = $db->table_name('cache_thread', true);
         $this->messages_table = $db->table_name('cache_messages', true);
     }
 
     /**
      * Cleanup actions (on shutdown).
      */
     public function close()
     {
         $this->save_icache();
         $this->icache = null;
     }
 
     /**
      * Set cache mode
      *
      * @param int $mode Cache mode
      */
     public function set_mode($mode)
     {
         $this->mode = $mode;
     }
 
     /**
      * 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] = [];
         }
 
         $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 (empty($this->icache[$mailbox]['index']['validated'])) {
                 $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;
             $modseq = isset($index['modseq']) ? $index['modseq'] : null;
 
             if ($sort_field == 'ANY') {
-                $sort_field = $index['sort_field'];
+                $sort_field = $index['sort_field'] ?? null;
             }
 
-            if ($sort_field != $index['sort_field']) {
+            if ($sort_field != ($index['sort_field'] ?? null)) {
                 $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;
             }
 
             if ($sort_field == 'ANY') {
                 $sort_field = '';
             }
 
             // Got it in internal cache, so the row already exist
             $exists = array_key_exists('index', $this->icache[$mailbox]);
 
             $modseq = null;
         }
 
         // 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);
 
             if (isset($mbox_data['HIGHESTMODSEQ'])) {
                 $modseq = $mbox_data['HIGHESTMODSEQ'];
             }
 
             // insert/update
             $this->add_index_row($mailbox, $sort_field, $data, $mbox_data, $exists, $modseq);
         }
 
         $this->icache[$mailbox]['index'] = [
             'validated'  => true,
             'object'     => $data,
             'sort_field' => $sort_field,
             'modseq'     => $modseq
         ];
 
         return $data;
     }
 
     /**
      * Return messages thread.
      * If threaded index doesn't exist or is invalid, will be updated.
      *
      * @param string $mailbox Folder name
      *
      * @return array Messages threaded index
      */
     function get_thread($mailbox)
     {
         if (empty($this->icache[$mailbox])) {
             $this->icache[$mailbox] = [];
         }
 
         // Seek in internal cache
         if (array_key_exists('thread', $this->icache[$mailbox])) {
             return $this->icache[$mailbox]['thread']['object'];
         }
 
         $index = null;
 
         // 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);
             // Get THREADS result
             $index['object'] = $this->get_thread_data($mailbox, $mbox_data);
 
             // insert/update
             $this->add_thread_row($mailbox, $index['object'], $mbox_data, !empty($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 = [])
     {
         $result = [];
 
         if (empty($msgs)) {
             return $result;
         }
 
         if ($this->mode & self::MODE_MESSAGE) {
             // Fetch messages from cache
             $sql_result = $this->db->query(
                 "SELECT `uid`, `data`, `flags`"
                 ." FROM {$this->messages_table}"
                 ." WHERE `user_id` = ?"
                     ." AND `mailbox` = ?"
                     ." AND `uid` IN (".$this->db->array2list($msgs, 'integer').")",
                 $this->userid, $mailbox);
 
             $msgs = array_flip($msgs);
 
             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]);
                 }
             }
 
             $this->db->reset();
 
             $msgs = array_flip($msgs);
         }
 
         // Fetch not found messages from IMAP server
         if (!empty($msgs)) {
             $messages = $this->imap->fetch_headers($mailbox, $msgs, false, true);
 
             // Insert to DB and add to result list
             if (!empty($messages)) {
                 foreach ($messages as $msg) {
                     if ($this->mode & self::MODE_MESSAGE) {
                         $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 (!empty($this->icache['__message'])
             && $this->icache['__message']['mailbox'] == $mailbox
             && $this->icache['__message']['object']->uid == $uid
         ) {
             return $this->icache['__message']['object'];
         }
 
         $message = null;
         $found   = false;
 
         if ($this->mode & self::MODE_MESSAGE) {
             $sql_result = $this->db->query(
                 "SELECT `flags`, `data`"
                 ." FROM {$this->messages_table}"
                 ." 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
         }
 
         if (!($this->mode & self::MODE_MESSAGE)) {
             return $message;
         }
 
         // 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'] = [
                 '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 existence check
      */
     function add_message($mailbox, $message, $force = false)
     {
         if (!is_object($message) || empty($message->uid)) {
             return;
         }
 
         if (!($this->mode & self::MODE_MESSAGE)) {
             return;
         }
 
         $flags = 0;
         $msg   = clone $message;
 
         if (!empty($message->flags)) {
             foreach ($this->flags as $idx => $flag) {
                 if (!empty($message->flags[$flag])) {
                     $flags += $idx;
                 }
             }
         }
 
         unset($msg->flags);
 
         $msg     = $this->db->encode($msg, true);
         $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL);
 
         $this->db->insert_or_update(
             $this->messages_table,
             ['user_id' => $this->userid, 'mailbox' => $mailbox, 'uid' => (int) $message->uid],
             ['flags', 'expires', 'data'],
             [$flags, $expires, $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;
         }
 
         if (!($this->mode & self::MODE_MESSAGE)) {
             return;
         }
 
         $flag = strtoupper($flag);
         $idx  = (int) array_search($flag, $this->flags);
         $uids = (array) $uids;
 
         if (!$idx) {
             return;
         }
 
         // Internal cache update
         if (
             !empty($this->icache['__message'])
             && ($message = $this->icache['__message'])
             && $message['mailbox'] === $mailbox
             && in_array($message['object']->uid, $uids)
         ) {
             $message['object']->flags[$flag] = $enabled;
 
             if (count($uids) == 1) {
                 return;
             }
         }
 
         $binary_check = $this->db->db_provider == 'oracle' ? "BITAND(`flags`, %d)" : "(`flags` & %d)";
 
         $this->db->query(
             "UPDATE {$this->messages_table}"
             ." SET `expires` = ". ($this->ttl ? $this->db->now($this->ttl) : 'NULL')
             .", `flags` = `flags` ".($enabled ? "+ $idx" : "- $idx")
             ." WHERE `user_id` = ?"
                 ." AND `mailbox` = ?"
                 .(!empty($uids) ? " AND `uid` IN (".$this->db->array2list($uids, 'integer').")" : "")
                 ." AND " . sprintf($binary_check, $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 (!($this->mode & self::MODE_MESSAGE)) {
             return;
         }
 
         if (!strlen($mailbox)) {
             $this->db->query(
                 "DELETE FROM {$this->messages_table}"
                 ." WHERE `user_id` = ?",
                 $this->userid);
         }
         else {
             // Remove the message from internal cache
             if (
                 !empty($uids)
                 && !empty($this->icache['__message'])
                 && ($message = $this->icache['__message'])
                 && $message['mailbox'] === $mailbox
                 && in_array($message['object']->uid, (array) $uids)
             ) {
                 $this->icache['__message'] = null;
             }
 
             $this->db->query(
                 "DELETE FROM {$this->messages_table}"
                 ." 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)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         // 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->index_table}"
                 ." WHERE `user_id` = ?"
                     .(strlen($mailbox) ? " AND `mailbox` = ".$this->db->quote($mailbox) : ""),
                 $this->userid
             );
         }
         else {
             $this->db->query(
                 "UPDATE {$this->index_table}"
                 ." 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 = [];
         }
     }
 
     /**
      * Clears thread cache.
      *
      * @param string $mailbox Folder name
      */
     function remove_thread($mailbox = null)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         $this->db->query(
             "DELETE FROM {$this->thread_table}"
             ." 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 = [];
         }
     }
 
     /**
      * 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 expired cache entries
      */
     static function gc()
     {
         $rcube = rcube::get_instance();
         $db    = $rcube->get_dbh();
         $now   = $db->now();
 
         $db->query("DELETE FROM " . $db->table_name('cache_messages', true)
               ." WHERE `expires` < $now");
 
         $db->query("DELETE FROM " . $db->table_name('cache_index', true)
               ." WHERE `expires` < $now");
 
         $db->query("DELETE FROM ".$db->table_name('cache_thread', true)
               ." WHERE `expires` < $now");
     }
 
     /**
      * Fetches index data from database
      */
     private function get_index_row($mailbox)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         // Get index from DB
         $sql_result = $this->db->query(
             "SELECT `data`, `valid`"
             ." FROM {$this->index_table}"
             ." WHERE `user_id` = ?"
                 ." AND `mailbox` = ?",
             $this->userid, $mailbox
         );
 
         if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
             $data  = explode('@', $sql_arr['data']);
             $index = $this->db->decode($data[0], true);
             unset($data[0]);
 
             if (empty($index)) {
                 $index = new rcube_result_index($mailbox);
             }
 
             return [
                 'valid'      => $sql_arr['valid'],
                 'object'     => $index,
                 'sort_field' => $data[1],
                 'deleted'    => $data[2],
                 'validity'   => $data[3],
                 'uidnext'    => $data[4],
                 'modseq'     => $data[5],
             ];
         }
     }
 
     /**
      * Fetches thread data from database
      */
     private function get_thread_row($mailbox)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         // Get thread from DB
         $sql_result = $this->db->query(
             "SELECT `data`"
             ." FROM {$this->thread_table}"
             ." WHERE `user_id` = ?"
                 ." AND `mailbox` = ?",
             $this->userid, $mailbox);
 
         if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
             $data   = explode('@', $sql_arr['data']);
             $thread = $this->db->decode($data[0], true);
             unset($data[0]);
 
             if (empty($thread)) {
                 $thread = new rcube_result_thread($mailbox);
             }
 
             return [
                 'object'   => $thread,
                 'deleted'  => $data[1],
                 'validity' => $data[2],
                 'uidnext'  => $data[3],
             ];
         }
     }
 
     /**
      * Saves index data into database
      */
     private function add_index_row($mailbox, $sort_field, $data, $mbox_data = [], $exists = false, $modseq = null)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         $data = [
             $this->db->encode($data, true),
             $sort_field,
             (int) $this->skip_deleted,
             (int) $mbox_data['UIDVALIDITY'],
             (int) $mbox_data['UIDNEXT'],
             $modseq ?: (isset($mbox_data['HIGHESTMODSEQ']) ? $mbox_data['HIGHESTMODSEQ'] : ''),
         ];
 
         $data    = implode('@', $data);
         $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL);
 
         $this->db->insert_or_update(
             $this->index_table,
             ['user_id' => $this->userid, 'mailbox' => $mailbox],
             ['valid', 'expires', 'data'],
             [1, $expires, $data]
         );
     }
 
     /**
      * Saves thread data into database
      */
     private function add_thread_row($mailbox, $data, $mbox_data = [], $exists = false)
     {
         if (!($this->mode & self::MODE_INDEX)) {
             return;
         }
 
         $data = [
             $this->db->encode($data, true),
             (int) $this->skip_deleted,
             (int) $mbox_data['UIDVALIDITY'],
             (int) $mbox_data['UIDNEXT'],
         ];
 
         $data    = implode('@', $data);
         $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL);
 
         $this->db->insert_or_update(
             $this->thread_table,
             ['user_id' => $this->userid, 'mailbox' => $mailbox],
             ['expires', 'data'],
             [$expires, $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;
         }
 
         $index['validated'] = true;
 
         // 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']) {
+        if (($index['validity'] ?? null) != ($mbox_data['UIDVALIDITY'] ?? null)) {
             $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 if ($object->is_empty()) {
                 // We have to run ALL UNDELETED search anyway for this case, so we can
                 // return early to skip the following search command.
                 return false;
             }
             else {
                 // get all undeleted messages excluding cached UIDs
                 $existing = rcube_imap_generic::compressMessageSet($object->get());
                 $ids = $this->imap->search_once($mailbox, "ALL UNDELETED NOT UID $existing");
 
                 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)) {
                 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    = [];
         $removed = [];
 
         // Get known UIDs
         if ($this->mode & self::MODE_MESSAGE) {
             $sql_result = $this->db->query(
                 "SELECT `uid`"
                 ." FROM {$this->messages_table}"
                 ." 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, ['FLAGS'], $index['modseq'], $qresync);
 
             if (!empty($result)) {
                 foreach ($result as $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->messages_table}"
                         ." SET `flags` = ?, `expires` = " . ($this->ttl ? $this->db->now($this->ttl) : 'NULL')
                         ." 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 = isset($mbox_data['VANISHED']) ? rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']) : null;
                 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);
             }
         }
 
         $sort_field = $index['sort_field'];
         $sort_order = $index['object']->get_parameters('ORDER');
         $exists     = true;
 
         // Validate index
         if (!$this->validate($mailbox, $index, $exists)) {
             // Invalidate (remove) thread index
             // if $exists=false it was already removed in validate()
             if ($exists) {
                 $this->remove_thread($mailbox);
             }
 
             // 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($sql_arr['data'], true);
 
         if ($message) {
             $message->flags = [];
             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 (!empty($this->icache['__message'])) {
             $message = $this->icache['__message'];
 
             // clean up some object's data
             $this->message_object_prepare($message['object']);
 
             // calculate current md5 sum
             $md5sum = md5(serialize($message['object']));
 
             if ($message['md5sum'] != $md5sum) {
                 $this->add_message($message['mailbox'], $message['object'], !$message['exists']);
             }
 
             $this->icache['__message']['md5sum'] = $md5sum;
         }
     }
 
     /**
      * Prepares message object to be stored in database.
      *
      * @param rcube_message_header|rcube_message_part
      */
     private function message_object_prepare(&$msg, &$size = 0)
     {
         // Remove body too big
         if (isset($msg->body)) {
             $length = strlen($msg->body);
 
             if (!empty($msg->body_modified) || $size + $length > $this->threshold * 1024) {
                 unset($msg->body);
             }
             else {
                 $size += $length;
             }
         }
 
         // 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 (!empty($msg->mimetype)) {
             list($msg->ctype_primary, $msg->ctype_secondary) = explode('/', $msg->mimetype);
         }
 
         unset($msg->replaces);
 
         if (!empty($msg->structure) && is_object($msg->structure)) {
             $this->message_object_prepare($msg->structure, $size);
         }
 
         if (!empty($msg->parts) && is_array($msg->parts)) {
             foreach ($msg->parts as $part) {
                 $this->message_object_prepare($part, $size);
             }
         }
     }
 
     /**
      * Fetches index data from IMAP server
      */
     private function get_index_data($mailbox, $sort_field, $sort_order, $mbox_data = [])
     {
         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;
     }
 
     /**
      * Fetches thread data from IMAP server
      */
     private function get_thread_data($mailbox, $mbox_data = [])
     {
         if (empty($mbox_data)) {
             $mbox_data = $this->imap->folder_data($mailbox);
         }
 
         if ($mbox_data['EXISTS']) {
             // get all threads (default sort order)
             return $this->imap->threads_direct($mailbox);
         }
 
         return new rcube_result_thread($mailbox, '* THREAD');
     }
 }
diff --git a/program/lib/Roundcube/rcube_ldap.php b/program/lib/Roundcube/rcube_ldap.php
index ce051aec6..2997a480c 100644
--- a/program/lib/Roundcube/rcube_ldap.php
+++ b/program/lib/Roundcube/rcube_ldap.php
@@ -1,2284 +1,2284 @@
 <?php
 
 /**
  +-----------------------------------------------------------------------+
  | This file is part of the Roundcube Webmail client                     |
  |                                                                       |
  | Copyright (C) The Roundcube Dev Team                                  |
  | Copyright (C) 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    = [];
     public $export_groups = false;
 
     // private properties
     protected $ldap;
     protected $formats  = [];
     protected $prop     = [];
     protected $fieldmap = [];
     protected $filter   = '';
     protected $sub_filter;
     protected $result;
     protected $ldap_result;
     protected $mail_domain = '';
     protected $debug       = false;
 
     /**
      * Group objectclass (lowercase) to member attribute mapping
      *
      * @var array
      */
     private $group_types = [
         'group'                   => 'member',
         'groupofnames'            => 'member',
         'kolabgroupofnames'       => 'member',
         'groupofuniquenames'      => 'uniqueMember',
         'kolabgroupofuniquenames' => 'uniqueMember',
         'univentiongroup'         => 'uniqueMember',
         'groupofurls'             => null,
     ];
 
     private $base_dn        = '';
     private $groups_base_dn = '';
     private $group_data;
     private $group_search_cache;
     private $cache;
 
 
     /**
     * Object constructor
     *
     * @param array  $p           LDAP connection properties
     * @param bool   $debug       Enables debug mode
     * @param string $mail_domain Current user mail domain name
     */
     function __construct($p, $debug = false, $mail_domain = null)
     {
         $this->prop = $p;
 
         $fetch_attributes = ['objectClass'];
 
         // check if groups are configured
         if (!empty($p['groups']) && is_array($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';
             }
             // extend group objectclass => member attribute mapping
             if (!empty($this->prop['groups']['class_member_attr'])) {
                 $this->group_types = array_merge($this->group_types, $this->prop['groups']['class_member_attr']);
             }
 
             // add group name attrib to the list of attributes to be fetched
             $fetch_attributes[] = $this->prop['groups']['name_attr'];
         }
         if (isset($p['group_filters']) && is_array($p['group_filters'])) {
             $this->groups = $this->groups || count($p['group_filters']) > 0;
 
             foreach ($p['group_filters'] as $k => $group_filter) {
                 // set default name attribute to cn
                 if (empty($group_filter['name_attr']) && empty($this->prop['groups']['name_attr'])) {
                     $this->prop['group_filters'][$k]['name_attr'] = $group_filter['name_attr'] = 'cn';
                 }
 
                 if (!empty($group_filter['name_attr'])) {
                     $fetch_attributes[] = $group_filter['name_attr'];
                 }
             }
         }
 
         // fieldmap property is given
         if (isset($p['fieldmap']) && is_array($p['fieldmap'])) {
             $p['fieldmap'] = array_filter($p['fieldmap']);
             foreach ($p['fieldmap'] as $rf => $lf) {
                 $this->fieldmap[$rf] = $this->_attr_name($lf);
             }
         }
         else if (!empty($p)) {
             // read deprecated *_field properties to remain backwards compatible
             foreach ($p as $prop => $value) {
                 if (!empty($value) && preg_match('/^(.+)_field$/', $prop, $matches)) {
                     $this->fieldmap[$matches[1]] = $this->_attr_name($value);
                 }
             }
         }
 
         // use fieldmap to advertise supported coltypes to the application
         foreach ($this->fieldmap as $colv => $lfv) {
-            list($col, $type) = explode(':', $colv);
+            list($col, $type) = array_pad(explode(':', $colv), 2, null);
             $params           = explode(':', $lfv);
 
             $lf    = array_shift($params);
             $limit = 1;
 
             foreach ($params as $idx => $param) {
                 // field format specification
                 if (preg_match('/^(date)\[(.+)\]$/i', $param, $m)) {
                     $this->formats[$lf] = ['type' => strtolower($m[1]), 'format' => $m[2]];
                 }
                 // first argument is a limit
                 else if ($idx === 0) {
                     if ($param == '*') $limit = null;
                     else               $limit = max(1, intval($param));
                 }
                 // second is a composite field separator
                 else if ($idx === 1 && $param) {
                     $this->coltypes[$col]['serialized'][$type] = $param;
                 }
             }
 
-            if (!is_array($this->coltypes[$col])) {
+            if (!is_array($this->coltypes[$col] ?? null)) {
                 $subtypes = $type ? [$type] : null;
                 $this->coltypes[$col] = ['limit' => $limit, 'subtypes' => $subtypes, 'attributes' => [$lf]];
             }
             elseif ($type) {
                 $this->coltypes[$col]['subtypes'][] = $type;
                 $this->coltypes[$col]['attributes'][] = $lf;
                 $this->coltypes[$col]['limit'] += $limit;
             }
 
             $this->fieldmap[$colv] = $lf;
         }
 
         // support for composite address
         if (!empty($this->coltypes['street']) && !empty($this->coltypes['locality'])) {
             $this->coltypes['address'] = [
                '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'   => [],
                'attributes' => [],
                ] + (array)$this->coltypes['address'];
 
             foreach (['street','locality','zipcode','region','country'] as $childcol) {
                 if ($this->coltypes[$childcol]) {
                     $this->coltypes['address']['childs'][$childcol] = ['type' => 'text'];
                     $this->coltypes['address']['attributes'] = array_merge($this->coltypes['address']['attributes'], $this->coltypes[$childcol]['attributes']);
                     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'] = ['home'];
             }
         }
         else if (!empty($this->coltypes['address'])) {
             $this->coltypes['address'] += ['type' => 'textarea', 'childs' => null, 'size' => 40];
 
             // 'serialized' means the UI has to present a composite address field
             if (!empty($this->coltypes['address']['serialized'])) {
                 $childprop = ['type' => 'text'];
                 $this->coltypes['address']['type'] = 'composite';
                 $this->coltypes['address']['childs'] = [
                     'street'   => $childprop,
                     'locality' => $childprop,
                     'zipcode'  => $childprop,
                     'country'  => $childprop
                 ];
             }
         }
 
         // make sure 'required_fields' is an array
         if (!isset($this->prop['required_fields'])) {
             $this->prop['required_fields'] = [];
         }
         else 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']))
+            && !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($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 $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 . ')';
             }
         }
 
         if (!empty($p['sort'])) {
             $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
         }
 
         $this->debug       = $debug;
         $this->mail_domain = $this->prop['mail_domain'] = $mail_domain;
 
         // initialize cache
         $rcube = rcube::get_instance();
         if ($cache_type = $rcube->config->get('ldap_cache', 'db')) {
             $cache_ttl  = $rcube->config->get('ldap_cache_ttl', '10m');
             $cache_name = 'LDAP.' . (!empty($this->prop['name']) ? asciiwords($this->prop['name']) : 'unnamed');
 
             $this->cache = $rcube->get_cache($cache_name, $cache_type, $cache_ttl);
         }
 
         // determine which attributes to fetch
         $this->prop['list_attributes'] = array_unique($fetch_attributes);
         $this->prop['attributes']      = array_merge(array_values($this->fieldmap), $fetch_attributes);
 
         foreach ($rcube->config->get('contactlist_fields') as $col) {
             $this->prop['list_attributes'] = array_merge($this->prop['list_attributes'], $this->_map_field($col));
         }
 
         // initialize ldap wrapper object
         $this->ldap = new rcube_ldap_generic($this->prop);
         $this->ldap->config_set(['cache' => $this->cache, 'debug' => $this->debug]);
 
         $this->_connect();
     }
 
     /**
      * Establish a connection to the LDAP server
      */
     private function _connect()
     {
         $rcube = rcube::get_instance();
 
         if ($this->ready) {
             return true;
         }
 
         if (empty($this->prop['hosts'])) {
             $this->prop['hosts'] = [];
         }
 
         // try to connect + bind for every host configured
         // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable
         // see http://www.php.net/manual/en/function.ldap-connect.php
         foreach ((array) $this->prop['hosts'] as $host) {
             // skip host if connection failed
             if (!$this->ldap->connect($host)) {
                 continue;
             }
 
             // See if the directory is writeable.
             if (!empty($this->prop['writable'])) {
                 $this->readonly = false;
             }
 
             // trigger post-connect hook
             $rcube = rcube::get_instance();
             $conf  = $rcube->plugins->exec_hook('ldap_connected', $this->prop + ['host' => $host]);
 
             $bind_pass   = $conf['bind_pass'];
-            $bind_user   = $conf['bind_user'];
+            $bind_user   = $conf['bind_user'] ?? null;
             $bind_dn     = $conf['bind_dn'];
-            $auth_method = $conf['auth_method'];
+            $auth_method = $conf['auth_method'] ?? null;
 
             $this->base_dn        = $conf['base_dn'];
-            $this->groups_base_dn = $conf['groups']['base_dn'] ?: $this->base_dn;
+            $this->groups_base_dn = $conf['groups']['base_dn'] ?? $this->base_dn;
 
             // User specific access, generate the proper values to use.
             if (!empty($conf['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() ?: $conf['username'])) {
                     list($u, $d) = explode('@', $fu);
                 }
                 else {
                     $u = '';
                     $d = $this->mail_domain;
                 }
 
                 $dc = 'dc='.strtr($d, ['.' => ',dc=']); // hierarchal domain string
 
                 // resolve $dc through LDAP
                 if (
                     !empty($conf['domain_filter'])
                     && !empty($conf['search_bind_dn'])
                     && method_exists($this->ldap, 'domain_root_dn')
                 ) {
                     $this->ldap->bind($conf['search_bind_dn'], $conf['search_bind_pw']);
                     $dc = $this->ldap->domain_root_dn($d);
                 }
 
                 $replaces = ['%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u];
 
                 // Search for the dn to use to authenticate
                 if (!empty($conf['search_base_dn']) && !empty($conf['search_filter'])
                     && (strstr($bind_dn, '%dn') || strstr($this->base_dn, '%dn') || strstr($this->groups_base_dn, '%dn'))
                 ) {
                     $search_attribs = ['uid'];
                     $search_bind_attrib = null;
 
                     if (!empty($conf['search_bind_attrib'])) {
                         $search_bind_attrib = (array) $conf['search_bind_attrib'];
                         foreach ($search_bind_attrib as $r => $attr) {
                             $search_attribs[] = $attr;
                             $replaces[$r] = '';
                         }
                     }
 
                     $search_bind_dn = strtr($conf['search_bind_dn'], $replaces);
                     $search_base_dn = strtr($conf['search_base_dn'], $replaces);
                     $search_filter  = strtr($conf['search_filter'], $replaces);
 
                     $cache_key = rcube_cache::key_name('DN', [$host, $search_bind_dn, $search_base_dn, $search_filter, $conf['search_bind_pw']]);
 
                     if ($this->cache && ($dn = $this->cache->get($cache_key))) {
                         $replaces['%dn'] = $dn;
                     }
                     else {
                         $ldap = $this->ldap;
                         if (!empty($search_bind_dn) && !empty($conf['search_bind_pw'])) {
                             // To protect from "Critical extension is unavailable" error
                             // we need to use a separate LDAP connection
                             if (!empty($conf['vlv'])) {
                                 $ldap = new rcube_ldap_generic($conf);
                                 $ldap->config_set(['cache' => $this->cache, 'debug' => $this->debug]);
                                 if (!$ldap->connect($host)) {
                                     continue;
                                 }
                             }
 
                             if (!$ldap->bind($search_bind_dn, $conf['search_bind_pw'])) {
                                 continue;  // bind failed, try next host
                             }
                         }
 
                         $res = $ldap->search($search_base_dn, $search_filter, 'sub', $search_attribs);
                         if ($res) {
                             $res->rewind();
                             $replaces['%dn'] = key($res->entries(true));
 
                             // add more replacements from 'search_bind_attrib' config
                             if (!empty($search_bind_attrib)) {
                                 $res = $res->current();
                                 foreach ($search_bind_attrib as $r => $attr) {
                                     $replaces[$r] = $res[$attr][0];
                                 }
                             }
                         }
 
                         if ($ldap != $this->ldap) {
                             $ldap->close();
                         }
                     }
 
                     // DN not found
                     if (empty($replaces['%dn'])) {
                         if (!empty($conf['search_dn_default'])) {
                             $replaces['%dn'] = $conf['search_dn_default'];
                         }
                         else {
                             rcube::raise_error([
                                     'code' => 100, 'type' => 'ldap',
                                     'file' => __FILE__, 'line' => __LINE__,
                                     'message' => "DN not found using LDAP search."
                                 ], true
                             );
                             continue;
                         }
                     }
 
                     if ($this->cache && !empty($replaces['%dn'])) {
                         $this->cache->set($cache_key, $replaces['%dn']);
                     }
                 }
 
                 // 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);
 
                 // replace placeholders in filter settings
                 if (!empty($conf['filter'])) {
                     $this->prop['filter'] = strtr($conf['filter'], $replaces);
                 }
 
                 foreach (['base_dn', 'filter', 'member_filter'] as $k) {
                     if (!empty($conf['groups'][$k])) {
                         $this->prop['groups'][$k] = strtr($conf['groups'][$k], $replaces);
                     }
                 }
 
                 if (!empty($conf['group_filters']) && is_array($conf['group_filters'])) {
                     foreach ($conf['group_filters'] as $i => $gf) {
                         if (!empty($gf['base_dn'])) {
                             $this->prop['group_filters'][$i]['base_dn'] = strtr($gf['base_dn'], $replaces);
                         }
                         if (!empty($gf['filter'])) {
                             $this->prop['group_filters'][$i]['filter'] = strtr($gf['filter'], $replaces);
                         }
                     }
                 }
 
                 if (empty($bind_user)) {
                     $bind_user = $u;
                 }
             }
 
             if (empty($bind_pass) && strcasecmp($auth_method, 'GSSAPI') != 0) {
                 $this->ready = true;
             }
             else {
                 if (!empty($conf['auth_cid'])) {
                     $this->ready = $this->ldap->sasl_bind($conf['auth_cid'], $bind_pass, $bind_dn);
                 }
                 else if (!empty($bind_dn)) {
                     $this->ready = $this->ldap->bind($bind_dn, $bind_pass);
                 }
                 else {
                     $this->ready = $this->ldap->sasl_bind($bind_user, $bind_pass);
                 }
             }
 
             // connection established, we're done here
             if ($this->ready) {
                 break;
             }
 
         }  // end foreach hosts
 
         if (!is_resource($this->ldap->conn)) {
             rcube::raise_error([
                     'code' => 100, 'type' => 'ldap',
                     'file' => __FILE__, 'line' => __LINE__,
                     'message' => "Could not connect to any LDAP server"
                 ], true
             );
 
             return false;
         }
 
         return $this->ready;
     }
 
     /**
      * Close connection to LDAP server
      */
     function close()
     {
         if ($this->ldap) {
             $this->ldap->close();
         }
     }
 
     /**
      * Returns address book name
      *
      * @return string Address book name
      */
     function get_name()
     {
         return $this->prop['name'];
     }
 
     /**
      * Set internal list page
      *
      * @param  number  Page number to list
      */
     function set_page($page)
     {
         $this->list_page = (int) $page;
         $this->ldap->set_vlv_page($this->list_page, $this->page_size);
     }
 
     /**
      * Set internal page size
      *
      * @param  number  Number of records to display on one page
      */
     function set_pagesize($size)
     {
         $this->page_size = (int) $size;
         $this->ldap->set_vlv_page($this->list_page, $this->page_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 (!empty($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 $cols   List of cols to show
      * @param int   $subset Only return this number of records
      * @param bool   $nocount True to skip the count query (Not used)
      *
      * @return array Indexed list of contact records, each a hash array
      */
     function list_records($cols = null, $subset = 0, $nocount = false)
     {
         if (!empty($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 && !empty($this->group_data['dn'])) {
             $entries = $this->list_group_members($this->group_data['dn']);
 
             // make list of entries unique and sort it
             $seen = [];
             foreach ($entries as $i => $rec) {
                 if (!empty($seen[$rec['dn']])) {
                     unset($entries[$i]);
                 }
                 $seen[$rec['dn']] = true;
             }
             usort($entries, [$this, '_entry_sort_cmp']);
 
             $entries['count'] = count($entries);
             $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
         }
         else {
             // exec LDAP search if no result resource is stored
             if ($this->ready && $this->ldap_result === null) {
                 $this->ldap_result = $this->extended_search();
             }
 
             // count contacts for this user
             $this->result = $this->count();
 
             $entries = $this->ldap_result;
         }  // end else
 
         // start and end of the page
         $start_row = $this->ldap->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++) {
+        for ($i = $start_row; $i < min($entries['count'] ?? null, $last_row); $i++) {
             if (!empty($entries[$i])) {
                 $this->result->add($this->_ldap2result($entries[$i]));
             }
         }
 
         return $this->result;
     }
 
     /**
      * Get all members of the given group
      *
      * @param string $dn      Group DN
      * @param bool   $count   Count only
      * @param array  $entries Group entries (if called recursively)
      *
      * @return array  Accumulated group members
      */
     function list_group_members($dn, $count = false, $entries = null)
     {
         $group_members = [];
 
         // fetch group object
         if (empty($entries)) {
             $attribs = array_merge(['dn', 'objectClass', 'memberURL'], array_values($this->group_types));
             $entries = $this->ldap->read_entries($dn, '(objectClass=*)', $attribs);
             if ($entries === false) {
                 return $group_members;
             }
         }
 
         for ($i=0; $i < $entries['count']; $i++) {
             $entry = $entries[$i];
             $attrs = [];
 
             foreach ((array) $entry['objectclass'] as $objectclass) {
                 if (($member_attr = $this->get_group_member_attr([$objectclass], ''))
                     && ($member_attr = strtolower($member_attr)) && !in_array($member_attr, $attrs)
                 ) {
                     $members       = $this->_list_group_members($dn, $entry, $member_attr, $count);
                     $group_members = array_merge($group_members, $members);
                     $attrs[]       = $member_attr;
                 }
                 else if (!empty($entry['memberurl'])) {
                     $members       = $this->_list_group_memberurl($dn, $entry, $count);
                     $group_members = array_merge($group_members, $members);
                 }
 
                 if (!empty($this->prop['sizelimit']) && count($group_members) > $this->prop['sizelimit']) {
                     break 2;
                 }
             }
         }
 
         return array_filter($group_members);
     }
 
     /**
      * Fetch members of the given group entry from server
      *
      * @param string $dn    Group DN
      * @param array  $entry Group entry
      * @param string $attr  Member attribute to use
      * @param bool   $count Count only
      *
      * @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 = [];
         if (empty($entry[$attr])) {
             return $group_members;
         }
 
         // read these attributes for all members
         $attrib = $count ? ['dn', 'objectClass'] : $this->prop['list_attributes'];
         $attrib = array_merge($attrib, array_values($this->group_types));
         $attrib[] = 'memberURL';
 
         $filter = !empty($this->prop['groups']['member_filter']) ? $this->prop['groups']['member_filter'] : '(objectclass=*)';
 
         for ($i=0; $i < $entry[$attr]['count']; $i++) {
             if (empty($entry[$attr][$i])) {
                 continue;
             }
 
             $members = $this->ldap->read_entries($entry[$attr][$i], $filter, $attrib);
             if ($members == false) {
                 $members = [];
             }
 
             // 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 $dn    Group DN
      * @param array  $entry Group entry
      * @param bool   $count True if only used for counting
      *
      * @return array Accumulated group members
      */
     private function _list_group_memberurl($dn, $entry, $count)
     {
         $group_members = [];
 
         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];
             $attrs  = $count ? ['dn', 'objectClass'] : $this->prop['list_attributes'];
 
             if ($result = $this->ldap->search($m[1], $filter, $m[2], $attrs, $this->group_data)) {
                 $entries = $result->entries();
                 for ($j = 0; $j < $entries['count']; $j++) {
                     if ($this->is_group_entry($entries[$j]) && ($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. Sum of rcube_addressbook::SEARCH_*
      * @param bool    $select   True if results are requested, False if count only
      * @param bool    $nocount  (Not used)
      * @param array   $required List of fields that cannot be empty
      *
      * @return rcube_result_set List of contact records
      */
     function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
     {
         $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;
         }
 
         $rcube = rcube::get_instance();
 
         $list_fields  = $rcube->config->get('contactlist_fields');
         $fuzzy_search = intval(!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT));
 
         // use VLV pseudo-search for autocompletion
         if (!empty($this->prop['vlv_search']) && $this->ready
             && implode(',', (array)$fields) == implode(',', $list_fields)
         ) {
             $this->result = new rcube_result_set(0);
 
             $this->ldap->config_set('fuzzy_search', $fuzzy_search);
 
             $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'],
                 ['search' => $value /*, 'sort' => $this->prop['sort'] */]);
 
             if ($ldap_data === false) {
                 return $this->result;
             }
 
             // get all entries of this page and post-filter those that really match the query
             $search = mb_strtolower($value);
             foreach ($ldap_data as $entry) {
                 $rec = $this->_ldap2result($entry);
                 foreach ($fields as $f) {
                     if (!empty($rec[$f])) {
                         foreach ((array)$rec[$f] as $val) {
                             if ($this->compare_search_value($f, $val, $search, $mode)) {
                                 $this->result->add($rec);
                                 $this->result->count++;
                                 break 2;
                             }
                         }
                     }
                 }
             }
 
             return $this->result;
         }
 
         // advanced per-attribute search
         if (is_array($value)) {
             // use AND operator for advanced searches
             $filter = '(&';
 
             // set wildcards
             $wp = $ws = '';
             if ($fuzzy_search) {
                 $ws = '*';
                 if (!($mode & rcube_addressbook::SEARCH_PREFIX)) {
                     $wp = '*';
                 }
             }
 
             foreach ((array) $fields as $idx => $field) {
                 $val = $value[$idx];
                 if (!strlen($val)) {
                     continue;
                 }
                 if ($attrs = $this->_map_field($field)) {
                     if (count($attrs) > 1) {
                         $filter .= '(|';
                     }
                     foreach ($attrs as $f) {
                         $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)";
                     }
                     if (count($attrs) > 1) {
                         $filter .= ')';
                     }
                 }
             }
 
             $filter .= ')';
         }
         else {
             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;
                 }
                 $attributes = (array) $this->prop['search_fields'];
             }
             else {
                 // map address book fields into ldap attributes
                 $attributes = [];
                 foreach ((array) $fields as $field) {
                     if (!empty($this->coltypes[$field]) && !empty($this->coltypes[$field]['attributes'])) {
                         $attributes = array_merge($attributes, (array) $this->coltypes[$field]['attributes']);
                     }
                 }
             }
 
             // compose a full-text-like search filter
             $filter = rcube_ldap_generic::fulltext_search_filter($value, $attributes, $mode & ~rcube_addressbook::SEARCH_GROUPS);
         }
 
         // add required (non empty) fields filter
         $req_filter = '';
         foreach ((array) $required as $field) {
             if (in_array($field, (array) $fields)) {
                 // required field is already in search filter
                 continue;
             }
             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);
 
         if ($mode & rcube_addressbook::SEARCH_GROUPS) {
             $filter = 'e:' . $filter;
         }
 
         // Reset the previous search result
         $this->reset();
 
         // set filter string and execute search
         $this->set_search_set($filter);
 
         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 (!empty($this->ldap_result)) {
             $count = $this->ldap_result['count'];
         }
         else if ($this->group_id && !empty($this->group_data['dn'])) {
             $count = count($this->list_group_members($this->group_data['dn'], true));
         }
         // We have a connection but no result set, attempt to get one.
         else if ($this->ready) {
             $count = $this->extended_search(true);
         }
 
         return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
     }
 
     /**
      * Wrapper on LDAP searches with group_filters support, which
      * allows searching for contacts AND groups.
      *
      * @param bool $count Return count instead of the records
      *
      * @return int|array Count of records or the result array (with 'count' item)
      */
     protected function extended_search($count = false)
     {
         $prop    = $this->group_id ? $this->group_data : $this->prop;
         $base_dn = $this->group_id ? $prop['base_dn'] : $this->base_dn;
         $attrs   = $count ? ['dn'] : $this->prop['attributes'];
 
         // Use global search filter
         if ($filter = $this->filter) {
             if ($filter[0] == 'e' && $filter[1] == ':') {
                 $filter = substr($filter, 2);
                 $is_extended_search = !$this->group_id;
             }
 
             $prop['filter'] = $filter;
 
             // add general filter to query
             if (!empty($this->prop['filter'])) {
                 $prop['filter'] = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $prop['filter'] . ')';
             }
         }
 
         $result = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], $attrs, $prop, $count);
         $result_count = 0;
 
         // we have a search result resource, get all entries
         if (!$count && $result) {
             $result_count = $result->count();
             $result       = $result->entries();
             unset($result['count']);
         }
 
         // search for groups
         if (!empty($is_extended_search)
             && !empty($this->prop['group_filters'])
             && is_array($this->prop['group_filters'])
             && !empty($this->prop['groups']['filter'])
         ) {
             $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['groups']['filter']) . ')' . $filter . ')';
 
             // for groups we may use cn instead of displayname...
             if ($this->prop['fieldmap']['name'] != $this->prop['groups']['name_attr']) {
                 $filter = str_replace(strtolower($this->prop['fieldmap']['name']) . '=', $this->prop['groups']['name_attr'] . '=', $filter);
             }
 
             $name_attr  = $this->prop['groups']['name_attr'];
             $email_attr = $this->prop['groups']['email_attr'] ?: 'mail';
             $attrs      = array_unique(['dn', 'objectClass', $name_attr, $email_attr]);
 
             $res = $this->ldap->search($this->groups_base_dn, $filter, $this->prop['groups']['scope'], $attrs, $prop, $count);
 
             if ($count && $res) {
                 $result += $res;
             }
             else if (!$count && $res && ($res_count = $res->count())) {
                 $res = $res->entries();
                 unset($res['count']);
                 $result = array_merge($result, $res);
                 $result_count += $res_count;
             }
         }
 
         if (!$count && $result) {
             // sorting
             if ($this->sort_col && $prop['scope'] !== 'base' && !$this->ldap->vlv_active) {
                 usort($result, [$this, '_entry_sort_cmp']);
             }
 
             $result['count'] = $result_count;
         }
 
         return $result;
     }
 
     /**
      * 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 $dn    Record identifier
      * @param bool  $assoc 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->ready && $dn) {
             $dn = self::dn_decode($dn);
 
             if ($rec = $this->ldap->get_entry($dn, $this->prop['attributes'])) {
                 $rec = array_change_key_case($rec, CASE_LOWER);
             }
 
             // Use ldap_list to get subentries like country (c) attribute (#1488123)
             if (!empty($rec) && $this->sub_filter) {
                 if ($entries = $this->ldap->list_entries($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(1);
                 $this->result->add($res);
             }
         }
 
         return $assoc ? $res : $this->result;
     }
 
     /**
      * Returns the last error occurred (e.g. when updating/inserting failed)
      *
      * @return array Hash array with the following fields: type, message
      */
     function get_error()
     {
         $err = $this->error;
 
         // check ldap connection for errors
         if (!$err && $this->ldap->get_error()) {
             $err = [self::ERROR_SEARCH, $this->ldap->get_error()];
         }
 
         return $err;
     }
 
     /**
      * Check the given data before saving.
      * If input not valid, the message to display can be fetched using get_error()
      *
      * @param array &$save_data Associative array with data to save
      * @param bool  $autofix    Try to fix/complete record automatically
      *
      * @return bool 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   = [];
         $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 (!empty($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
                 $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_first($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 $save_cols Associative 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 bool  $check True to check for duplicates first
      *
      * @return mixed The created record ID on success, False on error
      */
     function insert($save_cols, $check = false)
     {
         // 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 required 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'].'='.rcube_ldap_generic::quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
 
         // Remove attributes that need to be added separately (child objects)
         $xfields = [];
         if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
             foreach (array_keys($this->prop['sub_fields']) as $xf) {
                 if (!empty($newentry[$xf])) {
                     $xfields[$xf] = $newentry[$xf];
                     unset($newentry[$xf]);
                 }
             }
         }
 
         if (!$this->ldap->add_entry($dn, $newentry)) {
             $this->set_error(self::ERROR_SAVING, 'errorsaving');
             return false;
         }
 
         foreach ($xfields as $xidx => $xf) {
             $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn;
             $xf = [
                 $xidx => $xf,
                 'objectClass' => (array) $this->prop['sub_fields'][$xidx],
             ];
 
             $this->ldap->add_entry($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 $id        Record identifier
      * @param array $save_cols Hash array with save data
      *
      * @return bool True on success, False on error
      */
     function update($id, $save_cols)
     {
         $record      = $this->get_record($id, true);
         $newdata     = [];
         $replacedata = [];
         $deletedata  = [];
         $subdata     = [];
         $subdeldata  = [];
         $subnewdata  = [];
         $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 $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] = [];
                             //$deletedata[$fld] = $old_data[$fld];
                         }
                     }
                     else {
                         // The data was modified, save it out.
                         $replacedata[$fld] = $val;
                     }
                 }
             }
         }
 
         // 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;
             }
         }
 
         if (!empty($replacedata)) {
             // Handle RDN change
             if (!empty($replacedata[$this->prop['LDAP_rdn']])) {
                 $newdn = $this->prop['LDAP_rdn'] . '='
                     . rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
                     . ',' . $this->base_dn;
 
                 if ($dn != $newdn) {
                     $newrdn = $this->prop['LDAP_rdn'] . '='
                         . rcube_ldap_generic::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;
                 }
             }
         }
 
         // 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.'='.rcube_ldap_generic::quote_string($val).','.$dn;
                 if (!$this->ldap->delete_entry($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;
             }
         }
 
         // Handle RDN change
         if (!empty($newrdn) && !empty($newdn)) {
             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 (array_keys($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.'='.rcube_ldap_generic::quote_string($val).','.$dn;
                 $xf = [
                     $fld => $val,
                     'objectClass' => (array) $this->prop['sub_fields'][$fld],
                 ];
                 $this->ldap->add_entry($subdn, $xf);
             }
         }
 
         return isset($newdn) ? $newdn : true;
     }
 
     /**
      * Mark one or more contact records as deleted
      *
      * @param array $ids   Record identifiers
      * @param bool  $force Remove record(s) irreversible (unsupported)
      *
      * @return int|bool Number of deleted records 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);
         }
 
         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_entries($dn, $this->sub_filter)) {
                     foreach ($entries as $entry) {
                         if (!$this->ldap->delete_entry($entry['dn'])) {
                             $this->set_error(self::ERROR_SAVING, 'errorsaving');
                             return false;
                         }
                     }
                 }
             }
 
             // Delete the record.
             if (!$this->ldap->delete_entry($dn)) {
                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
                 return false;
             }
 
             // remove contact from all groups where he was a member
             if ($this->groups) {
                 $dn        = self::dn_encode($dn);
                 $group_ids = $this->get_record_groups($dn);
 
                 foreach (array_keys($group_ids) as $group_id) {
                     $this->remove_from_group($group_id, $dn);
                 }
             }
         }
 
         return count($ids);
     }
 
     /**
      * Remove all contact records
      *
      * @param bool $with_groups Delete also groups if enabled
      */
     function delete_all($with_groups = false)
     {
         // searching for contact entries
         $dn_list = $this->ldap->list_entries($this->base_dn, $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);
         }
 
         if ($with_groups && $this->groups && ($groups = $this->_fetch_groups()) && count($groups)) {
             foreach ($groups as $group) {
                 $this->ldap->delete_entry($group['dn']);
             }
 
             if ($this->cache) {
                 $this->cache->remove('groups');
             }
         }
     }
 
     /**
      * Generate missing attributes as configured
      *
      * @param array &$attrs LDAP record attributes
      */
     protected function add_autovalues(&$attrs)
     {
         if (empty($this->prop['autovalues'])) {
             return;
         }
 
         $attrvals = [];
         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])) {
                 if (strpos($templ, '(') !== false) {
                     // replace {attr} placeholders with (escaped!) attribute values to be safely eval'd
                     $code = preg_replace('/\{\w+\}/', '', strtr($templ, array_map('addslashes', $attrvals)));
                     $res  = false;
 
                     try {
                         $res = eval("return ($code);");
                     }
                     catch (ParseError $e) {
                         // ignore
                     }
 
                     if ($res === false) {
                         rcube::raise_error([
                                 'code' => 505, 'file' => __FILE__, 'line' => __LINE__,
                                 'message' => "Expression parse error on: ($code)"
                             ], true, false);
                         continue;
                     }
 
                     $attrs[$lf] = $res;
                 }
                 else {
                     // replace {attr} placeholders with concrete attribute values
                     $attrs[$lf] = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals));
                 }
             }
         }
     }
 
     /**
      * Converts LDAP entry into an array
      */
     private function _ldap2result($rec)
     {
         $out      = ['_type' => 'person'];
         $fieldmap = $this->fieldmap;
 
         if (!empty($rec['dn'])) {
             $out[$this->primary_key] = self::dn_encode($rec['dn']);
         }
 
         // determine record type
         if ($this->is_group_entry($rec)) {
             $out['_type']     = 'group';
             $out['readonly']  = true;
             $fieldmap['name'] = $this->group_data['name_attr'] ?: $this->prop['groups']['name_attr'];
         }
 
         // assign object type from object class mapping
         if (!empty($this->prop['class_type_map'])) {
             foreach (array_map('strtolower', (array)$rec['objectclass']) as $objcls) {
                 if (!empty($this->prop['class_type_map'][$objcls])) {
                     $out['_type'] = $this->prop['class_type_map'][$objcls];
                     break;
                 }
             }
         }
 
         foreach ($fieldmap as $rf => $lf) {
             // we might be dealing with normalized and non-normalized data
-            $entry = $rec[$lf];
+            $entry = $rec[$lf] ?? null;
             if (!is_array($entry) || !isset($entry['count'])) {
                 $entry = (array) $entry;
                 $entry['count'] = count($entry);
             }
 
             for ($i=0; $i < $entry['count']; $i++) {
                 if (!($value = $entry[$i])) {
                     continue;
                 }
 
-                list($col, $subtype) = explode(':', $rf);
+                list($col, $subtype) = array_pad(explode(':', $rf), 2, null);
                 $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, ['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 ($entry['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, ['name', 'surname', 'firstname', 'middlename', 'nickname'])) {
+            if (is_array($out[$rf] ?? null) && in_array($rf, ['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)
     {
         if (isset($this->coltypes[$field]['attributes'])) {
             return (array) $this->coltypes[$field]['attributes'];
         }
 
         return [];
     }
 
     /**
      * 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 (!empty($colprop['childs']) && is_array($colprop['childs'])) {
                 foreach ($this->get_col_values($col, $save_cols, false) 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 (!empty($colprop['serialized']) && is_array($colprop['serialized'])) {
                foreach ($colprop['serialized'] as $subtype => $delim) {
                   $key = $col.':'.$subtype;
                   foreach ((array)$save_cols[$key] as $i => $val) {
                      $values = [$val['street'], $val['locality'], $val['zipcode'], $val['country']];
                      $save_cols[$key][$i] = count(array_filter($values)) ? implode($delim, $values) : null;
                  }
                }
             }
         }
 
         $ldap_data = [];
         foreach ($this->fieldmap as $rf => $fld) {
             $val = $save_cols[$rf];
 
             // check for value in base field (e.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]);  // use this value only 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;
             }
         }
 
         foreach ($this->formats as $fld => $format) {
             if (empty($ldap_data[$fld])) {
                 continue;
             }
 
             switch ($format['type']) {
             case 'date':
                 if ($dt = rcube_utils::anytodatetime($ldap_data[$fld])) {
                     $ldap_data[$fld] = $dt->format($format['format']);
                 }
                 break;
             }
         }
 
         return $ldap_data;
     }
 
     /**
      * Returns unified attribute name (resolving aliases)
      */
     private static function _attr_name($namev)
     {
         // list of known attribute aliases
         static $aliases = [
             'gn'            => 'givenname',
             'rfc822mailbox' => 'email',
             'userid'        => 'uid',
             'emailaddress'  => 'email',
             'pkcs9email'    => 'email',
         ];
 
-        list($name, $limit) = explode(':', $namev, 2);
+        list($name, $limit) = array_pad(explode(':', $namev, 2), 2, null);
         $suffix = $limit ? ':'.$limit : '';
         $name   = strtolower($name);
 
         return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix;
     }
 
     /**
      * Determines whether the given LDAP entry is a group record
      */
     private function is_group_entry($entry)
     {
         if (empty($entry['objectclass'])) {
             return false;
         }
 
         $classes = array_map('strtolower', (array)$entry['objectclass']);
 
         return count(array_intersect(array_keys($this->group_types), $classes)) > 0;
     }
 
     /**
      * Activate/deactivate debug mode
      *
      * @param bool $dbg True if LDAP commands should be logged
      */
     function set_debug($dbg = true)
     {
         $this->debug = $dbg;
 
         if ($this->ldap) {
             $this->ldap->config_set('debug', $dbg);
         }
     }
 
     /**
      * Setter for the current group
      *
      * @param mixed $group_id Group identifier
      */
     function set_group($group_id)
     {
         if ($group_id) {
             $this->group_id = $group_id;
             $this->group_data = $this->get_group_entry($group_id);
         }
         else {
             $this->group_id = 0;
             $this->group_data = null;
         }
     }
 
     /**
      * List all active contact groups of this source
      *
      * @param string $search Optional search string to match group name
      * @param int    $mode   Matching mode. Sum of rcube_addressbook::SEARCH_*
      *
      * @return array Indexed list of contact groups, each a hash array
      */
     function list_groups($search = null, $mode = 0)
     {
         if (!$this->groups) {
             return [];
         }
 
         $group_cache = $this->_fetch_groups($search, $mode);
         $groups      = [];
 
         if ($search) {
             foreach ($group_cache as $group) {
                 if ($this->compare_search_value('name', $group['name'], mb_strtolower($search), $mode)) {
                     $groups[] = $group;
                 }
             }
         }
         else {
             $groups = $group_cache;
         }
 
         return array_values($groups);
     }
 
     /**
      * Fetch groups from server
      */
     private function _fetch_groups($search = null, $mode = 0, $vlv_page = null)
     {
         // reset group search cache
         if ($search !== null && $vlv_page === null) {
             $this->group_search_cache = null;
         }
         // return in-memory cache from previous search results
         else if (is_array($this->group_search_cache) && $vlv_page === null) {
             return $this->group_search_cache;
         }
 
         // special case: list groups from 'group_filters' config
         if ($vlv_page === null && $search === null && !empty($this->prop['group_filters'])) {
             $groups = [];
             $rcube  = rcube::get_instance();
 
             // list regular groups configuration as special filter
             if (!empty($this->prop['groups']['filter'])) {
                 $id = '__groups__';
                 $groups[$id] = ['ID' => $id, 'name' => $rcube->gettext('groups'), 'virtual' => true] + $this->prop['groups'];
             }
 
             foreach ($this->prop['group_filters'] as $id => $prop) {
                 $groups[$id] = $prop + ['ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn];
             }
 
             return $groups;
         }
 
         if ($this->cache && $search === null && $vlv_page === null && ($groups = $this->cache->get('groups')) !== null) {
             return $groups;
         }
 
         $base_dn    = $this->groups_base_dn;
         $filter     = $this->prop['groups']['filter'];
         $scope      = $this->prop['groups']['scope'];
         $name_attr  = $this->prop['groups']['name_attr'];
-        $email_attr = $this->prop['groups']['email_attr'] ?: 'mail';
-        $sort_attrs = (array) ($this->prop['groups']['sort'] ? $this->prop['groups']['sort'] : $name_attr);
+        $email_attr = $this->prop['groups']['email_attr'] ?? 'mail';
+        $sort_attrs = (array) (($this->prop['groups']['sort'] ?? false) ? $this->prop['groups']['sort'] : $name_attr);
         $sort_attr  = $sort_attrs[0];
         $page_size  = 200;
 
         $ldap = $this->ldap;
 
         // use vlv to list groups
         if (!empty($this->prop['groups']['vlv'])) {
             if (empty($this->prop['groups']['sort'])) {
                 $this->prop['groups']['sort'] = $sort_attrs;
             }
 
             $ldap = clone $this->ldap;
             $ldap->config_set($this->prop['groups']);
             $ldap->set_vlv_page($vlv_page+1, $page_size);
         }
 
-        $props = ['sort' => $this->prop['groups']['sort']];
+        $props = ['sort' => $this->prop['groups']['sort'] ?? null];
         $attrs = array_unique(['dn', 'objectClass', $name_attr, $email_attr, $sort_attr]);
 
         // add search filter
         if ($search !== null) {
             // set wildcards
             $wp = $ws = '';
             if (!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT)) {
                 $ws = '*';
                 if (!($mode & rcube_addressbook::SEARCH_PREFIX)) {
                     $wp = '*';
                 }
             }
             $filter = "(&$filter($name_attr=$wp" . rcube_ldap_generic::quote_string($search) . "$ws))";
             $props['search'] = $wp . $search . $ws;
         }
 
         $ldap_data = $ldap->search($base_dn, $filter, $scope, $attrs, $props);
 
         if ($ldap_data === false) {
             return [];
         }
 
         $groups          = [];
         $group_sortnames = [];
         $group_count     = $ldap_data->count();
 
         foreach ($ldap_data as $entry) {
             // DN is mandatory
             if (empty($entry['dn'])) {
                 $entry['dn'] = $ldap_data->get_dn();
             }
 
             $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
             $group_id   = self::dn_encode($entry['dn']);
             $classes    = !empty($entry['objectclass']) ? $entry['objectclass'] : [];
 
             $groups[$group_id]['ID'] = $group_id;
             $groups[$group_id]['dn'] = $entry['dn'];
             $groups[$group_id]['name'] = $group_name;
             $groups[$group_id]['member_attr'] = $this->get_group_member_attr($classes);
 
             // list email attributes of a group
             for ($j=0; $entry[$email_attr] && $j < $entry[$email_attr]['count']; $j++) {
                 if (strpos($entry[$email_attr][$j], '@') > 0)
                     $groups[$group_id]['email'][] = $entry[$email_attr][$j];
             }
 
             $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]);
         }
 
         // recursive call can exit here
         if ($vlv_page > 0) {
             return $groups;
         }
 
         // call recursively until we have fetched all groups
         if (!empty($this->prop['groups']['vlv'])) {
             while ($group_count == $page_size) {
                 $next_page   = $this->_fetch_groups($search, $mode, ++$vlv_page);
                 $groups      = array_merge($groups, $next_page);
                 $group_count = count($next_page);
             }
         }
         // when using VLV the list of groups is already sorted
         else {
             array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
         }
 
         // cache this
         if ($this->cache && $search === null) {
             $this->cache->set('groups', $groups);
         }
         else if ($search !== null) {
             $this->group_search_cache = $groups;
         }
 
         return $groups;
     }
 
     /**
      * Fetch a group entry from LDAP and save in local cache
      */
     private function get_group_entry($group_id)
     {
         $group_cache = $this->_fetch_groups();
 
         // add group record to cache if it isn't yet there
         if (!isset($group_cache[$group_id])) {
             $name_attr = $this->prop['groups']['name_attr'];
             $dn    = self::dn_decode($group_id);
             $attrs = ['dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']];
 
             if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', $attrs)) {
                 $entry      = $list[0];
                 $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
                 $classes    = !empty($entry['objectclass']) ? $entry['objectclass'] : [];
 
                 $group_cache[$group_id]['ID'] = $group_id;
                 $group_cache[$group_id]['dn'] = $dn;
                 $group_cache[$group_id]['name'] = $group_name;
                 $group_cache[$group_id]['member_attr'] = $this->get_group_member_attr($classes);
             }
             else {
                 $group_cache[$group_id] = false;
             }
 
             if ($this->cache) {
                 $this->cache->set('groups', $group_cache);
             }
         }
 
         return $group_cache[$group_id];
     }
 
     /**
      * Get group properties such as name and email address(es)
      *
      * @param string $group_id Group identifier
      *
      * @return array Group properties as hash array
      */
     function get_group($group_id)
     {
         $group_data = $this->get_group_entry($group_id);
         unset($group_data['dn'], $group_data['member_attr']);
 
         return $group_data;
     }
 
     /**
      * Create a contact group with the given name
      *
      * @param string $group_name The group name
      *
      * @return mixed False on error, array with record props in success
      */
     function create_group($group_name)
     {
         $new_dn      = 'cn=' . rcube_ldap_generic::quote_string($group_name, true) . ',' . $this->groups_base_dn;
         $new_gid     = self::dn_encode($new_dn);
         $member_attr = $this->get_group_member_attr();
         $name_attr   = $this->prop['groups']['name_attr'] ?: 'cn';
         $new_entry   = [
             'objectClass' => $this->prop['groups']['object_classes'],
             $name_attr    => $group_name,
             $member_attr  => '',
         ];
 
         if (!$this->ldap->add_entry($new_dn, $new_entry)) {
             $this->set_error(self::ERROR_SAVING, 'errorsaving');
             return false;
         }
 
         if ($this->cache) {
             $this->cache->remove('groups');
         }
 
         return ['id' => $new_gid, 'name' => $group_name];
     }
 
     /**
      * Delete the given group and all linked group members
      *
      * @param string $group_id Group identifier
      *
      * @return bool True on success, false if no data was changed
      */
     function delete_group($group_id)
     {
         $group_cache = $this->_fetch_groups();
         $del_dn      = $group_cache[$group_id]['dn'];
 
         if (!$this->ldap->delete_entry($del_dn)) {
             $this->set_error(self::ERROR_SAVING, 'errorsaving');
             return false;
         }
 
         if ($this->cache) {
             unset($group_cache[$group_id]);
             $this->cache->set('groups', $group_cache);
         }
 
         return true;
     }
 
     /**
      * Rename a specific contact group
      *
      * @param string $group_id Group identifier
      * @param string $new_name New name to set for this group
      * @param string &$new_gid New group identifier (if changed, otherwise don't set)
      *
      * @return bool New name on success, false if no data was changed
      */
     function rename_group($group_id, $new_name, &$new_gid)
     {
         $group_cache = $this->_fetch_groups();
         $old_dn      = $group_cache[$group_id]['dn'];
         $new_rdn     = "cn=" . rcube_ldap_generic::quote_string($new_name, true);
         $new_gid     = self::dn_encode($new_rdn . ',' . $this->groups_base_dn);
 
         if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) {
             $this->set_error(self::ERROR_SAVING, 'errorsaving');
             return false;
         }
 
         if ($this->cache) {
             $this->cache->remove('groups');
         }
 
         return $new_name;
     }
 
     /**
      * Add the given contact records the a certain group
      *
      * @param string       $group_id    Group identifier
      * @param array|string $contact_ids List of contact identifiers to be added
      *
      * @return int Number of contacts added
      */
     function add_to_group($group_id, $contact_ids)
     {
         $group_cache = $this->_fetch_groups();
         $member_attr = $group_cache[$group_id]['member_attr'];
         $group_dn    = $group_cache[$group_id]['dn'];
         $new_attrs   = [];
 
         if (!is_array($contact_ids)) {
             $contact_ids = explode(',', $contact_ids);
         }
 
         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;
         }
 
         if ($this->cache) {
             $this->cache->remove('groups');
         }
 
         return count($new_attrs[$member_attr]);
     }
 
     /**
      * Remove the given contact records from a certain group
      *
      * @param string       $group_id    Group identifier
      * @param array|string $contact_ids List of contact identifiers to be removed
      *
      * @return int Number of deleted group members
      */
     function remove_from_group($group_id, $contact_ids)
     {
         $group_cache = $this->_fetch_groups();
         $member_attr = $group_cache[$group_id]['member_attr'];
         $group_dn    = $group_cache[$group_id]['dn'];
         $del_attrs   = [];
 
         if (!is_array($contact_ids)) {
             $contact_ids = explode(',', $contact_ids);
         }
 
         foreach ($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;
         }
 
         if ($this->cache) {
             $this->cache->remove('groups');
         }
 
         return count($del_attrs[$member_attr]);
     }
 
     /**
      * Get group assignments of a specific contact record
      *
      * @param mixed $contact_id 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 [];
         }
 
         $base_dn     = $this->groups_base_dn;
         $contact_dn  = self::dn_decode($contact_id);
         $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)", ["\\" => "\\\\"]);
 
         $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', ['dn', $name_attr]);
 
         if ($ldap_data === false) {
             return [];
         }
 
         $groups = [];
         foreach ($ldap_data as $entry) {
             if (empty($entry['dn'])) {
                 $entry['dn'] = $ldap_data->get_dn();
             }
 
             $group_name = $entry[$name_attr][0];
             $group_id   = self::dn_encode($entry['dn']);
             $groups[$group_id] = $group_name;
         }
 
         return $groups;
     }
 
     /**
      * Detects group member attribute name
      */
     private function get_group_member_attr($object_classes = [], $default = 'member')
     {
         if (empty($object_classes)) {
             $object_classes = $this->prop['groups']['object_classes'];
         }
 
         if (!empty($object_classes)) {
             foreach ((array) $object_classes as $oc) {
                 if (!empty($this->group_types[strtolower($oc)])) {
                     return $this->group_types[strtolower($oc)];
                 }
             }
         }
 
         if (!empty($this->prop['groups']['member_attr'])) {
             return $this->prop['groups']['member_attr'];
         }
 
         return $default;
     }
 
     /**
      * 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);
     }
 }
diff --git a/program/lib/Roundcube/rcube_ldap_generic.php b/program/lib/Roundcube/rcube_ldap_generic.php
index f696566f5..cfac2e9a2 100644
--- a/program/lib/Roundcube/rcube_ldap_generic.php
+++ b/program/lib/Roundcube/rcube_ldap_generic.php
@@ -1,356 +1,356 @@
 <?php
 
 /**
  +-----------------------------------------------------------------------+
  | This file is part of the Roundcube Webmail client                     |
  |                                                                       |
  | Copyright (C) The Roundcube Dev Team                                  |
  | Copyright (C) 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 basic functionality for accessing LDAP directories          |
  |                                                                       |
  +-----------------------------------------------------------------------+
  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
  |         Aleksander Machniak <machniak@kolabsys.com>                   |
  +-----------------------------------------------------------------------+
 */
 
 /**
  * Model class to access an LDAP directories
  *
  * @package    Framework
  * @subpackage LDAP
  */
 class rcube_ldap_generic extends Net_LDAP3
 {
     /** private properties */
     protected $cache = null;
     protected $attributes = ['dn'];
     protected $error;
 
     /**
      * Class constructor
      *
      * @param array $config Configuration
      */
     function __construct($config = null)
     {
         parent::__construct($config);
 
         $this->config_set('log_hook', [$this, 'log']);
     }
 
     /**
      * Establish a connection to the LDAP server
      */
     public function connect($host = null)
     {
         // Net_LDAP3 does not support IDNA yet
         // also parse_host() here is very Roundcube specific
-        $host = rcube_utils::parse_host($host, $this->config['mail_domain']);
+        $host = rcube_utils::parse_host($host, $this->config['mail_domain'] ?? null);
         $host = rcube_utils::idn_to_ascii($host);
 
         return parent::connect($host);
     }
 
     /**
      * Prints debug/error info to the log
      */
     public function log($level, $msg)
     {
         $msg = implode("\n", $msg);
 
         switch ($level) {
         case LOG_DEBUG:
         case LOG_INFO:
         case LOG_NOTICE:
             if (!empty($this->config['debug'])) {
                 rcube::write_log('ldap', $msg);
             }
             break;
 
         case LOG_EMERG:
         case LOG_ALERT:
         case LOG_CRIT:
             rcube::raise_error($msg, true, true);
             break;
 
         case LOG_ERR:
         case LOG_WARNING:
             $this->error = $msg;
             rcube::raise_error($msg, true, false);
             break;
         }
     }
 
     /**
      * Returns the last LDAP error occurred
      *
      * @return mixed Error message string or null if no error occurred
      */
     function get_error()
     {
         return $this->error;
     }
 
     /**
      * @deprecated
      */
     public function set_debug($dbg = true)
     {
         $this->config['debug'] = (bool) $dbg;
     }
 
     /**
      * @deprecated
      */
     public function set_cache($cache_engine)
     {
         $this->config['cache'] = $cache_engine;
     }
 
     /**
      * @deprecated
      */
     public static function scope2func($scope, &$ns_function = null)
     {
         return self::scope_to_function($scope, $ns_function);
     }
 
     /**
      * @deprecated
      */
     public function set_config($opt, $val = null)
     {
         $this->config_set($opt, $val);
     }
 
     /**
      * @deprecated
      */
     public function add($dn, $entry)
     {
         return $this->add_entry($dn, $entry);
     }
 
     /**
      * @deprecated
      */
     public function delete($dn)
     {
         return $this->delete_entry($dn);
     }
 
     /**
      * Wrapper for ldap_mod_replace()
      *
      * @see ldap_mod_replace()
      */
     public function mod_replace($dn, $entry)
     {
         $this->_debug("C: Replace $dn: ".print_r($entry, true));
 
         if (!ldap_mod_replace($this->conn, $dn, $entry)) {
             $this->_error("ldap_mod_replace() failed with " . ldap_error($this->conn));
             return false;
         }
 
         $this->_debug("S: OK");
         return true;
     }
 
     /**
      * Wrapper for ldap_mod_add()
      *
      * @see ldap_mod_add()
      */
     public function mod_add($dn, $entry)
     {
         $this->_debug("C: Add $dn: ".print_r($entry, true));
 
         if (!ldap_mod_add($this->conn, $dn, $entry)) {
             $this->_error("ldap_mod_add() failed with " . ldap_error($this->conn));
             return false;
         }
 
         $this->_debug("S: OK");
         return true;
     }
 
     /**
      * Wrapper for ldap_mod_del()
      *
      * @see ldap_mod_del()
      */
     public function mod_del($dn, $entry)
     {
         $this->_debug("C: Delete $dn: ".print_r($entry, true));
 
         if (!ldap_mod_del($this->conn, $dn, $entry)) {
             $this->_error("ldap_mod_del() failed with " . ldap_error($this->conn));
             return false;
         }
 
         $this->_debug("S: OK");
         return true;
     }
 
     /**
      * Wrapper for ldap_rename()
      *
      * @see ldap_rename()
      */
     public function rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true)
     {
         $this->_debug("C: Rename $dn to $newrdn");
 
         if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) {
             $this->_error("ldap_rename() failed with " . ldap_error($this->conn));
             return false;
         }
 
         $this->_debug("S: OK");
         return true;
     }
 
     /**
      * Wrapper for ldap_list() + ldap_get_entries()
      *
      * @see ldap_list()
      * @see ldap_get_entries()
      */
     public function list_entries($dn, $filter, $attributes = ['dn'])
     {
         $this->_debug("C: List $dn [{$filter}]");
 
         if ($result = ldap_list($this->conn, $dn, $filter, $attributes)) {
             $list = ldap_get_entries($this->conn, $result);
 
             if ($list === false) {
                 $this->_error("ldap_get_entries() failed with " . ldap_error($this->conn));
                 return [];
             }
 
             $count = $list['count'];
             unset($list['count']);
 
             $this->_debug("S: $count record(s)");
         }
         else {
             $list = [];
             $this->_error("ldap_list() failed with " . ldap_error($this->conn));
         }
 
         return $list;
     }
 
     /**
      * Wrapper for ldap_read() + ldap_get_entries()
      *
      * @see ldap_read()
      * @see ldap_get_entries()
      */
     public function read_entries($dn, $filter, $attributes = null)
     {
         $this->_debug("C: Read $dn [{$filter}]");
 
         if ($this->conn && $dn) {
             $result = @ldap_read($this->conn, $dn, $filter, $attributes, 0, (int)$this->config['sizelimit'], (int)$this->config['timelimit']);
             if ($result === false) {
                 $this->_error("ldap_read() failed with " . ldap_error($this->conn));
                 return false;
             }
 
             $this->_debug("S: OK");
             return ldap_get_entries($this->conn, $result);
         }
 
         return false;
     }
 
     /**
      * Turn an LDAP entry into a regular PHP array with attributes as keys.
      *
      * @param array $entry Attributes array as retrieved from ldap_get_attributes() or ldap_get_entries()
      * @param bool  $flat  Convert one-element-array values into strings (not implemented)
      *
      * @return array Hash array with attributes as keys
      */
     public static function normalize_entry($entry, $flat = false)
     {
         if (!isset($entry['count'])) {
             return $entry;
         }
 
         $rec = [];
 
         for ($i=0; $i < $entry['count']; $i++) {
             $attr = $entry[$i];
             if ($entry[$attr]['count'] == 1) {
                 switch ($attr) {
                     case 'objectclass':
                         $rec[$attr] = [strtolower($entry[$attr][0])];
                         break;
                     default:
                         $rec[$attr] = $entry[$attr][0];
                         break;
                 }
             }
             else {
                 for ($j=0; $j < $entry[$attr]['count']; $j++) {
                     $rec[$attr][$j] = $entry[$attr][$j];
                 }
             }
         }
 
         return $rec;
     }
 
     /**
      * Compose an LDAP filter string matching all words from the search string
      * in the given list of attributes.
      *
      * @param string $value Search value
      * @param mixed  $attrs List of LDAP attributes to search
      * @param int    $mode  Matching mode:
      *                      0 - partial (*abc*),
      *                      1 - strict (=),
      *                      2 - prefix (abc*)
      * @return string LDAP filter
      */
     public static function fulltext_search_filter($value, $attributes, $mode = 1)
     {
         if (empty($attributes)) {
             $attributes = ['cn'];
         }
 
         $groups = [];
         $value  = str_replace('*', '', $value);
         $words  = $mode == 0 ? rcube_utils::tokenize_string($value, 1) : [$value];
 
         // set wildcards
         $wp = $ws = '';
         if ($mode != 1) {
             $ws = '*';
             $wp = !$mode ? '*' : '';
         }
 
         // search each word in all listed attributes
         foreach ($words as $word) {
             $parts = [];
 
             foreach ($attributes as $attr) {
                 $parts[] = "($attr=$wp" . self::quote_string($word) . "$ws)";
             }
 
             $groups[] = '(|' . implode('', $parts) . ')';
         }
 
         return count($groups) > 1 ? '(&' . implode('', $groups) . ')' : implode('', $groups);
     }
 }