Thomas
2014-01-15 5b3e568036be1d349ab47bf896d81285c8e02a5e
Merge branch 'release-0.9-ldap-groups' into release-0.9-kolab-ucs
4 files added
35 files modified
3977 ■■■■ changed files
config/main.inc.php.dist 66 ●●●● patch | view | raw | blame | history
plugins/managesieve/config.inc.php.dist 4 ●●●● patch | view | raw | blame | history
plugins/managesieve/managesieve.js 2 ●●● patch | view | raw | blame | history
plugins/managesieve/managesieve.php 56 ●●●● patch | view | raw | blame | history
program/include/rcmail.php 16 ●●●● patch | view | raw | blame | history
program/js/app.js 344 ●●●● patch | view | raw | blame | history
program/js/treelist.js 575 ●●●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_config.php 2 ●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_ldap.php 1156 ●●●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_ldap_generic.php 1059 ●●●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_ldap_result.php 130 ●●●●● patch | view | raw | blame | history
program/localization/en_US/labels.inc 1 ●●●● patch | view | raw | blame | history
program/steps/addressbook/copy.inc 4 ●●●● patch | view | raw | blame | history
program/steps/addressbook/func.inc 94 ●●●● patch | view | raw | blame | history
program/steps/addressbook/list.inc 5 ●●●●● patch | view | raw | blame | history
program/steps/addressbook/save.inc 4 ●●●● patch | view | raw | blame | history
program/steps/addressbook/show.inc 5 ●●●● patch | view | raw | blame | history
program/steps/mail/list_contacts.inc 29 ●●●● patch | view | raw | blame | history
skins/classic/addressbook.css 70 ●●●● patch | view | raw | blame | history
skins/classic/common.css 27 ●●●●● patch | view | raw | blame | history
skins/classic/mail.css 34 ●●●● patch | view | raw | blame | history
skins/classic/templates/addressbook.html 5 ●●●●● patch | view | raw | blame | history
skins/classic/templates/contact.html 2 ●●● patch | view | raw | blame | history
skins/classic/templates/mail.html 2 ●●● patch | view | raw | blame | history
skins/classic/templates/message.html 2 ●●● patch | view | raw | blame | history
skins/classic/templates/messageerror.html 2 ●●● patch | view | raw | blame | history
skins/larry/addressbook.css 70 ●●●●● patch | view | raw | blame | history
skins/larry/ie7hacks.css 1 ●●●● patch | view | raw | blame | history
skins/larry/images/contactgroup.png patch | view | raw | blame | history
skins/larry/images/listicons.png patch | view | raw | blame | history
skins/larry/includes/footer.html 12 ●●●●● patch | view | raw | blame | history
skins/larry/mail.css 113 ●●●●● patch | view | raw | blame | history
skins/larry/styles.css 34 ●●●●● patch | view | raw | blame | history
skins/larry/templates/addressbook.html 4 ●●●● patch | view | raw | blame | history
skins/larry/templates/contact.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/mail.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/message.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/messageerror.html 2 ●●● patch | view | raw | blame | history
skins/larry/ui.js 39 ●●●● patch | view | raw | blame | history
config/main.inc.php.dist
@@ -601,6 +601,8 @@
  // DN and password to bind as before searching for bind DN, if anonymous search is not allowed
  'search_bind_dn' => '',
  'search_bind_pw' => '',
  // Optional map of replacement strings => attributes used when binding for an individual address book
  'search_bind_attrib' => array(),  // e.g. array('%udc' => 'ou')
  // Default for %dn variable if search doesn't return DN value
  'search_dn_default' => '',
  // Optional authentication identifier to be used as SASL authorization proxy
@@ -642,6 +644,7 @@
    'phone:work'  => 'telephoneNumber',
    'phone:mobile' => 'mobile',
    'phone:pager' => 'pager',
    'phone:workfax' => 'facsimileTelephoneNumber',
    'street'      => 'street',
    'zipcode'     => 'postalCode',
    'region'      => 'st',
@@ -652,9 +655,8 @@
    'department'   => 'ou',
    'jobtitle'     => 'title',
    'notes'        => 'description',
    'photo'        => 'jpegPhoto',
    // these currently don't work:
    // 'phone:workfax' => 'facsimileTelephoneNumber',
    // 'photo'         => 'jpegPhoto',
    // 'manager'       => 'manager',
    // 'assistant'     => 'secretary',
  ),
@@ -665,27 +667,55 @@
  // 'uid'  => 'md5(microtime())',               // You may specify PHP code snippets which are then eval'ed 
  // 'mail' => '{givenname}.{sn}@mydomain.com',  // or composite strings with placeholders for existing attributes
  ),
  'sort'          => 'cn',    // The field to sort the listing by.
  'scope'         => 'sub',   // search mode: sub|base|list
  'filter'        => '(objectClass=inetOrgPerson)',      // used for basic listing (if not empty) and will be &'d with search queries. example: status=act
  'fuzzy_search'  => true,    // server allows wildcard search
  'vlv'           => false,   // Enable Virtual List View to more efficiently fetch paginated data (if server supports it)
  'numsub_filter' => '(objectClass=organizationalUnit)',   // with VLV, we also use numSubOrdinates to query the total number of records. Set this filter to get all numSubOrdinates attributes for counting
  'sizelimit'     => '0',     // Enables you to limit the count of entries fetched. Setting this to 0 means no limit.
  'timelimit'     => '0',     // Sets the number of seconds how long is spend on the search. Setting this to 0 means no limit.
  'referrals'     => true|false,  // Sets the LDAP_OPT_REFERRALS option. Mostly used in multi-domain Active Directory setups
  'sort'           => 'cn',         // The field to sort the listing by.
  'scope'          => 'sub',        // search mode: sub|base|list
  'filter'         => '(objectClass=inetOrgPerson)',      // used for basic listing (if not empty) and will be &'d with search queries. example: status=act
  'fuzzy_search'   => true,         // server allows wildcard search
  'vlv'            => false,        // Enable Virtual List View to more efficiently fetch paginated data (if server supports it)
  'vlv_search'     => false,        // Use Virtual List View functions for autocompletion searches (if server supports it)
  'numsub_filter'  => '(objectClass=organizationalUnit)',   // with VLV, we also use numSubOrdinates to query the total number of records. Set this filter to get all numSubOrdinates attributes for counting
  'config_root_dn' => 'cn=config',  // Root DN to search config entries (e.g. vlv indexes)
  'sizelimit'      => '0',          // Enables you to limit the count of entries fetched. Setting this to 0 means no limit.
  'timelimit'      => '0',          // Sets the number of seconds how long is spend on the search. Setting this to 0 means no limit.
  'referrals'      => false,        // Sets the LDAP_OPT_REFERRALS option. Mostly used in multi-domain Active Directory setups
  // definition for contact groups (uncomment if no groups are supported)
  // for the groups base_dn, the user replacements %fu, %u, $d and %dc work as for base_dn (see above)
  // if the groups base_dn is empty, the contact base_dn is used for the groups as well
  // -> in this case, assure that groups and contacts are separated due to the concernig filters! 
  'groups'        => array(
    'base_dn'     => '',
    'scope'       => 'sub',   // search mode: sub|base|list
    'filter'      => '(objectClass=groupOfNames)',
    'object_classes' => array("top", "groupOfNames"),
    'member_attr'  => 'member',   // name of the member attribute, e.g. uniqueMember
    'name_attr'    => 'cn',       // attribute to be used as group name
  'groups'  => array(
    'base_dn'           => '',
    'scope'             => 'sub',       // Search mode: sub|base|list
    'filter'            => '(objectClass=groupOfNames)',
    'object_classes'    => array('top', 'groupOfNames'),   // Object classes to be assigned to new groups
    'member_attr'       => 'member',   // Name of the default member attribute, e.g. uniqueMember
    'name_attr'         => 'cn',       // Attribute to be used as group name
    'email_attr'        => 'mail',     // Group email address attribute (e.g. for mailing lists)
    'member_filter'     => '(objectclass=*)',  // Optional filter to use when querying for group members
    'vlv'               => false,      // Use VLV controls to list groups
    'class_member_attr' => array(      // Mapping of group object class to member attribute used in these objects
      'groupofnames'       => 'member',
      'groupofuniquenames' => 'uniquemember'
    ),
  ),
  // this configuration replaces the regular groups listing in the directory tree with
  // a hard-coded list of groups, each listing entries with the configured base DN and filter.
  // if the 'groups' option from above is set, it'll be shown as the first entry with the name 'Groups'
  'group_filters' => array(
    'departments' => array(
      'name'    => 'Company Departments',
      'scope'   => 'list',
      'base_dn' => 'ou=Groups,dc=mydomain,dc=com',
      'filter'  => '(|(objectclass=groupofuniquenames)(objectclass=groupofurls))',
      'name_attr' => 'cn',
    ),
    'customers' => array(
      'name'    => 'Customers',
      'scope'   => 'sub',
      'base_dn' => 'ou=Customers,dc=mydomain,dc=com',
      'filter'  => '(objectClass=inetOrgPerson)',
      'name_attr' => 'sn',
    ),
  ),
);
*/
plugins/managesieve/config.inc.php.dist
@@ -64,4 +64,8 @@
// Scripts listed here will be not presented to the user.
$rcmail_config['managesieve_filename_exceptions'] = array();
// List of domains limiting destination emails in redirect action
// If not empty, user will need to select domain from a list
$config['managesieve_domains'] = array();
?>
plugins/managesieve/managesieve.js
@@ -642,7 +642,7 @@
    enabled = {},
    elems = {
      mailbox: document.getElementById('action_mailbox' + id),
      target: document.getElementById('action_target' + id),
      target: document.getElementById('redirect_target' + id),
      target_area: document.getElementById('action_target_area' + id),
      flags: document.getElementById('action_flags' + id),
      vacation: document.getElementById('action_vacation' + id),
plugins/managesieve/managesieve.php
@@ -660,6 +660,7 @@
            $act_types      = get_input_value('_action_type', RCUBE_INPUT_POST, true);
            $mailboxes      = get_input_value('_action_mailbox', RCUBE_INPUT_POST, true);
            $act_targets    = get_input_value('_action_target', RCUBE_INPUT_POST, true);
            $domain_targets = get_input_value('_action_target_domain', RCUBE_INPUT_POST);
            $area_targets   = get_input_value('_action_target_area', RCUBE_INPUT_POST, true);
            $reasons        = get_input_value('_action_reason', RCUBE_INPUT_POST, true);
            $addresses      = get_input_value('_action_addresses', RCUBE_INPUT_POST, true);
@@ -828,8 +829,7 @@
            $i = 0;
            // actions
            foreach($act_types as $idx => $type) {
                $type   = $this->strip_value($type);
                $target = $this->strip_value($act_targets[$idx]);
                $type = $this->strip_value($type);
                switch ($type) {
@@ -854,12 +854,25 @@
                case 'redirect':
                case 'redirect_copy':
                    $target = $this->strip_value($act_targets[$idx]);
                    $domain = $this->strip_value($domain_targets[$idx]);
                    // force one of the configured domains
                    $domains = (array) $this->rc->config->get('managesieve_domains');
                    if (!empty($domains) && !empty($target)) {
                        if (!$domain || !in_array($domain, $domains)) {
                            $domain = $domains[0];
                        }
                        $target .= '@' . $domain;
                    }
                    $this->form['actions'][$i]['target'] = $target;
                    if ($this->form['actions'][$i]['target'] == '')
                    if ($target == '')
                        $this->errors['actions'][$i]['target'] = $this->gettext('cannotbeempty');
                    else if (!check_email($this->form['actions'][$i]['target']))
                        $this->errors['actions'][$i]['target'] = $this->gettext('noemailwarning');
                    else if (!rcube_utils::check_email($target))
                        $this->errors['actions'][$i]['target'] = $this->plugin->gettext(!empty($domains) ? 'forbiddenchars' : 'noemailwarning');
                    if ($type == 'redirect_copy') {
                        $type = 'redirect';
@@ -1560,11 +1573,34 @@
        // actions target inputs
        $out .= '<td class="rowtargets">';
        // shared targets
        $out .= '<input type="text" name="_action_target['.$id.']" id="action_target' .$id. '" '
            .'value="' .($action['type']=='redirect' ? Q($action['target'], 'strict', false) : ''). '" size="35" '
            .'style="display:' .($action['type']=='redirect' ? 'inline' : 'none') .'" '
            . $this->error_class($id, 'action', 'target', 'action_target') .' />';
        // force domain selection in redirect email input
        $domains = (array) $this->rc->config->get('managesieve_domains');
        if (!empty($domains)) {
            sort($domains);
            $domain_select = new html_select(array('name' => "_action_target_domain[$id]", 'id' => 'action_target_domain'.$id));
            $domain_select->add(array_combine($domains, $domains));
            $parts = explode('@', $action['target']);
            if (!empty($parts)) {
                $action['domain'] = array_pop($parts);
                $action['target'] = implode('@', $parts);
            }
        }
        // redirect target
        $out .= '<span id="redirect_target' . $id . '" style="white-space:nowrap;'
            . ' display:' . ($action['type'] == 'redirect' ? 'inline' : 'none') . '">'
            . '<input type="text" name="_action_target['.$id.']" id="action_target' .$id. '"'
            . ' value="' .($action['type'] == 'redirect' ? Q($action['target'], 'strict', false) : '') . '"'
            . (!empty($domains) ? ' size="20"' : ' size="35"')
            . $this->error_class($id, 'action', 'target', 'action_target') .' />'
            . (!empty($domains) ? ' @ ' . $domain_select->show($action['domain']) : '')
            . '</span>';
        // (e)reject target
        $out .= '<textarea name="_action_target_area['.$id.']" id="action_target_area' .$id. '" '
            .'rows="3" cols="35" '. $this->error_class($id, 'action', 'targetarea', 'action_target_area')
            .'style="display:' .(in_array($action['type'], array('reject', 'ereject')) ? 'inline' : 'none') .'">'
program/include/rcmail.php
@@ -312,7 +312,7 @@
        $list[$id] = array(
          'id'       => $id,
          'name'     => html::quote($prop['name']),
          'groups'   => is_array($prop['groups']),
          'groups'   => !empty($prop['groups']) || !empty($prop['group_filters']),
          'readonly' => !$prop['writable'],
          'hidden'   => $prop['hidden'],
          'autocomplete' => in_array($id, $autocomplete)
@@ -1435,6 +1435,7 @@
            $js_mailboxlist = array();
            $out = html::tag('ul', $attrib, $rcmail->render_folder_tree_html($a_mailboxes, $mbox_name, $js_mailboxlist, $attrib), html::$common_attrib);
            $rcmail->output->include_script('treelist.js');
            $rcmail->output->add_gui_object('mailboxlist', $attrib['id']);
            $rcmail->output->set_env('mailboxes', $js_mailboxlist);
            $rcmail->output->set_env('unreadwrap', $attrib['unreadwrap']);
@@ -1613,14 +1614,13 @@
                'id' => "rcmli".$folder_id,
                'class' => join(' ', $classes),
                'noclose' => true),
                html::a($link_attrib, $html_name) .
                (!empty($folder['folders']) ? html::div(array(
                    'class' => ($is_collapsed ? 'collapsed' : 'expanded'),
                    'style' => "position:absolute",
                    'onclick' => sprintf("%s.command('collapse-folder', '%s')", rcmail_output::JS_OBJECT_NAME, $js_name)
                ), '&nbsp;') : ''));
                html::a($link_attrib, $html_name));
            $jslist[$folder_id] = array(
            if (!empty($folder['folders'])) {
                $out .= html::div('treetoggle ' . ($is_collapsed ? 'collapsed' : 'expanded'), '&nbsp;');
            }
            $jslist[$folder['id']] = array(
                'id'      => $folder['id'],
                'name'    => $foldername,
                'virtual' => $folder['virtual']
program/js/app.js
@@ -4,7 +4,7 @@
 |                                                                       |
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2005-2013, The Roundcube Dev Team                       |
 | Copyright (C) 2011-2012, Kolab Systems AG                             |
 | Copyright (C) 2011-2013, Kolab Systems AG                             |
 |                                                                       |
 | Licensed under the GNU General Public License version 3 or            |
 | any later version with exceptions for skins & plugins.                |
@@ -251,7 +251,8 @@
          }
        }
        else if (this.env.action == 'compose') {
          this.env.compose_commands = ['send-attachment', 'remove-attachment', 'send', 'cancel', 'toggle-editor', 'list-adresses', 'search', 'reset-search', 'extwin'];
          this.env.address_group_stack = [];
          this.env.compose_commands = ['send-attachment', 'remove-attachment', 'send', 'cancel', 'toggle-editor', 'list-adresses', 'pushgroup', 'search', 'reset-search', 'extwin'];
          if (this.env.drafts_mailbox)
            this.env.compose_commands.push('savedraft')
@@ -318,11 +319,13 @@
        break;
      case 'addressbook':
        this.env.address_group_stack = [];
        if (this.gui_objects.folderlist)
          this.env.contactfolders = $.extend($.extend({}, this.env.address_sources), this.env.contactgroups);
        this.enable_command('add', 'import', this.env.writable_source);
        this.enable_command('list', 'listgroup', 'listsearch', 'advanced-search', true);
        this.enable_command('list', 'listgroup', 'pushgroup', 'popgroup', 'listsearch', 'advanced-search', true);
        if (this.gui_objects.contactslist) {
          this.contact_list = new rcube_list_widget(this.gui_objects.contactslist,
@@ -459,8 +462,21 @@
      this.display_message(this.pending_message[0], this.pending_message[1], this.pending_message[2]);
    // map implicit containers
    if (this.gui_objects.folderlist)
    if (this.gui_objects.folderlist) {
      this.gui_containers.foldertray = $(this.gui_objects.folderlist);
      // init treelist widget
      if (window.rcube_treelist_widget) {
        this.treelist = new rcube_treelist_widget(this.gui_objects.folderlist, {
          id_prefix: 'rcmli',
          id_encode: this.html_identifier_encode,
          id_decode: this.html_identifier_decode,
          check_droptarget: function(node){ return !node.virtual && ref.check_droptarget(node.id) }
        });
        this.treelist.addEventListener('collapse', function(node){ ref.folder_collapsed(node) });
        this.treelist.addEventListener('expand', function(node){ ref.folder_collapsed(node) });
      }
    }
    // activate html5 file drop feature (if browser supports it and if configured)
    if (this.gui_objects.filedrop && this.env.filedrop && ((window.XMLHttpRequest && XMLHttpRequest.prototype && XMLHttpRequest.prototype.sendAsBinary) || window.FormData)) {
@@ -756,7 +772,7 @@
      case 'moveto':
        if (this.task == 'mail')
          this.move_messages(props);
        else if (this.task == 'addressbook' && this.drag_active)
        else if (this.task == 'addressbook')
          this.copy_contact(null, props);
        break;
@@ -1067,9 +1083,23 @@
        }
        break;
      case 'pushgroup':
        // add group ID to stack
        this.env.address_group_stack.push(props.id);
        if (obj && event)
          rcube_event.cancel(event);
      case 'listgroup':
        this.reset_qsearch();
        this.list_contacts(props.source, props.id);
        break;
      case 'popgroup':
        if (this.env.address_group_stack.length > 1) {
          this.env.address_group_stack.pop();
          this.reset_qsearch();
          this.list_contacts(props.source, this.env.address_group_stack[this.env.address_group_stack.length-1]);
        }
        break;
      case 'import':
@@ -1257,11 +1287,12 @@
  this.html_identifier = function(str, encode)
  {
    str = String(str);
    if (encode)
      return Base64.encode(str).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
    else
      return str.replace(this.identifier_expr, '_');
    return encode ? this.html_identifier_encode(str) : String(str).replace(this.identifier_expr, '_');
  };
  this.html_identifier_encode = function(str)
  {
    return Base64.encode(String(str)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
  };
  this.html_identifier_decode = function(str)
@@ -1314,29 +1345,9 @@
    if (this.preview_read_timer)
      clearTimeout(this.preview_read_timer);
    // save folderlist and folders location/sizes for droptarget calculation in drag_move()
    if (this.gui_objects.folderlist && model) {
      this.initialBodyScrollTop = bw.ie ? 0 : window.pageYOffset;
      this.initialListScrollTop = this.gui_objects.folderlist.parentNode.scrollTop;
      var k, li, height,
        list = $(this.gui_objects.folderlist);
        pos = list.offset();
      this.env.folderlist_coords = { x1:pos.left, y1:pos.top, x2:pos.left + list.width(), y2:pos.top + list.height() };
      this.env.folder_coords = [];
      for (k in model) {
        if (li = this.get_folder_li(k)) {
          // only visible folders
          if (height = li.firstChild.offsetHeight) {
            pos = $(li.firstChild).offset();
            this.env.folder_coords[k] = { x1:pos.left, y1:pos.top,
              x2:pos.left + li.firstChild.offsetWidth, y2:pos.top + height, on:0 };
          }
        }
      }
    }
    // prepare treelist widget for dragging interactions
    if (this.treelist)
      this.treelist.drag_start();
  };
  this.drag_end = function(e)
@@ -1344,87 +1355,28 @@
    this.drag_active = false;
    this.env.last_folder_target = null;
    if (this.folder_auto_timer) {
      clearTimeout(this.folder_auto_timer);
      this.folder_auto_timer = null;
      this.folder_auto_expand = null;
    }
    // over the folders
    if (this.gui_objects.folderlist && this.env.folder_coords) {
      for (var k in this.env.folder_coords) {
        if (this.env.folder_coords[k].on)
          $(this.get_folder_li(k)).removeClass('droptarget');
      }
    }
    if (this.treelist)
      this.treelist.drag_end();
  };
  this.drag_move = function(e)
  {
    if (this.gui_objects.folderlist && this.env.folder_coords) {
      var k, li, div, check, oldclass,
    if (this.gui_objects.folderlist) {
      var drag_target, oldclass,
        layerclass = 'draglayernormal',
        mouse = rcube_event.get_mouse_pos(e),
        pos = this.env.folderlist_coords,
        // offsets to compensate for scrolling while dragging a message
        boffset = bw.ie ? -document.documentElement.scrollTop : this.initialBodyScrollTop,
        moffset = this.initialListScrollTop-this.gui_objects.folderlist.parentNode.scrollTop;
        mouse = rcube_event.get_mouse_pos(e);
      if (this.contact_list && this.contact_list.draglayer)
        oldclass = this.contact_list.draglayer.attr('class');
      mouse.y += -moffset-boffset;
      // if mouse pointer is outside of folderlist
      if (mouse.x < pos.x1 || mouse.x >= pos.x2 || mouse.y < pos.y1 || mouse.y >= pos.y2) {
        if (this.env.last_folder_target) {
          $(this.get_folder_li(this.env.last_folder_target)).removeClass('droptarget');
          this.env.folder_coords[this.env.last_folder_target].on = 0;
          this.env.last_folder_target = null;
        }
        if (layerclass != oldclass && this.contact_list && this.contact_list.draglayer)
          this.contact_list.draglayer.attr('class', layerclass);
        return;
      // mouse intersects a valid drop target on the treelist
      if (this.treelist && (drag_target = this.treelist.intersects(mouse, true))) {
        this.env.last_folder_target = drag_target;
        layerclass = 'draglayer' + (this.check_droptarget(drag_target) > 1 ? 'copy' : 'normal');
      }
      // over the folders
      for (k in this.env.folder_coords) {
        pos = this.env.folder_coords[k];
        if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.y >= pos.y1 && mouse.y < pos.y2) {
          if (check = this.check_droptarget(k)) {
            li = this.get_folder_li(k);
            div = $(li.getElementsByTagName('div')[0]);
            // if the folder is collapsed, expand it after 1sec and restart the drag & drop process.
            if (div.hasClass('collapsed')) {
              if (this.folder_auto_timer)
                clearTimeout(this.folder_auto_timer);
              this.folder_auto_expand = this.env.mailboxes[k].id;
              this.folder_auto_timer = setTimeout(function() {
                rcmail.command('collapse-folder', rcmail.folder_auto_expand);
                rcmail.drag_start(null);
              }, 1000);
            }
            else if (this.folder_auto_timer) {
              clearTimeout(this.folder_auto_timer);
              this.folder_auto_timer = null;
              this.folder_auto_expand = null;
            }
            $(li).addClass('droptarget');
            this.env.folder_coords[k].on = 1;
            this.env.last_folder_target = k;
            layerclass = 'draglayer' + (check > 1 ? 'copy' : 'normal');
          }
          // Clear target, otherwise drag end will trigger move into last valid droptarget
          else
            this.env.last_folder_target = null;
        }
        else if (pos.on) {
          $(this.get_folder_li(k)).removeClass('droptarget');
          this.env.folder_coords[k].on = 0;
        }
      else {
        // Clear target, otherwise drag end will trigger move into last valid droptarget
        this.env.last_folder_target = null;
      }
      if (layerclass != oldclass && this.contact_list && this.contact_list.draglayer)
@@ -1434,40 +1386,33 @@
  this.collapse_folder = function(name)
  {
    var li = this.get_folder_li(name, '', true),
      div = $('div:first', li),
      ul = $('ul:first', li);
    if (this.treelist)
      this.treelist.toggle(name);
  };
    if (div.hasClass('collapsed')) {
      ul.show();
      div.removeClass('collapsed').addClass('expanded');
      var reg = new RegExp('&'+urlencode(name)+'&');
      this.env.collapsed_folders = this.env.collapsed_folders.replace(reg, '');
    }
    else if (div.hasClass('expanded')) {
      ul.hide();
      div.removeClass('expanded').addClass('collapsed');
      this.env.collapsed_folders = this.env.collapsed_folders+'&'+urlencode(name)+'&';
  this.folder_collapsed = function(node)
  {
    var prefname = this.env.task == 'addressbook' ? 'collapsed_abooks' : 'collapsed_folders';
    if (node.collapsed) {
      this.env[prefname] = this.env[prefname] + '&'+urlencode(node.id)+'&';
      // select the folder if one of its childs is currently selected
      // don't select if it's virtual (#1488346)
      if (this.env.mailbox.indexOf(name + this.env.delimiter) == 0 && !$(li).hasClass('virtual'))
      if (this.env.mailbox && this.env.mailbox.indexOf(name + this.env.delimiter) == 0 && !node.virtual)
        this.command('list', name);
    }
    else
      return;
    // Work around a bug in IE6 and IE7, see #1485309
    if (bw.ie6 || bw.ie7) {
      var siblings = li.nextSibling ? li.nextSibling.getElementsByTagName('ul') : null;
      if (siblings && siblings.length && (li = siblings[0]) && li.style && li.style.display != 'none') {
        li.style.display = 'none';
        li.style.display = '';
      }
    else {
      var reg = new RegExp('&'+urlencode(node.id)+'&');
      this.env[prefname] = this.env[prefname].replace(reg, '');
    }
    this.command('save-pref', { name: 'collapsed_folders', value: this.env.collapsed_folders });
    this.set_unread_count_display(name, false);
    if (!this.drag_active) {
      this.command('save-pref', { name: prefname, value: this.env[prefname] });
      if (this.env.unread_counts)
        this.set_unread_count_display(node.id, false);
    }
  };
  this.doc_mouse_up = function(e)
@@ -1492,9 +1437,9 @@
    if (this.drag_active && model && this.env.last_folder_target) {
      var target = model[this.env.last_folder_target];
      $(this.get_folder_li(this.env.last_folder_target)).removeClass('droptarget');
      this.env.last_folder_target = null;
      list.draglayer.hide();
      this.drag_end(e);
      if (!this.drag_menu(e, target))
        this.command('moveto', target);
@@ -3140,7 +3085,13 @@
  this.compose_recipient_select = function(list)
  {
    this.enable_command('add-recipient', list.selection.length > 0);
    var id, n, recipients = 0;
    for (n=0; n < list.selection.length; n++) {
      id = list.selection[n];
      if (this.env.contactdata[id])
        recipients++;
    }
    this.enable_command('add-recipient', recipients);
  };
  this.compose_add_recipient = function(field)
@@ -4139,7 +4090,7 @@
    if (this.preview_timer)
      clearTimeout(this.preview_timer);
    var n, id, sid, ref = this, writable = false,
    var n, id, sid, contact, ref = this, writable = false,
      source = this.env.source ? this.env.address_sources[this.env.source] : null;
    // we don't have dblclick handler here, so use 200 instead of this.dblclick_time
@@ -4149,33 +4100,39 @@
      this.show_contentframe(false);
    if (list.selection.length) {
      list.draggable = false;
      // no source = search result, we'll need to detect if any of
      // selected contacts are in writable addressbook to enable edit/delete
      // we'll also need to know sources used in selection for copy
      // and group-addmember operations (drag&drop)
      this.env.selection_sources = [];
      if (!source) {
        for (n in list.selection) {
      if (source)
        this.env.selection_sources.push(this.env.source);
      for (n in list.selection) {
        contact = list.data[list.selection[n]];
        if (!source) {
          sid = String(list.selection[n]).replace(/^[^-]+-/, '');
          if (sid && this.env.address_sources[sid]) {
            writable = writable || !this.env.address_sources[sid].readonly;
            writable = writable || (!this.env.address_sources[sid].readonly && !contact.readonly);
            this.env.selection_sources.push(sid);
          }
        }
        this.env.selection_sources = $.unique(this.env.selection_sources);
        else
          writable = writable || (!source.readonly && !contact.readonly);
      }
      else {
        this.env.selection_sources.push(this.env.source);
        writable = !source.readonly;
      }
      this.env.selection_sources = $.unique(this.env.selection_sources);
    }
    // if a group is currently selected, and there is at least one contact selected
    // thend we can enable the group-remove-selected command
    this.enable_command('group-remove-selected', this.env.group && list.selection.length > 0);
    this.enable_command('group-remove-selected', this.env.group && list.selection.length > 0 && writable);
    this.enable_command('compose', this.env.group || list.selection.length > 0);
    this.enable_command('edit', id && writable);
    this.enable_command('delete', list.selection.length && writable);
    this.enable_command('delete', list.selection.length > 0 && writable);
    return false;
  };
@@ -4203,10 +4160,28 @@
    else if (!this.env.search_request)
      folder = group ? 'G'+src+group : src;
    this.select_folder(folder);
    this.env.source = src;
    this.env.group = group;
    // truncate groups listing stack
    var index = $.inArray(this.env.group, this.env.address_group_stack);
    if (index < 0)
      this.env.address_group_stack = [];
    else
      this.env.address_group_stack = this.env.address_group_stack.slice(0,index);
    // make sure the current group is on top of the stack
    if (this.env.group) {
      this.env.address_group_stack.push(this.env.group);
      // mark the first group on the stack as selected in the directory list
      folder = 'G'+src+this.env.address_group_stack[0];
    }
    else if (this.gui_objects.addresslist_title) {
      $(this.gui_objects.addresslist_title).html(this.get_label('contacts'));
    }
    this.select_folder(folder, '', true);
    // load contacts remotely
    if (this.gui_objects.contactslist) {
@@ -4262,16 +4237,38 @@
  this.list_contacts_clear = function()
  {
    this.contact_list.data = {};
    this.contact_list.clear(true);
    this.show_contentframe(false);
    this.enable_command('delete', false);
    this.enable_command('compose', this.env.group ? true : false);
  };
  this.set_group_prop = function(prop)
  {
    if (this.gui_objects.addresslist_title) {
      var boxtitle = $(this.gui_objects.addresslist_title).html('');  // clear contents
      // add link to pop back to parent group
      if (this.env.address_group_stack.length > 1) {
        $('<a href="#list">...</a>')
          .addClass('poplink')
          .appendTo(boxtitle)
          .click(function(e){ return ref.command('popgroup','',this); });
        boxtitle.append('&nbsp;&raquo;&nbsp;');
      }
      boxtitle.append($('<span>'+prop.name+'</span>'));
    }
    this.triggerEvent('groupupdate', prop);
  };
  // load contact record
  this.load_contact = function(cid, action, framed)
  {
    var win, url = {}, target = window;
    var win, url = {}, target = window,
      rec = this.contact_list ? this.contact_list.data[cid] : null;
    if (win = this.get_frame_window(this.env.contentframe)) {
      url._framed = 1;
@@ -4282,7 +4279,9 @@
      if (!cid) {
        // unselect selected row(s)
        this.contact_list.clear_selection();
        this.enable_command('delete', 'compose', false);
        this.enable_command('compose', rec && rec.email);
        this.enable_command('delete', rec && rec._type != 'group');
      }
    }
    else if (framed)
@@ -4393,7 +4392,7 @@
  };
  // update a contact record in the list
  this.update_contact_row = function(cid, cols_arr, newcid, source)
  this.update_contact_row = function(cid, cols_arr, newcid, source, data)
  {
    var c, row, list = this.contact_list;
@@ -4420,11 +4419,13 @@
        list.selection[0] = newcid;
        row.style.display = '';
      }
      list.data[cid] = data;
    }
  };
  // add row to contacts list
  this.add_contact_row = function(cid, cols, classes)
  this.add_contact_row = function(cid, cols, classes, data)
  {
    if (!this.gui_objects.contactslist)
      return false;
@@ -4447,6 +4448,8 @@
      row.appendChild(col);
    }
    // store data in list member
    list.data[cid] = data;
    list.insert_row(row);
    this.enable_command('export', list.rowcount > 0);
@@ -4510,7 +4513,7 @@
      this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
      this.env.group_renaming = true;
      var link, li = this.get_folder_li(this.env.source+this.env.group, 'rcmliG');
      var link, li = this.get_folder_li('G'+this.env.source+this.env.group,'',true);
      if (li && (link = li.firstChild)) {
        $(link).hide().before(this.name_input);
      }
@@ -4531,7 +4534,7 @@
  this.remove_group_item = function(prop)
  {
    var li, key = 'G'+prop.source+prop.id;
    if ((li = this.get_folder_li(key))) {
    if ((li = this.get_folder_li(key,'',true))) {
      this.triggerEvent('group_delete', { source:prop.source, id:prop.id, li:li });
      li.parentNode.removeChild(li);
@@ -4553,8 +4556,22 @@
      this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
      this.name_input_li = $('<li>').addClass(type).append(this.name_input);
      var li = type == 'contactsearch' ? $('li:last', this.gui_objects.folderlist) : this.get_folder_li(this.env.source);
      this.name_input_li.insertAfter(li);
      var ul, li;
      // find list (UL) element
      if (type == 'contactsearch')
        ul = this.gui_objects.folderlist;
      else
        ul = $('ul.groups', this.get_folder_li(this.env.source,'',true));
      // append to the list
      li = $('li:last', ul);
      if (li.length)
        this.name_input_li.insertAfter(li);
      else {
        this.name_input_li.appendTo(ul);
        ul.show(); // make sure the list is visible
      }
    }
    this.name_input.select().focus();
@@ -4611,11 +4628,13 @@
  this.reset_add_input = function()
  {
    if (this.name_input) {
      var li = this.name_input.parent();
      if (this.env.group_renaming) {
        var li = this.name_input.parent();
        li.children().last().show();
        this.env.group_renaming = false;
      }
      else if ($('li', li.parent()).length == 1)
        li.parent().hide();
      this.name_input.remove();
@@ -4654,7 +4673,7 @@
    this.reset_add_input();
    var key = 'G'+prop.source+prop.id,
      li = this.get_folder_li(key),
      li = this.get_folder_li(key,'',true),
      link;
    // group ID has changed, replace link node and identifiers
@@ -4693,8 +4712,8 @@
  this.add_contact_group_row = function(prop, li, reloc)
  {
    var row, name = prop.name.toUpperCase(),
      sibling = this.get_folder_li(prop.source),
      prefix = 'rcmliG' + this.html_identifier(prop.source);
      sibling = this.get_folder_li(prop.source,'',true),
      prefix = 'rcmli' + this.html_identifier('G'+prop.source, true);
    // When renaming groups, we need to remove it from DOM and insert it in the proper place
    if (reloc) {
@@ -4840,6 +4859,9 @@
          if (++colprop.count == colprop.limit && colprop.limit)
            $(menu).children('option[value="'+col+'"]').prop('disabled', true);
        }
        if (contact._type != 'group')
          list.draggable = true;
      }
    }
  };
@@ -4943,12 +4965,12 @@
        .attr('rel', id)
        .click(function() { return rcmail.command('listsearch', id, this); })
        .html(name),
      li = $('<li>').attr({id: 'rcmli' + this.html_identifier(key), 'class': 'contactsearch'})
      li = $('<li>').attr({ id:'rcmli' + this.html_identifier(key,true), 'class':'contactsearch' })
        .append(link),
      prop = {name:name, id:id, li:li[0]};
    this.add_saved_search_row(prop, li);
    this.select_folder('S'+id);
    this.select_folder(key,'',true);
    this.enable_command('search-delete', true);
    this.env.search_id = id;
@@ -5002,7 +5024,7 @@
  this.remove_search_item = function(id)
  {
    var li, key = 'S'+id;
    if ((li = this.get_folder_li(key))) {
    if ((li = this.get_folder_li(key,'',true))) {
      this.triggerEvent('search_delete', { id:id, li:li });
      li.parentNode.removeChild(li);
@@ -5024,7 +5046,7 @@
    }
    this.reset_qsearch();
    this.select_folder('S'+id);
    this.select_folder('S'+id, '', true);
    // reset vars
    this.env.current_page = 1;
program/js/treelist.js
New file
@@ -0,0 +1,575 @@
/*
 +-----------------------------------------------------------------------+
 | Roundcube Treelist widget                                             |
 |                                                                       |
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2013, 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.                     |
 |                                                                       |
 +-----------------------------------------------------------------------+
 | Authors: Thomas Bruederli <roundcube@gmail.com>                       |
 +-----------------------------------------------------------------------+
 | Requires: common.js                                                   |
 +-----------------------------------------------------------------------+
*/
/**
 * Roundcube Treelist widget class
 * @contructor
 */
function rcube_treelist_widget(node, p)
{
  // apply some defaults to p
  p = $.extend({
    id_prefix: '',
    autoexpand: 1000,
    selectable: false,
    check_droptarget: function(node){ return !node.virtual }
  }, p || {});
  var container = $(node),
    data = p.data || [],
    indexbyid = {},
    selection = null,
    drag_active = false,
    box_coords = {},
    item_coords = [],
    autoexpand_timer,
    autoexpand_item,
    body_scroll_top = 0,
    list_scroll_top = 0,
    me = this;
  /////// export public members and methods
  this.container = container;
  this.expand = expand;
  this.collapse = collapse;
  this.select = select;
  this.render = render;
  this.drag_start = drag_start;
  this.drag_end = drag_end;
  this.intersects = intersects;
  this.update = update_node;
  this.insert = insert;
  this.remove = remove;
  this.get_item = get_item;
  this.get_selection = get_selection;
  /////// startup code (constructor)
  // abort if node not found
  if (!container.length)
    return;
  if (p.data)
    index_data({ children:data });
  // load data from DOM
  else
    update_data();
  // register click handlers on list
  container.on('click', 'div.treetoggle', function(e){
    toggle(dom2id($(this).parent()));
  });
  container.on('click', 'li', function(e){
    var node = p.selectable ? indexbyid[dom2id($(this))] : null;
    if (node && !node.virtual) {
      select(node.id);
      e.stopPropagation();
    }
  });
  /////// private methods
  /**
   * Collaps a the node with the given ID
   */
  function collapse(id, recursive, set)
  {
    var node;
    if (node = indexbyid[id]) {
      node.collapsed = typeof set == 'undefined' || set;
      update_dom(node);
      // Work around a bug in IE6 and IE7, see #1485309
      if (window.bw && (bw.ie6 || bw.ie7) && node.collapsed) {
        id2dom(node.id).next().children('ul:visible').hide().show();
      }
      if (recursive && node.children) {
        for (var i=0; i < node.children.length; i++) {
          collapse(node.children[i].id, recursive, set);
        }
      }
      me.triggerEvent(node.collapsed ? 'collapse' : 'expand', node);
    }
  }
  /**
   * Expand a the node with the given ID
   */
  function expand(id, recursive)
  {
    collapse(id, recursive, false);
  }
  /**
   * Toggle collapsed state of a list node
   */
  function toggle(id, recursive)
  {
    var node;
    if (node = indexbyid[id]) {
      collapse(id, recursive, !node.collapsed);
    }
  }
  /**
   * Select a tree node by it's ID
   */
  function select(id)
  {
    if (selection) {
      id2dom(selection).removeClass('selected');
      selection = null;
    }
    var li = id2dom(id);
    if (li.length) {
      li.addClass('selected');
      selection = id;
      // TODO: expand all parent nodes if collapsed
      scroll_to_node(li);
    }
    me.triggerEvent('select', indexbyid[id]);
  }
  /**
   * Getter for the currently selected node ID
   */
  function get_selection()
  {
    return selection;
  }
  /**
   * Return the DOM element of the list item with the given ID
   */
  function get_item(id)
  {
    return id2dom(id).get(0);
  }
  /**
   * Insert the given node
   */
  function insert(node, parent_id, sort)
  {
    var li, parent_li,
      parent_node = parent_id ? indexbyid[parent_id] : null;
    // insert as child of an existing node
    if (parent_node) {
      if (!parent_node.children)
        parent_node.children = [];
      parent_node.children.push(node);
      parent_li = id2dom(parent_id);
      // re-render the entire subtree
      if (parent_node.children.length == 1) {
        render_node(parent_node, parent_li.parent(), parent_li);
        li = id2dom(node.id);
      }
      else {
        // append new node to parent's child list
        li = render_node(node, parent_li.children('ul').first());
      }
    }
    // insert at top level
    else {
      data.push(node);
      li = render_node(node, container);
    }
    indexbyid[node.id] = node;
    if (sort) {
      resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
    }
  }
  /**
   * Update properties of an existing node
   */
  function update_node(id, updates, sort)
  {
    var li, node = indexbyid[id];
    if (node) {
      li = id2dom(id);
      if (updates.id || updates.html || updates.children || updates.classes) {
        $.extend(node, updates);
        render_node(node, li.parent(), li);
      }
      if (node.id != id) {
        delete indexbyid[id];
        indexbyid[node.id] = node;
      }
      if (sort) {
        resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
      }
    }
  }
  /**
   * Helper method to sort the list of the given item
   */
  function resort_node(li, filter)
  {
    var first, sibling,
      myid = li.get(0).id,
      sortname = li.children().first().text().toUpperCase();
    li.parent().children('li' + filter).each(function(i, elem) {
      if (i == 0)
        first = elem;
      if (elem.id == myid) {
        // skip
      }
      else if (elem.id != myid && sortname >= $(elem).children().first().text().toUpperCase()) {
        sibling = elem;
      }
      else {
        return false;
      }
    });
    if (sibling) {
      li.insertAfter(sibling);
    }
    else if (first.id != myid) {
      li.insertBefore(first);
    }
    // reload data from dom
    update_data();
  }
  /**
   * Remove the item with the given ID
   */
  function remove(id)
  {
    var node, li;
    if (node = indexbyid[id]) {
      li = id2dom(id);
      li.remove();
      node.deleted = true;
      delete indexbyid[id];
      return true;
    }
    return false;
  }
  /**
   * (Re-)read tree data from DOM
   */
  function update_data()
  {
    data = walk_list(container);
  }
  /**
   * Apply the 'collapsed' status of the data node to the corresponding DOM element(s)
   */
  function update_dom(node)
  {
    var li = id2dom(node.id);
    li.children('ul').first()[(node.collapsed ? 'hide' : 'show')]();
    li.children('div.treetoggle').removeClass('collapsed expanded').addClass(node.collapsed ? 'collapsed' : 'expanded');
    me.triggerEvent('toggle', node);
  }
  /**
   * Render the tree list from the internal data structure
   */
  function render()
  {
    if (me.triggerEvent('renderBefore', data) === false)
      return;
    // remove all child nodes
    container.html('');
    // render child nodes
    for (var i=0; i < data.length; i++) {
      render_node(data[i], container);
    }
    me.triggerEvent('renderAfter', container);
  }
  /**
   * Render a specific node into the DOM list
   */
  function render_node(node, parent, replace)
  {
    if (node.deleted)
      return;
    var li = $('<li>')
      .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id))
      .addClass((node.classes || []).join(' '));
    if (replace)
      replace.replaceWith(li);
    else
      li.appendTo(parent);
    if (typeof node.html == 'string')
      li.html(node.html);
    else if (typeof node.html == 'object')
      li.append(node.html);
    if (node.virtual)
      li.addClass('virtual');
    if (node.id == selection)
      li.addClass('selected');
    // add child list and toggle icon
    if (node.children && node.children.length) {
      $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '">&nbsp;</div>').appendTo(li);
      var ul = $('<ul>').appendTo(li).attr('class', node.childlistclass);
      if (node.collapsed)
        ul.hide();
      for (var i=0; i < node.children.length; i++) {
        render_node(node.children[i], ul);
      }
    }
    return li;
  }
  /**
   * Recursively walk the DOM tree and build an internal data structure
   * representing the skeleton of this tree list.
   */
  function walk_list(ul)
  {
    var result = [];
    ul.children('li').each(function(i,e){
      var li = $(e), sublist = li.children('ul');
      var node = {
        id: dom2id(li),
        classes: li.attr('class').split(' '),
        virtual: li.hasClass('virtual'),
        html: li.children().first().get(0).outerHTML,
        children: walk_list(sublist)
      }
      if (sublist.length) {
        node.childlistclass = sublist.attr('class');
      }
      if (node.children.length) {
        node.collapsed = sublist.css('display') == 'none';
      }
      if (li.hasClass('selected')) {
        selection = node.id;
      }
      result.push(node);
      indexbyid[node.id] = node;
    })
    return result;
  }
  /**
   * Recursively walk the data tree and index nodes by their ID
   */
  function index_data(node)
  {
    if (node.id) {
      indexbyid[node.id] = node;
    }
    for (var c=0; node.children && c < node.children.length; c++) {
      index_data(node.children[c]);
    }
  }
  /**
   * Get the (stripped) node ID from the given DOM element
   */
  function dom2id(li)
  {
    var domid = li.attr('id').replace(new RegExp('^' + (p.id_prefix) || '%'), '');
    return p.id_decode ? p.id_decode(domid) : domid;
  }
  /**
   * Get the <li> element for the given node ID
   */
  function id2dom(id)
  {
    var domid = p.id_encode ? p.id_encode(id) : id;
    return $('#' + p.id_prefix + domid);
  }
  /**
   * Scroll the parent container to make the given list item visible
   */
  function scroll_to_node(li)
  {
    var scroller = container.parent(),
      current_offset = scroller.scrollTop(),
      rel_offset = li.offset().top - scroller.offset().top;
    if (rel_offset < 0 || rel_offset + li.height() > scroller.height())
      scroller.scrollTop(rel_offset + current_offset);
  }
  ///// drag & drop support
  /**
   * When dragging starts, compute absolute bounding boxes of the list and it's items
   * for faster comparisons while mouse is moving
   */
  function drag_start()
  {
    var li, item, height,
      pos = container.offset();
    body_scroll_top = bw.ie ? 0 : window.pageYOffset;
    list_scroll_top = container.parent().scrollTop();
    drag_active = true;
    box_coords = {
      x1: pos.left,
      y1: pos.top,
      x2: pos.left + container.width(),
      y2: pos.top + container.height()
    };
    item_coords = [];
    for (var id in indexbyid) {
      li = id2dom(id);
      item = li.children().first().get(0);
      if (height = item.offsetHeight) {
        pos = $(item).offset();
        item_coords[id] = {
          x1: pos.left,
          y1: pos.top,
          x2: pos.left + item.offsetWidth,
          y2: pos.top + height,
          on: id == autoexpand_item
        };
      }
    }
  }
  /**
   * Signal that dragging has stopped
   */
  function drag_end()
  {
    drag_active = false;
    if (autoexpand_timer) {
      clearTimeout(autoexpand_timer);
      autoexpand_timer = null;
      autoexpand_item = null;
    }
    $('li.droptarget', container).removeClass('droptarget');
  }
  /**
   * Determine if the given mouse coords intersect the list and one if its items
   */
  function intersects(mouse, highlight)
  {
    // offsets to compensate for scrolling while dragging a message
    var boffset = bw.ie ? -document.documentElement.scrollTop : body_scroll_top,
      moffset = list_scroll_top - container.parent().scrollTop(),
      result = null;
    mouse.top = mouse.y + -moffset - boffset;
    // no intersection with list bounding box
    if (mouse.x < box_coords.x1 || mouse.x >= box_coords.x2 || mouse.top < box_coords.y1 || mouse.top >= box_coords.y2) {
      // TODO: optimize performance for this operation
      $('li.droptarget', container).removeClass('droptarget');
      return result;
    }
    // check intersection with visible list items
    var pos, node;
    for (var id in item_coords) {
      pos = item_coords[id];
      if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.top >= pos.y1 && mouse.top < pos.y2) {
        node = indexbyid[id];
        // if the folder is collapsed, expand it after the configured time
        if (node.children && node.children.length && node.collapsed && p.autoexpand && autoexpand_item != id) {
          if (autoexpand_timer)
            clearTimeout(autoexpand_timer);
          autoexpand_item = id;
          autoexpand_timer = setTimeout(function() {
            expand(autoexpand_item);
            drag_start();  // re-calculate item coords
            autoexpand_item = null;
          }, p.autoexpand);
        }
        else if (autoexpand_timer && autoexpand_item != id) {
          clearTimeout(autoexpand_timer);
          autoexpand_item = null;
          autoexpand_timer = null;
        }
        // check if this item is accepted as drop target
        if (p.check_droptarget(node)) {
          if (highlight) {
            id2dom(id).addClass('droptarget');
            pos.on = true;
          }
          result = id;
        }
        else {
          result = null;
        }
      }
      else if (pos.on) {
        id2dom(id).removeClass('droptarget');
        pos.on = false;
      }
    }
    return result;
  }
}
// use event processing functions from Roundcube's rcube_event_engine
rcube_treelist_widget.prototype.addEventListener = rcube_event_engine.prototype.addEventListener;
rcube_treelist_widget.prototype.removeEventListener = rcube_event_engine.prototype.removeEventListener;
rcube_treelist_widget.prototype.triggerEvent = rcube_event_engine.prototype.triggerEvent;
program/lib/Roundcube/rcube_config.php
@@ -192,7 +192,7 @@
     */
    public function get($name, $def = null)
    {
        if (isset($this->prop[$name])) {
        if (array_key_exists($name, $this->prop)) {
            $result = $this->prop[$name];
        }
        else {
program/lib/Roundcube/rcube_ldap.php
@@ -3,8 +3,8 @@
/*
 +-----------------------------------------------------------------------+
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2006-2012, The Roundcube Dev Team                       |
 | Copyright (C) 2011-2012, Kolab Systems AG                             |
 | Copyright (C) 2006-2013, The Roundcube Dev Team                       |
 | Copyright (C) 2011-2013, Kolab Systems AG                             |
 |                                                                       |
 | Licensed under the GNU General Public License version 3 or            |
 | any later version with exceptions for skins & plugins.                |
@@ -36,7 +36,7 @@
    public $coltypes = array();
    /** private properties */
    protected $conn;
    protected $ldap;
    protected $prop = array();
    protected $fieldmap = array();
    protected $sub_filter;
@@ -51,9 +51,6 @@
    private $group_url = null;
    private $cache;
    private $vlv_active = false;
    private $vlv_count = 0;
    /**
    * Object constructor
@@ -65,6 +62,8 @@
    function __construct($p, $debug = false, $mail_domain = null)
    {
        $this->prop = $p;
        $fetch_attributes = array('objectClass');
        if (isset($p['searchonly']))
            $this->searchonly = $p['searchonly'];
@@ -82,6 +81,31 @@
                $this->prop['groups']['name_attr'] = 'cn';
            if (empty($this->prop['groups']['scope']))
                $this->prop['groups']['scope'] = 'sub';
            // set default group objectclass => member attribute mapping
            if (empty($this->prop['groups']['class_member_attr'])) {
                $this->prop['groups']['class_member_attr'] = array(
                    'group'                   => 'member',
                    'groupofnames'            => 'member',
                    'kolabgroupofnames'       => 'member',
                    'groupofuniquenames'      => 'uniquemember',
                    'kolabgroupofuniquenames' => 'uniquemember',
                );
            }
            // add group name attrib to the list of attributes to be fetched
            $fetch_attributes[] = $this->prop['groups']['name_attr'];
        }
        if (is_array($p['group_filters']) && count($p['group_filters'])) {
            $this->groups = true;
            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 ($group_filter['name_attr'])
                    $fetch_attributes[] = $group_filter['name_attr'];
            }
        }
        // fieldmap property is given
@@ -186,7 +210,22 @@
        // initialize cache
        $rcube = rcube::get_instance();
        $this->cache = $rcube->get_cache('LDAP.' . asciiwords($this->prop['name']), 'db', 600);
        if ($cache_type = $rcube->config->get('ldap_cache', 'db')) {
            $cache_ttl  = $rcube->config->get('ldap_cache_ttl', '10m');
            $cache_name = 'LDAP.' . asciiwords($this->prop['name']);
            $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, true);
        $this->ldap->set_cache($this->cache);
        $this->_connect();
    }
@@ -199,49 +238,18 @@
    {
        $rcube = rcube::get_instance();
        if (!function_exists('ldap_connect'))
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "No ldap support in this installation of PHP"),
                true, true);
        if (is_resource($this->conn))
        if ($this->ready)
            return true;
        if (!is_array($this->prop['hosts']))
            $this->prop['hosts'] = array($this->prop['hosts']);
        if (empty($this->prop['ldap_version']))
            $this->prop['ldap_version'] = 3;
        // 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 ($this->prop['hosts'] as $host) {
            $host     = rcube_utils::idn_to_ascii(rcube_utils::parse_host($host));
            $hostname = $host.($this->prop['port'] ? ':'.$this->prop['port'] : '');
            $this->_debug("C: Connect [$hostname] [{$this->prop['name']}]");
            if ($lc = @ldap_connect($host, $this->prop['port'])) {
                if ($this->prop['use_tls'] === true)
                    if (!ldap_start_tls($lc))
                        continue;
                $this->_debug("S: OK");
                ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
                $this->prop['host'] = $host;
                $this->conn = $lc;
                if (!empty($this->prop['network_timeout']))
                  ldap_set_option($lc, LDAP_OPT_NETWORK_TIMEOUT, $this->prop['network_timeout']);
                if (isset($this->prop['referrals']))
                    ldap_set_option($lc, LDAP_OPT_REFERRALS, $this->prop['referrals']);
            }
            else {
                $this->_debug("S: NOT OK");
            // skip host if connection failed
            if (!$this->ldap->connect($host)) {
                continue;
            }
@@ -256,7 +264,7 @@
            $this->base_dn        = $this->prop['base_dn'];
            $this->groups_base_dn = ($this->prop['groups']['base_dn']) ?
            $this->prop['groups']['base_dn'] : $this->base_dn;
                $this->prop['groups']['base_dn'] : $this->base_dn;
            // User specific access, generate the proper values to use.
            if ($this->prop['user_specific']) {
@@ -277,7 +285,16 @@
                if ($this->prop['search_base_dn'] && $this->prop['search_filter']) {
                    if (!empty($this->prop['search_bind_dn']) && !empty($this->prop['search_bind_pw'])) {
                        $this->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']);
                        if (!$this->ldap->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']))
                            continue;  // bind failed, try neyt host
                    }
                    $search_attribs = array('uid');
                    if ($search_bind_attrib = (array)$this->prop['search_bind_attrib']) {
                        foreach ($search_bind_attrib as $r => $attr) {
                            $search_attribs[] = $attr;
                            $replaces[$r] = '';
                        }
                    }
                    // Search for the dn to use to authenticate
@@ -286,18 +303,28 @@
                    $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}");
                    $res = @ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid'));
                    // TODO: use $this->ldap->search() here
                    $res = @ldap_search($this->ldap->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], $search_attribs);
                    if ($res) {
                        if (($entry = ldap_first_entry($this->conn, $res))
                            && ($bind_dn = ldap_get_dn($this->conn, $entry))
                        if (($entry = ldap_first_entry($this->ldap->conn, $res))
                            && ($bind_dn = ldap_get_dn($this->ldap->conn, $entry))
                        ) {
                            $this->_debug("S: search returned dn: $bind_dn");
                            $dn = ldap_explode_dn($bind_dn, 1);
                            $replaces['%dn'] = $dn[0];
                            // add more replacements from 'search_bind_attrib' config
                            if ($search_bind_attrib) {
                                $res = ldap_get_attributes($this->ldap->conn, $entry);
                                foreach ($search_bind_attrib as $r => $attr) {
                                    $sa_value = $res[$attr][0];
                                    $replaces[$r] = $sa_value;
                                }
                            }
                        }
                    }
                    else {
                        $this->_debug("S: ".ldap_error($this->conn));
                        $this->_debug("S: ".ldap_error($this->ldap->conn));
                    }
                    // DN not found
@@ -319,6 +346,23 @@
                $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($this->prop['filter']))
                    $this->prop['filter'] = strtr($this->prop['filter'], $replaces);
                if (!empty($this->prop['groups']['filter']))
                    $this->prop['groups']['filter'] = strtr($this->prop['groups']['filter'], $replaces);
                if (!empty($this->prop['groups']['member_filter']))
                    $this->prop['groups']['member_filter'] = strtr($this->prop['groups']['member_filter'], $replaces);
                if (!empty($this->prop['group_filters'])) {
                    foreach ($this->prop['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;
                }
@@ -329,13 +373,13 @@
            }
            else {
                if (!empty($bind_dn)) {
                    $this->ready = $this->bind($bind_dn, $bind_pass);
                    $this->ready = $this->ldap->bind($bind_dn, $bind_pass);
                }
                else if (!empty($this->prop['auth_cid'])) {
                    $this->ready = $this->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
                    $this->ready = $this->ldap->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
                }
                else {
                    $this->ready = $this->sasl_bind($bind_user, $bind_pass);
                    $this->ready = $this->ldap->sasl_bind($bind_user, $bind_pass);
                }
            }
@@ -346,10 +390,10 @@
        }  // end foreach hosts
        if (!is_resource($this->conn)) {
        if (!is_resource($this->ldap->conn)) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Could not connect to any LDAP server, last tried $hostname"), true);
                'message' => "Could not connect to any LDAP server, last tried $host"), true);
            return false;
        }
@@ -359,100 +403,12 @@
    /**
     * Bind connection with (SASL-) user and password
     *
     * @param string $authc Authentication user
     * @param string $pass  Bind password
     * @param string $authz Autorization user
     *
     * @return boolean True on success, False on error
     */
    public function sasl_bind($authc, $pass, $authz=null)
    {
        if (!$this->conn) {
            return false;
        }
        if (!function_exists('ldap_sasl_bind')) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Unable to bind: ldap_sasl_bind() not exists"),
                true, true);
        }
        if (!empty($authz)) {
            $authz = 'u:' . $authz;
        }
        if (!empty($this->prop['auth_method'])) {
            $method = $this->prop['auth_method'];
        }
        else {
            $method = 'DIGEST-MD5';
        }
        $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]");
        if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "Bind failed for authcid=$authc ".ldap_error($this->conn)),
            true);
        return false;
    }
    /**
     * Bind connection with DN and password
     *
     * @param string Bind DN
     * @param string Bind password
     *
     * @return boolean True on success, False on error
     */
    public function bind($dn, $pass)
    {
        if (!$this->conn) {
            return false;
        }
        $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
        if (@ldap_bind($this->conn, $dn, $pass)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
            true);
        return false;
    }
    /**
     * Close connection to LDAP server
     */
    function close()
    {
        if ($this->conn)
        {
            $this->_debug("C: Close");
            ldap_unbind($this->conn);
            $this->conn = null;
        if ($this->ldap) {
            $this->ldap->close();
        }
    }
@@ -465,6 +421,29 @@
    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);
    }
@@ -524,16 +503,14 @@
     */
    function list_records($cols=null, $subset=0)
    {
        if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id)
        {
        if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id) {
            $this->result = new rcube_result_set(0);
            $this->result->searchonly = true;
            return $this->result;
        }
        // fetch group members recursively
        if ($this->group_id && $this->group_data['dn'])
        {
        if ($this->group_id && $this->group_data['dn']) {
            $entries = $this->list_group_members($this->group_data['dn']);
            // make list of entries unique and sort it
@@ -547,34 +524,34 @@
            $entries['count'] = count($entries);
            $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
        }
        else
        {
            // add general filter to query
            if (!empty($this->prop['filter']) && empty($this->filter))
                $this->set_search_set($this->prop['filter']);
        else {
            $prop = $this->group_id ? $this->group_data : $this->prop;
            // use global search filter
            if (!empty($this->filter))
                $prop['filter'] = $this->filter;
            // exec LDAP search if no result resource is stored
            if ($this->conn && !$this->ldap_result)
                $this->_exec_search();
            if ($this->ready && !$this->ldap_result)
                $this->ldap_result = $this->ldap->search($prop['base_dn'], $prop['filter'], $prop['scope'], $this->prop['attributes'], $prop);
            // count contacts for this user
            $this->result = $this->count();
            // we have a search result resource
            if ($this->ldap_result && $this->result->count > 0)
            {
            if ($this->ldap_result && $this->result->count > 0) {
                // sorting still on the ldap server
                if ($this->sort_col && $this->prop['scope'] !== 'base' && !$this->vlv_active)
                    ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
                if ($this->sort_col && $prop['scope'] !== 'base' && !$this->ldap->vlv_active)
                    $this->ldap_result->sort($this->sort_col);
                // get all entries from the ldap server
                $entries = ldap_get_entries($this->conn, $this->ldap_result);
                $entries = $this->ldap_result->entries();
            }
        }  // end else
        // start and end of the page
        $start_row = $this->vlv_active ? 0 : $this->result->first;
        $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;
@@ -589,9 +566,10 @@
    /**
     * Get all members of the given group
     *
     * @param string Group DN
     * @param array  Group entries (if called recursively)
     * @return array Accumulated group members
     * @param string  Group DN
     * @param boolean Count only
     * @param array   Group entries (if called recursively)
     * @return array  Accumulated group members
     */
    function list_group_members($dn, $count = false, $entries = null)
    {
@@ -599,38 +577,27 @@
        // fetch group object
        if (empty($entries)) {
            $result = @ldap_read($this->conn, $dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL'));
            if ($result === false)
            {
                $this->_debug("S: ".ldap_error($this->conn));
            $this->_debug("C: Read Group [dn: $dn]");
            $entries = $this->ldap->read_entries($dn, '(objectClass=*)', array_merge(array('dn','objectClass','memberURL'), array_values($this->prop['groups']['class_member_attr'])));
            if ($entries === false) {
                return $group_members;
            }
            $entries = @ldap_get_entries($this->conn, $result);
        }
        for ($i=0; $i < $entries['count']; $i++)
        {
        $class_member_map = $this->prop['groups']['class_member_attr'];
        for ($i=0; $i < $entries['count']; $i++) {
            $entry = $entries[$i];
            if (empty($entry['objectclass']))
                continue;
            foreach ((array)$entry['objectclass'] as $objectclass)
            {
                switch (strtolower($objectclass)) {
                    case "group":
                    case "groupofnames":
                    case "kolabgroupofnames":
                        $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'member', $count));
                        break;
                    case "groupofuniquenames":
                    case "kolabgroupofuniquenames":
                        $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'uniquemember', $count));
                        break;
                    case "groupofurls":
                        $group_members = array_merge($group_members, $this->_list_group_memberurl($dn, $entry, $count));
                        break;
            foreach ((array)$entry['objectclass'] as $objectclass) {
                if ($member_attr = $class_member_map[strtolower($objectclass)]) {
                    $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, $member_attr, $count));
                }
                else if (!empty($entry['memberurl'])) {
                    $group_members = array_merge($group_members, $this->_list_group_memberurl($dn, $entry, $count));
                }
            }
@@ -647,6 +614,7 @@
     * @param string Group DN
     * @param array  Group entry
     * @param string Member attribute to use
     * @param boolean Count only
     * @return array Accumulated group members
     */
    private function _list_group_members($dn, $entry, $attr, $count)
@@ -658,24 +626,18 @@
            return $group_members;
        // read these attributes for all members
        $attrib = $count ? array('dn') : array_values($this->fieldmap);
        $attrib[] = 'objectClass';
        $attrib[] = 'member';
        $attrib[] = 'uniqueMember';
        $attrib = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
        $attrib = array_merge($attrib, array_values($this->prop['groups']['class_member_attr']));
        $attrib[] = 'memberURL';
        for ($i=0; $i < $entry[$attr]['count']; $i++)
        {
        $filter = $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;
            $result = @ldap_read($this->conn, $entry[$attr][$i], '(objectclass=*)',
                $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']);
            $members = @ldap_get_entries($this->conn, $result);
            if ($members == false)
            {
                $this->_debug("S: ".ldap_error($this->conn));
            $members = $this->ldap->read_entries($entry[$attr][$i], $filter, $attrib);
            if ($members == false) {
                $members = array();
            }
@@ -701,34 +663,22 @@
    {
        $group_members = array();
        for ($i=0; $i < $entry['memberurl']['count']; $i++)
        {
        for ($i=0; $i < $entry['memberurl']['count']; $i++) {
            // extract components from url
            if (!preg_match('!ldap:///([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m))
                continue;
            // add search filter if any
            $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
            $func = $m[2] == 'sub' ? 'ldap_search' : ($m[2] == 'base' ? 'ldap_read' : 'ldap_list');
            $attrib = $count ? array('dn') : array_values($this->fieldmap);
            if ($result = @$func($this->conn, $m[1], $filter,
                $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
            ) {
                $this->_debug("S: ".ldap_count_entries($this->conn, $result)." record(s) for ".$m[1]);
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
                return $group_members;
            }
            $entries = @ldap_get_entries($this->conn, $result);
            for ($j = 0; $j < $entries['count']; $j++)
            {
                if ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count))
                    $group_members = array_merge($group_members, $nested_group_members);
                else
                    $group_members[] = $entries[$j];
            $attrs = $count ? array('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 (self::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];
                }
            }
        }
@@ -764,14 +714,11 @@
        $mode = intval($mode);
        // special treatment for ID-based search
        if ($fields == 'ID' || $fields == $this->primary_key)
        {
        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))
                {
            foreach ($ids as $id) {
                if ($rec = $this->get_record($id, true)) {
                    $result->add($rec);
                    $result->count++;
                }
@@ -783,34 +730,20 @@
        $rcube = rcube::get_instance();
        $list_fields = $rcube->config->get('contactlist_fields');
        if ($this->prop['vlv_search'] && $this->conn && join(',', (array)$fields) == join(',', $list_fields))
        {
            // add general filter to query
            if (!empty($this->prop['filter']) && empty($this->filter))
                $this->set_search_set($this->prop['filter']);
            // set VLV controls with encoded search string
            $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size, $value);
            $function = $this->_scope2func($this->prop['scope']);
            $this->ldap_result = @$function($this->conn, $this->base_dn, $this->filter ? $this->filter : '(objectclass=*)',
                array_values($this->fieldmap), 0, $this->page_size, (int)$this->prop['timelimit']);
        if ($this->prop['vlv_search'] && $this->ready && join(',', (array)$fields) == join(',', $list_fields)) {
            $this->result = new rcube_result_set(0);
            if (!$this->ldap_result) {
                $this->_debug("S: ".ldap_error($this->conn));
            $search_suffix = $this->prop['fuzzy_search'] && $mode != 1 ? '*' : '';
            $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'],
                array('search' => $value . $search_suffix /*, 'sort' => $this->prop['sort'] */));
            if ($ldap_data === false) {
                return $this->result;
            }
            $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
            // get all entries of this page and post-filter those that really match the query
            $search  = mb_strtolower($value);
            $entries = ldap_get_entries($this->conn, $this->ldap_result);
            for ($i = 0; $i < $entries['count']; $i++) {
                $rec = $this->_ldap2result($entries[$i]);
            $search = mb_strtolower($value);
            foreach ($ldap_data as $i => $entry) {
                $rec = $this->_ldap2result($entry);
                foreach ($fields as $f) {
                    foreach ((array)$rec[$f] as $val) {
                        if ($this->compare_search_value($f, $val, $search, $mode)) {
@@ -836,31 +769,27 @@
            }
        }
        if ($fields == '*')
        {
        if ($fields == '*') {
            // search_fields are required for fulltext search
            if (empty($this->prop['search_fields']))
            {
            if (empty($this->prop['search_fields'])) {
                $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
                $this->result = new rcube_result_set();
                return $this->result;
            }
            if (is_array($this->prop['search_fields']))
            {
            if (is_array($this->prop['search_fields'])) {
                foreach ($this->prop['search_fields'] as $field) {
                    $filter .= "($field=$wp" . $this->_quote_string($value) . "$ws)";
                    $filter .= "($field=$wp" . rcube_ldap_generic::quote_string($value) . "$ws)";
                }
            }
        }
        else
        {
        else {
            foreach ((array)$fields as $idx => $field) {
                $val = is_array($value) ? $value[$idx] : $value;
                if ($attrs = $this->_map_field($field)) {
                    if (count($attrs) > 1)
                        $filter .= '(|';
                    foreach ($attrs as $f)
                        $filter .= "($f=$wp" . $this->_quote_string($val) . "$ws)";
                        $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)";
                    if (count($attrs) > 1)
                        $filter .= ')';
                }
@@ -895,7 +824,6 @@
        // set filter string and execute search
        $this->set_search_set($filter);
        $this->_exec_search();
        if ($select)
            $this->list_records();
@@ -914,20 +842,21 @@
    function count()
    {
        $count = 0;
        if ($this->conn && $this->ldap_result) {
            $count = $this->vlv_active ? $this->vlv_count : ldap_count_entries($this->conn, $this->ldap_result);
        if ($this->ldap_result) {
            $count = $this->ldap_result->count();
        }
        else if ($this->group_id && $this->group_data['dn']) {
            $count = count($this->list_group_members($this->group_data['dn'], true));
        }
        else if ($this->conn) {
            // We have a connection but no result set, attempt to get one.
            if (empty($this->filter)) {
                // The filter is not set, set it.
                $this->filter = $this->prop['filter'];
        // We have a connection but no result set, attempt to get one.
        else if ($this->ready) {
            $prop = $this->group_id ? $this->group_data : $this->prop;
            if (!empty($this->filter)) {  // Use global search filter
                $prop['filter'] = $this->filter;
            }
            $count = (int) $this->_exec_search(true);
            $count = $this->ldap->search($prop['base_dn'], $prop['filter'], $prop['scope'], array('dn'), $prop, true);
        }
        return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
@@ -957,28 +886,16 @@
    {
        $res = $this->result = null;
        if ($this->conn && $dn)
        {
        if ($this->ready && $dn) {
            $dn = self::dn_decode($dn);
            $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
            if ($ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap))) {
                $this->_debug("S: OK");
                $entry = ldap_first_entry($this->conn, $ldap_result);
                if ($entry && ($rec = ldap_get_attributes($this->conn, $entry))) {
                    $rec = array_change_key_case($rec, CASE_LOWER);
                }
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            if ($rec = $this->ldap->get_entry($dn)) {
                $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($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) {
                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);
@@ -990,7 +907,7 @@
                // Add in the dn for the entry.
                $rec['dn'] = $dn;
                $res = $this->_ldap2result($rec);
                $this->result = new rcube_result_set();
                $this->result = new rcube_result_set(1);
                $this->result->add($res);
            }
        }
@@ -1104,7 +1021,7 @@
        }
        // Build the new entries DN.
        $dn = $this->prop['LDAP_rdn'].'='.$this->_quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_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 = array();
@@ -1117,19 +1034,19 @@
            }
        }
        if (!$this->ldap_add($dn, $newentry)) {
        if (!$this->ldap->add($dn, $newentry)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        foreach ($xfields as $xidx => $xf) {
            $xdn = $xidx.'='.$this->_quote_string($xf).','.$dn;
            $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn;
            $xf = array(
                $xidx => $xf,
                'objectClass' => (array) $this->prop['sub_fields'][$xidx],
            );
            $this->ldap_add($xdn, $xf);
            $this->ldap->add($xdn, $xf);
        }
        $dn = self::dn_encode($dn);
@@ -1235,7 +1152,7 @@
        // Update the entry as required.
        if (!empty($deletedata)) {
            // Delete the fields.
            if (!$this->ldap_mod_del($dn, $deletedata)) {
            if (!$this->ldap->mod_del($dn, $deletedata)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
@@ -1245,17 +1162,17 @@
            // Handle RDN change
            if ($replacedata[$this->prop['LDAP_rdn']]) {
                $newdn = $this->prop['LDAP_rdn'].'='
                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true)
                    .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
                    .','.$this->base_dn;
                if ($dn != $newdn) {
                    $newrdn = $this->prop['LDAP_rdn'].'='
                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true);
                    .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)) {
                if (!$this->ldap->mod_replace($dn, $replacedata)) {
                    $this->set_error(self::ERROR_SAVING, 'errorsaving');
                    return false;
                }
@@ -1271,8 +1188,8 @@
        // remove sub-entries
        if (!empty($subdeldata)) {
            foreach ($subdeldata as $fld => $val) {
                $subdn = $fld.'='.$this->_quote_string($val).','.$dn;
                if (!$this->ldap_delete($subdn)) {
                $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
                if (!$this->ldap->delete($subdn)) {
                    return false;
                }
            }
@@ -1280,7 +1197,7 @@
        if (!empty($newdata)) {
            // Add the fields.
            if (!$this->ldap_mod_add($dn, $newdata)) {
            if (!$this->ldap->mod_add($dn, $newdata)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
@@ -1288,7 +1205,7 @@
        // Handle RDN change
        if (!empty($newrdn)) {
            if (!$this->ldap_rename($dn, $newrdn, null, true)) {
            if (!$this->ldap->rename($dn, $newrdn, null, true)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
@@ -1299,7 +1216,7 @@
            // change the group membership of the contact
            if ($this->groups) {
                $group_ids = $this->get_record_groups($dn);
                foreach ($group_ids as $group_id)
                foreach ($group_ids as $group_id => $group_prop)
                {
                    $this->remove_from_group($group_id, $dn);
                    $this->add_to_group($group_id, $newdn);
@@ -1312,12 +1229,12 @@
        // add sub-entries
        if (!empty($subnewdata)) {
            foreach ($subnewdata as $fld => $val) {
                $subdn = $fld.'='.$this->_quote_string($val).','.$dn;
                $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
                $xf = array(
                    $fld => $val,
                    'objectClass' => (array) $this->prop['sub_fields'][$fld],
                );
                $this->ldap_add($subdn, $xf);
                $this->ldap->add($subdn, $xf);
            }
        }
@@ -1345,9 +1262,9 @@
            // Need to delete all sub-entries first
            if ($this->sub_filter) {
                if ($entries = $this->ldap_list($dn, $this->sub_filter)) {
                if ($entries = $this->ldap->list_entries($dn, $this->sub_filter)) {
                    foreach ($entries as $entry) {
                        if (!$this->ldap_delete($entry['dn'])) {
                        if (!$this->ldap->delete($entry['dn'])) {
                            $this->set_error(self::ERROR_SAVING, 'errorsaving');
                            return false;
                        }
@@ -1356,7 +1273,7 @@
            }
            // Delete the record.
            if (!$this->ldap_delete($dn)) {
            if (!$this->ldap->delete($dn)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
@@ -1365,7 +1282,7 @@
            if ($this->groups) {
                $dn = self::dn_encode($dn);
                $group_ids = $this->get_record_groups($dn);
                foreach ($group_ids as $group_id) {
                foreach ($group_ids as $group_id => $group_prop) {
                    $this->remove_from_group($group_id, $dn);
                }
            }
@@ -1380,8 +1297,8 @@
     */
    function delete_all()
    {
        //searching for contact entries
        $dn_list = $this->ldap_list($this->base_dn, $this->prop['filter'] ? $this->prop['filter'] : '(objectclass=*)');
        // searching for contact entries
        $dn_list = $this->ldap->list_entries($this->base_dn, $this->prop['filter'] ? $this->prop['filter'] : '(objectclass=*)');
        if (!empty($dn_list)) {
            foreach ($dn_list as $idx => $entry) {
@@ -1418,120 +1335,26 @@
        }
    }
    /**
     * Execute the LDAP search based on the stored credentials
     */
    private function _exec_search($count = false)
    {
        if ($this->ready)
        {
            $filter = $this->filter ? $this->filter : '(objectclass=*)';
            $function = $this->_scope2func($this->prop['scope'], $ns_function);
            $this->_debug("C: Search [$filter][dn: $this->base_dn]");
            // when using VLV, we get the total count by...
            if (!$count && $function != 'ldap_read' && $this->prop['vlv'] && !$this->group_id) {
                // ...either reading numSubOrdinates attribute
                if ($this->prop['numsub_filter'] && ($result_count = @$ns_function($this->conn, $this->base_dn, $this->prop['numsub_filter'], array('numSubOrdinates'), 0, 0, 0))) {
                    $counts = ldap_get_entries($this->conn, $result_count);
                    for ($this->vlv_count = $j = 0; $j < $counts['count']; $j++)
                        $this->vlv_count += $counts[$j]['numsubordinates'][0];
                    $this->_debug("D: total numsubordinates = " . $this->vlv_count);
                }
                else if (!function_exists('ldap_parse_virtuallist_control'))  // ...or by fetching all records dn and count them
                    $this->vlv_count = $this->_exec_search(true);
                $this->vlv_active = $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size);
            }
            // only fetch dn for count (should keep the payload low)
            $attrs = $count ? array('dn') : array_values($this->fieldmap);
            if ($this->ldap_result = @$function($this->conn, $this->base_dn, $filter,
                $attrs, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
            ) {
                // when running on a patched PHP we can use the extended functions to retrieve the total count from the LDAP search result
                if ($this->vlv_active && function_exists('ldap_parse_virtuallist_control')) {
                    if (ldap_parse_result($this->conn, $this->ldap_result,
                        $errcode, $matcheddn, $errmsg, $referrals, $serverctrls)
                        && $serverctrls // can be null e.g. in case of adm. limit error
                    ) {
                        ldap_parse_virtuallist_control($this->conn, $serverctrls,
                            $last_offset, $this->vlv_count, $vresult);
                        $this->_debug("S: VLV result: last_offset=$last_offset; content_count=$this->vlv_count");
                    }
                    else {
                        $this->_debug("S: ".($errmsg ? $errmsg : ldap_error($this->conn)));
                    }
                }
                $entries_count = ldap_count_entries($this->conn, $this->ldap_result);
                $this->_debug("S: $entries_count record(s)");
                return $count ? $entries_count : true;
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            }
        }
        return false;
    }
    /**
     * Choose the right PHP function according to scope property
     */
    private function _scope2func($scope, &$ns_function = null)
    {
        switch ($scope) {
          case 'sub':
            $function = $ns_function  = 'ldap_search';
            break;
          case 'base':
            $function = $ns_function = 'ldap_read';
            break;
          default:
            $function = 'ldap_list';
            $ns_function = 'ldap_read';
            break;
        }
        return $function;
    }
    /**
     * Set server controls for Virtual List View (paginated listing)
     */
    private function _vlv_set_controls($prop, $list_page, $page_size, $search = null)
    {
        $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473",  'value' => $this->_sort_ber_encode((array)$prop['sort']));
        $vlv_ctrl  = array('oid' => "2.16.840.1.113730.3.4.9", 'value' => $this->_vlv_ber_encode(($offset = ($list_page-1) * $page_size + 1), $page_size, $search), 'iscritical' => true);
        $sort = (array)$prop['sort'];
        $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ($sort[0]);"
            . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset/$page_size)");
        if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) {
            $this->_debug("S: ".ldap_error($this->conn));
            $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported');
            return false;
        }
        return true;
    }
    /**
     * Converts LDAP entry into an array
     */
    private function _ldap2result($rec)
    {
        $out = array();
        $out = array('_type' => 'person');
        $fieldmap = $this->fieldmap;
        if ($rec['dn'])
            $out[$this->primary_key] = self::dn_encode($rec['dn']);
        foreach ($this->fieldmap as $rf => $lf)
        // determine record type
        if (self::is_group_entry($rec)) {
            $out['_type'] = 'group';
            $out['readonly'] = true;
            $fieldmap['name'] = $this->group_data['name_attr'] ? $this->group_data['name_attr'] : $this->prop['groups']['name_attr'];
        }
        foreach ($fieldmap as $rf => $lf)
        {
            for ($i=0; $i < $rec[$lf]['count']; $i++) {
                if (!($value = $rec[$lf][$i]))
@@ -1593,8 +1416,10 @@
            if (is_array($colprop['serialized'])) {
               foreach ($colprop['serialized'] as $subtype => $delim) {
                  $key = $col.':'.$subtype;
                  foreach ((array)$save_cols[$key] as $i => $val)
                     $save_cols[$key][$i] = join($delim, array($val['street'], $val['locality'], $val['zipcode'], $val['country']));
                  foreach ((array)$save_cols[$key] as $i => $val) {
                     $values = array($val['street'], $val['locality'], $val['zipcode'], $val['country']);
                     $save_cols[$key][$i] = count(array_filter($values)) ? join($delim, $values) : null;
                 }
               }
            }
        }
@@ -1645,6 +1470,16 @@
        return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix;
    }
    /**
     * Determines whether the given LDAP entry is a group record
     */
    private static function is_group_entry($entry)
    {
        return array_intersect(
            array('group', 'groupofnames', 'kolabgroupofnames', 'groupofuniquenames','kolabgroupofuniquenames','groupofurls'),
            array_map('strtolower', (array)$entry['objectclass'])
        );
    }
    /**
     * Prints debug info to the log
@@ -1670,46 +1505,15 @@
    /**
     * Quotes attribute value string
     *
     * @param string $str Attribute value
     * @param bool   $dn  True if the attribute is a DN
     *
     * @return string Quoted string
     */
    private static function _quote_string($str, $dn=false)
    {
        // take firt entry if array given
        if (is_array($str))
            $str = reset($str);
        if ($dn)
            $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
                '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
        else
            $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
                '/'=>'\2f');
        return strtr($str, $replace);
    }
    /**
     * Setter for the current group
     * (empty, has to be re-implemented by extending class)
     */
    function set_group($group_id)
    {
        if ($group_id)
        {
            if (($group_cache = $this->cache->get('groups')) === null)
                $group_cache = $this->_fetch_groups();
        if ($group_id) {
            $this->group_id = $group_id;
            $this->group_data = $group_cache[$group_id];
            $this->group_data = $this->get_group_entry($group_id);
        }
        else
        {
        else {
            $this->group_id = 0;
            $this->group_data = null;
        }
@@ -1732,9 +1536,9 @@
            return array();
        // use cached list for searching
        $this->cache->expunge();
        if (!$search || ($group_cache = $this->cache->get('groups')) === null)
        if (!$this->cache || !empty($this->prop['group_filters']) || ($group_cache = $this->cache->get('groups')) === null) {
            $group_cache = $this->_fetch_groups();
        }
        $groups = array();
        if ($search) {
@@ -1755,6 +1559,23 @@
     */
    private function _fetch_groups($vlv_page = 0)
    {
        // special case: list groups from 'group_filters' config
        if (!empty($this->prop['group_filters'])) {
            $groups = array();
            // list regular groups configuration as special filter
            if (!empty($this->prop['groups']['filter'])) {
                $id = '__groups__';
                $groups[$id] = array('ID' => $id, 'name' => rcube_label('groups'), 'virtual' => true) + $this->prop['groups'];
            }
            foreach ($this->prop['group_filters'] as $id => $prop) {
                $groups[$id] = $prop + array('ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn);
            }
            return $groups;
        }
        $base_dn = $this->groups_base_dn;
        $filter = $this->prop['groups']['filter'];
        $name_attr = $this->prop['groups']['name_attr'];
@@ -1762,46 +1583,46 @@
        $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
        $sort_attr = $sort_attrs[0];
        $this->_debug("C: Search [$filter][dn: $base_dn]");
        $ldap = $this->ldap;
        // use vlv to list groups
        if ($this->prop['groups']['vlv']) {
            $page_size = 200;
            if (!$this->prop['groups']['sort'])
                $this->prop['groups']['sort'] = $sort_attrs;
            $vlv_active = $this->_vlv_set_controls($this->prop['groups'], $vlv_page+1, $page_size);
            $ldap = clone $this->ldap;
            $ldap->set_config($this->prop['groups']);
            $ldap->set_vlv_page($vlv_page+1, $page_size);
        }
        $function = $this->_scope2func($this->prop['groups']['scope'], $ns_function);
        $res = @$function($this->conn, $base_dn, $filter, array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr)));
        if ($res === false)
        {
            $this->_debug("S: ".ldap_error($this->conn));
        $attrs = array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr));
        $ldap_data = $ldap->search($base_dn, $filter, $this->prop['groups']['scope'], $attrs, $this->prop['groups']);
        if ($ldap_data === false) {
            return array();
        }
        $ldap_data = ldap_get_entries($this->conn, $res);
        $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
        $groups = array();
        $group_sortnames = array();
        $group_count = $ldap_data["count"];
        for ($i=0; $i < $group_count; $i++)
        {
            $group_name = is_array($ldap_data[$i][$name_attr]) ? $ldap_data[$i][$name_attr][0] : $ldap_data[$i][$name_attr];
            $group_id = self::dn_encode($group_name);
        $group_count = $ldap_data->count();
        foreach ($ldap_data as $entry) {
            if (!$entry['dn'])  // DN is mandatory
                $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']);
            $groups[$group_id]['ID'] = $group_id;
            $groups[$group_id]['dn'] = $ldap_data[$i]['dn'];
            $groups[$group_id]['dn'] = $entry['dn'];
            $groups[$group_id]['name'] = $group_name;
            $groups[$group_id]['member_attr'] = $this->get_group_member_attr($ldap_data[$i]['objectclass']);
            $groups[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
            // list email attributes of a group
            for ($j=0; $ldap_data[$i][$email_attr] && $j < $ldap_data[$i][$email_attr]['count']; $j++) {
                if (strpos($ldap_data[$i][$email_attr][$j], '@') > 0)
                    $groups[$group_id]['email'][] = $ldap_data[$i][$email_attr][$j];
            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($ldap_data[$i][$sort_attr][0]);
            $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]);
        }
        // recursive call can exit here
@@ -1809,8 +1630,7 @@
            return $groups;
        // call recursively until we have fetched all groups
        while ($vlv_active && $group_count == $page_size)
        {
        while ($this->prop['groups']['vlv'] && $group_count == $page_size) {
            $next_page = $this->_fetch_groups(++$vlv_page);
            $groups = array_merge($groups, $next_page);
            $group_count = count($next_page);
@@ -1821,9 +1641,46 @@
            array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
        // cache this
        $this->cache->set('groups', $groups);
        if ($this->cache) {
            $this->cache->set('groups', $groups);
        }
        return $groups;
    }
    /**
     * Fetch a group entry from LDAP and save in local cache
     */
    private function get_group_entry($group_id)
    {
        if (!$this->cache || ($group_cache = $this->cache->get('groups')) === null) {
            $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);
            $this->_debug("C: Read Group [dn: $dn]");
            if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']))) {
                $entry = $list[0];
                $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
                $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($entry['objectclass']);
            }
            else {
                $group_cache[$group_id] = false;
            }
            if ($this->cache) {
                $this->cache->set('groups', $group_cache);
            }
        }
        return $group_cache[$group_id];
    }
    /**
@@ -1834,10 +1691,7 @@
     */
    function get_group($group_id)
    {
        if (($group_cache = $this->cache->get('groups')) === null)
            $group_cache = $this->_fetch_groups();
        $group_data = $group_cache[$group_id];
        $group_data = $this->get_group_entry($group_id);
        unset($group_data['dn'], $group_data['member_attr']);
        return $group_data;
@@ -1851,9 +1705,8 @@
     */
    function create_group($group_name)
    {
        $base_dn = $this->groups_base_dn;
        $new_dn = "cn=$group_name,$base_dn";
        $new_gid = self::dn_encode($group_name);
        $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'] ? $this->prop['groups']['name_attr'] : 'cn';
@@ -1863,12 +1716,14 @@
            $member_attr => '',
        );
        if (!$this->ldap_add($new_dn, $new_entry)) {
        if (!$this->ldap->add($new_dn, $new_entry)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $this->cache->remove('groups');
        if ($this->cache) {
            $this->cache->remove('groups');
        }
        return array('id' => $new_gid, 'name' => $group_name);
    }
@@ -1881,19 +1736,21 @@
     */
    function delete_group($group_id)
    {
        if (($group_cache = $this->cache->get('groups')) === null)
        if (!$this->cache || ($group_cache = $this->cache->get('groups')) === null) {
            $group_cache = $this->_fetch_groups();
        }
        $base_dn = $this->groups_base_dn;
        $group_name = $group_cache[$group_id]['name'];
        $del_dn = "cn=$group_name,$base_dn";
        $del_dn = $group_cache[$group_id]['dn'];
        if (!$this->ldap_delete($del_dn)) {
        if (!$this->ldap->delete($del_dn)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $this->cache->remove('groups');
        if ($this->cache) {
            unset($group_cache[$group_id]);
            $this->cache->set('groups', $group_cache);
        }
        return true;
    }
@@ -1908,21 +1765,22 @@
     */
    function rename_group($group_id, $new_name, &$new_gid)
    {
        if (($group_cache = $this->cache->get('groups')) === null)
        if (!$this->cache || ($group_cache = $this->cache->get('groups')) === null) {
            $group_cache = $this->_fetch_groups();
        }
        $base_dn = $this->groups_base_dn;
        $group_name = $group_cache[$group_id]['name'];
        $old_dn = "cn=$group_name,$base_dn";
        $new_rdn = "cn=$new_name";
        $new_gid = self::dn_encode($new_name);
        $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)) {
        if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $this->cache->remove('groups');
        if ($this->cache) {
            $this->cache->remove('groups');
        }
        return $new_name;
    }
@@ -1937,8 +1795,9 @@
     */
    function add_to_group($group_id, $contact_ids)
    {
        if (($group_cache = $this->cache->get('groups')) === null)
        if (!$this->cache || ($group_cache = $this->cache->get('groups')) === null) {
            $group_cache = $this->_fetch_groups();
        }
        if (!is_array($contact_ids))
            $contact_ids = explode(',', $contact_ids);
@@ -1946,7 +1805,7 @@
        $base_dn     = $this->groups_base_dn;
        $group_name  = $group_cache[$group_id]['name'];
        $member_attr = $group_cache[$group_id]['member_attr'];
        $group_dn    = "cn=$group_name,$base_dn";
        $group_dn    = $group_cache[$group_id]['dn'];
        $new_attrs   = array();
        foreach ($contact_ids as $id)
@@ -1957,7 +1816,9 @@
            return 0;
        }
        $this->cache->remove('groups');
        if ($this->cache) {
            $this->cache->remove('groups');
        }
        return count($new_attrs[$member_attr]);
    }
@@ -1972,8 +1833,9 @@
     */
    function remove_from_group($group_id, $contact_ids)
    {
        if (($group_cache = $this->cache->get('groups')) === null)
        if (!$this->cache || ($group_cache = $this->cache->get('groups')) === null) {
            $group_cache = $this->_fetch_groups();
        }
        if (!is_array($contact_ids))
            $contact_ids = explode(',', $contact_ids);
@@ -1981,7 +1843,7 @@
        $base_dn     = $this->groups_base_dn;
        $group_name  = $group_cache[$group_id]['name'];
        $member_attr = $group_cache[$group_id]['member_attr'];
        $group_dn    = "cn=$group_name,$base_dn";
        $group_dn    = $group_cache[$group_id]['dn'];
        $del_attrs   = array();
        foreach ($contact_ids as $id)
@@ -1992,7 +1854,9 @@
            return 0;
        }
        $this->cache->remove('groups');
        if ($this->cache) {
            $this->cache->remove('groups');
        }
        return count($del_attrs[$member_attr]);
    }
@@ -2019,23 +1883,18 @@
            $add_filter = "($member_attr=$contact_dn)";
        $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
        $this->_debug("C: Search [$filter][dn: $base_dn]");
        $res = @ldap_search($this->conn, $base_dn, $filter, array($name_attr));
        if ($res === false)
        {
            $this->_debug("S: ".ldap_error($this->conn));
        $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', array('dn', $name_attr));
        if ($res === false) {
            return array();
        }
        $ldap_data = ldap_get_entries($this->conn, $res);
        $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
        $groups = array();
        for ($i=0; $i<$ldap_data["count"]; $i++)
        {
            $group_name = $ldap_data[$i][$name_attr][0];
            $group_id = self::dn_encode($group_name);
            $groups[$group_id] = $group_id;
        foreach ($ldap_data as $entry) {
            if (!$entry['dn'])
                $entry['dn'] = $ldap_data->get_dn();
            $group_name = $entry[$name_attr][0];
            $group_id = self::dn_encode($entry['dn']);
            $groups[$group_id] = array('ID' => $group_id, 'name' => $group_name, 'dn' => $entry['dn']);
        }
        return $groups;
    }
@@ -2049,18 +1908,12 @@
            $object_classes = $this->prop['groups']['object_classes'];
        }
        if (!empty($object_classes)) {
            $class_member_map = $this->prop['groups']['class_member_attr'];
            foreach ((array)$object_classes as $oc) {
                switch (strtolower($oc)) {
                    case 'group':
                    case 'groupofnames':
                    case 'kolabgroupofnames':
                        $member_attr = 'member';
                        break;
                    case 'groupofuniquenames':
                    case 'kolabgroupofuniquenames':
                        $member_attr = 'uniqueMember';
                        break;
                $oc = strtolower($oc);
                if (!empty($class_member_map[$oc])) {
                    $member_attr = $class_member_map[$oc];
                    break;
                }
            }
        }
@@ -2076,135 +1929,6 @@
        return 'member';
    }
    /**
     * Generate BER encoded string for Virtual List View option
     *
     * @param integer List offset (first record)
     * @param integer Records per page
     * @return string BER encoded option value
     */
    private function _vlv_ber_encode($offset, $rpp, $search = '')
    {
        # this string is ber-encoded, php will prefix this value with:
        # 04 (octet string) and 10 (length of 16 bytes)
        # the code behind this string is broken down as follows:
        # 30 = ber sequence with a length of 0e (14) bytes following
        # 02 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0)
        # 02 = type integer (in two's complement form) with 2 bytes following (afterCount):  01 18 (ie 25-1=24)
        # a0 = type context-specific/constructed with a length of 06 (6) bytes following
        # 02 = type integer with 2 bytes following (offset): 01 01 (ie 1)
        # 02 = type integer with 2 bytes following (contentCount):  01 00
        # whith a search string present:
        # 81 = type context-specific/constructed with a length of 04 (4) bytes following (the length will change here)
        # 81 indicates a user string is present where as a a0 indicates just a offset search
        # 81 = type context-specific/constructed with a length of 06 (6) bytes following
        # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the
        # encoding of integer values (note: these values are in
        # two-complement form so since offset will never be negative bit 8 of the
        # leftmost octet should never by set to 1):
        # 8.3.2: If the contents octets of an integer value encoding consist
        # of more than one octet, then the bits of the first octet (rightmost) and bit 8
        # of the second (to the left of first octet) octet:
        # a) shall not all be ones; and
        # b) shall not all be zero
        if ($search)
        {
            $search = preg_replace('/[^-[:alpha:] ,.()0-9]+/', '', $search);
            $ber_val = self::_string2hex($search);
            $str = self::_ber_addseq($ber_val, '81');
        }
        else
        {
            # construct the string from right to left
            $str = "020100"; # contentCount
            $ber_val = self::_ber_encode_int($offset);  // returns encoded integer value in hex format
            // calculate octet length of $ber_val
            $str = self::_ber_addseq($ber_val, '02') . $str;
            // now compute length over $str
            $str = self::_ber_addseq($str, 'a0');
        }
        // now tack on records per page
        $str = "020100" . self::_ber_addseq(self::_ber_encode_int($rpp-1), '02') . $str;
        // now tack on sequence identifier and length
        $str = self::_ber_addseq($str, '30');
        return pack('H'.strlen($str), $str);
    }
    /**
     * create ber encoding for sort control
     *
     * @param array List of cols to sort by
     * @return string BER encoded option value
     */
    private function _sort_ber_encode($sortcols)
    {
        $str = '';
        foreach (array_reverse((array)$sortcols) as $col) {
            $ber_val = self::_string2hex($col);
            # 30 = ber sequence with a length of octet value
            # 04 = octet string with a length of the ascii value
            $oct = self::_ber_addseq($ber_val, '04');
            $str = self::_ber_addseq($oct, '30') . $str;
        }
        // now tack on sequence identifier and length
        $str = self::_ber_addseq($str, '30');
        return pack('H'.strlen($str), $str);
    }
    /**
     * Add BER sequence with correct length and the given identifier
     */
    private static function _ber_addseq($str, $identifier)
    {
        $len = dechex(strlen($str)/2);
        if (strlen($len) % 2 != 0)
            $len = '0'.$len;
        return $identifier . $len . $str;
    }
    /**
     * Returns BER encoded integer value in hex format
     */
    private static function _ber_encode_int($offset)
    {
        $val = dechex($offset);
        $prefix = '';
        // check if bit 8 of high byte is 1
        if (preg_match('/^[89abcdef]/', $val))
            $prefix = '00';
        if (strlen($val)%2 != 0)
            $prefix .= '0';
        return $prefix . $val;
    }
    /**
     * Returns ascii string encoded in hex
     */
    private static function _string2hex($str)
    {
        $hex = '';
        for ($i=0; $i < strlen($str); $i++)
            $hex .= dechex(ord($str[$i]));
        return $hex;
    }
    /**
     * HTML-safe DN string encoding
@@ -2231,132 +1955,6 @@
    {
        $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
        return base64_decode($str);
    }
    /**
     * Wrapper for ldap_add()
     */
    protected function ldap_add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        $res = ldap_add($this->conn, $dn, $entry);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_delete()
     */
    protected function ldap_delete($dn)
    {
        $this->_debug("C: Delete [dn: $dn]");
        $res = ldap_delete($this->conn, $dn);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_replace()
     */
    protected function ldap_mod_replace($dn, $entry)
    {
        $this->_debug("C: Replace [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_replace($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_add()
     */
    protected function ldap_mod_add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_add($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_del()
     */
    protected function ldap_mod_del($dn, $entry)
    {
        $this->_debug("C: Delete [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_del($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_rename()
     */
    protected function ldap_rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true)
    {
        $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
        if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_list()
     */
    protected function ldap_list($dn, $filter, $attrs = array(''))
    {
        $list = array();
        $this->_debug("C: List [dn: $dn] [{$filter}]");
        if ($result = ldap_list($this->conn, $dn, $filter, $attrs)) {
            $list = ldap_get_entries($this->conn, $result);
            if ($list === false) {
                $this->_debug("S: ".ldap_error($this->conn));
                return array();
            }
            $count = $list['count'];
            unset($list['count']);
            $this->_debug("S: $count record(s)");
        }
        else {
            $this->_debug("S: ".ldap_error($this->conn));
        }
        return $list;
    }
}
program/lib/Roundcube/rcube_ldap_generic.php
New file
@@ -0,0 +1,1059 @@
<?php
/*
 +-----------------------------------------------------------------------+
 | Roundcube/rcube_ldap_generic.php                                      |
 |                                                                       |
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2006-2013, The Roundcube Dev Team                       |
 | Copyright (C) 2012-2013, 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>                   |
 +-----------------------------------------------------------------------+
*/
/*
  LDAP connection properties
  --------------------------
  $prop = array(
      'host'            => '<ldap-server-address>',
      // or
      'hosts'           => array('directory.verisign.com'),
      'port'            => 389,
      'use_tls'            => true|false,
      'ldap_version'    => 3,             // using LDAPv3
      'auth_method'     => '',            // SASL authentication method (for proxy auth), e.g. DIGEST-MD5
      'attributes'      => array('dn'),   // List of attributes to read from the server
      'vlv'             => false,         // Enable Virtual List View to more efficiently fetch paginated data (if server supports it)
      'config_root_dn'  => 'cn=config',   // Root DN to read config (e.g. vlv indexes) from
      'numsub_filter'   => '(objectClass=organizationalUnit)',   // with VLV, we also use numSubOrdinates to query the total number of records. Set this filter to get all numSubOrdinates attributes for counting
      'sizelimit'       => '0',           // Enables you to limit the count of entries fetched. Setting this to 0 means no limit.
      'timelimit'       => '0',           // Sets the number of seconds how long is spend on the search. Setting this to 0 means no limit.
      'network_timeout' => 10,            // The timeout (in seconds) for connect + bind arrempts. This is only supported in PHP >= 5.3.0 with OpenLDAP 2.x
      'referrals'       => true|false,    // Sets the LDAP_OPT_REFERRALS option. Mostly used in multi-domain Active Directory setups
  );
*/
/**
 * Model class to access an LDAP directories
 *
 * @package    Framework
 * @subpackage LDAP
 */
class rcube_ldap_generic
{
    const UPDATE_MOD_ADD = 1;
    const UPDATE_MOD_DELETE = 2;
    const UPDATE_MOD_REPLACE = 4;
    const UPDATE_MOD_FULL = 7;
    public $conn;
    public $vlv_active = false;
    /** private properties */
    protected $cache = null;
    protected $config = array();
    protected $attributes = array('dn');
    protected $entries = null;
    protected $result = null;
    protected $debug = false;
    protected $list_page = 1;
    protected $page_size = 10;
    protected $vlv_config = null;
    /**
    * Object constructor
    *
    * @param array   $p       LDAP connection properties
    * @param boolean $debug   Enables debug mode
    */
    function __construct($p, $debug = false)
    {
        $this->config = $p;
        if (is_array($p['attributes']))
            $this->attributes = $p['attributes'];
        if (!is_array($p['hosts']) && !empty($p['host']))
            $this->config['hosts'] = array($p['host']);
        $this->debug = $debug;
    }
    /**
     * Activate/deactivate debug mode
     *
     * @param boolean $dbg True if LDAP commands should be logged
     */
    public function set_debug($dbg = true)
    {
        $this->debug = $dbg;
    }
    /**
     * Set connection options
     *
     * @param mixed $opt Option name as string or hash array with multiple options
     * @param mixed $val Option value
     */
    public function set_config($opt, $val = null)
    {
        if (is_array($opt))
            $this->config = array_merge($this->config, $opt);
        else
            $this->config[$opt] = $value;
    }
    /**
     * Enable caching by passing an instance of rcube_cache to be used by this object
     *
     * @param object rcube_cache Instance or False to disable caching
     */
    public function set_cache($cache_engine)
    {
        $this->cache = $cache_engine;
    }
    /**
     * Set properties for VLV-based paging
     *
     * @param  number $page  Page number to list (starting at 1)
     * @param  number $size  Number of entries to display on one page
     */
    public function set_vlv_page($page, $size = 10)
    {
        $this->list_page = $page;
        $this->page_size = $size;
    }
    /**
    * Establish a connection to the LDAP server
    */
    public function connect($host = null)
    {
        if (!function_exists('ldap_connect')) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "No ldap support in this installation of PHP"),
                true);
            return false;
        }
        if (is_resource($this->conn) && $this->config['host'] == $host)
            return true;
        if (empty($this->config['ldap_version']))
            $this->config['ldap_version'] = 3;
        // iterate over hosts if none specified
        if (!$host) {
            if (!is_array($this->config['hosts']))
                $this->config['hosts'] = array($this->config['hosts']);
            foreach ($this->config['hosts'] as $host) {
                if ($this->connect($host)) {
                    return true;
                }
            }
            return false;
        }
        // open connection to the given $host
        $host     = rcube_utils::idn_to_ascii(rcube_utils::parse_host($host));
        $hostname = $host . ($this->config['port'] ? ':'.$this->config['port'] : '');
        $this->_debug("C: Connect [$hostname] [{$this->config['name']}]");
        if ($lc = @ldap_connect($host, $this->config['port'])) {
            if ($this->config['use_tls'] === true)
                if (!ldap_start_tls($lc))
                    continue;
            $this->_debug("S: OK");
            ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->config['ldap_version']);
            $this->config['host'] = $host;
            $this->conn = $lc;
            if (!empty($this->config['network_timeout']))
              ldap_set_option($lc, LDAP_OPT_NETWORK_TIMEOUT, $this->config['network_timeout']);
            if (isset($this->config['referrals']))
                ldap_set_option($lc, LDAP_OPT_REFERRALS, $this->config['referrals']);
        }
        else {
            $this->_debug("S: NOT OK");
        }
        if (!is_resource($this->conn)) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Could not connect to any LDAP server, last tried $hostname"),
                true);
            return false;
        }
        return true;
    }
    /**
     * Bind connection with (SASL-) user and password
     *
     * @param string $authc Authentication user
     * @param string $pass  Bind password
     * @param string $authz Autorization user
     *
     * @return boolean True on success, False on error
     */
    public function sasl_bind($authc, $pass, $authz=null)
    {
        if (!$this->conn) {
            return false;
        }
        if (!function_exists('ldap_sasl_bind')) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Unable to bind: ldap_sasl_bind() not exists"),
                true);
            return false;
        }
        if (!empty($authz)) {
            $authz = 'u:' . $authz;
        }
        if (!empty($this->config['auth_method'])) {
            $method = $this->config['auth_method'];
        }
        else {
            $method = 'DIGEST-MD5';
        }
        $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]");
        if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "SASL Bind failed for authcid=$authc ".ldap_error($this->conn)),
            true);
        return false;
    }
    /**
     * Bind connection with DN and password
     *
     * @param string $dn   Bind DN
     * @param string $pass Bind password
     *
     * @return boolean True on success, False on error
     */
    public function bind($dn, $pass)
    {
        if (!$this->conn) {
            return false;
        }
        $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
        if (@ldap_bind($this->conn, $dn, $pass)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
            true);
        return false;
    }
    /**
     * Close connection to LDAP server
     */
    public function close()
    {
        if ($this->conn) {
            $this->_debug("C: Close");
            ldap_unbind($this->conn);
            $this->conn = null;
        }
    }
    /**
     * Return the last result set
     *
     * @return object rcube_ldap_result Result object
     */
    function get_result()
    {
        return $this->result;
    }
    /**
     * Get a specific LDAP entry, identified by its DN
     *
     * @param string $dn Record identifier
     * @return array     Hash array
     */
    function get_entry($dn)
    {
        $rec = null;
        if ($this->conn && $dn) {
            $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
            if ($ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', $this->attributes)) {
                $this->_debug("S: OK");
                if ($entry = ldap_first_entry($this->conn, $ldap_result)) {
                    $rec = ldap_get_attributes($this->conn, $entry);
                }
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            }
            if (!empty($rec)) {
                $rec['dn'] = $dn; // Add in the dn for the entry.
            }
        }
        return $rec;
    }
    /**
     * Execute the LDAP search based on the stored credentials
     *
     * @param string $base_dn  The base DN to query
     * @param string $filter   The LDAP filter for search
     * @param string $scope    The LDAP scope (list|sub|base)
     * @param array  $attrs    List of entry attributes to read
     * @param array  $prop     Hash array with query configuration properties:
     *   - sort: array of sort attributes (has to be in sync with the VLV index)
     *   - search: search string used for VLV controls
     * @param boolean $count_only Set to true if only entry count is requested
     *
     * @return mixed  rcube_ldap_result object or number of entries (if count_only=true) or false on error
     */
    public function search($base_dn, $filter = '', $scope = 'sub', $attrs = array('dn'), $prop = array(), $count_only = false)
    {
        if ($this->conn) {
            if (empty($filter))
                $filter = $filter = '(objectclass=*)';
            $this->_debug("C: Search [$filter][dn: $base_dn]");
            $function = self::scope2func($scope, $ns_function);
            // find available VLV index for this query
            if (!$count_only && ($vlv_sort = $this->_find_vlv($base_dn, $filter, $scope, $prop['sort']))) {
                // when using VLV, we get the total count by...
                // ...either reading numSubOrdinates attribute
                if ($this->config['numsub_filter'] && ($result_count = @$ns_function($this->conn, $base_dn, $this->config['numsub_filter'], array('numSubOrdinates'), 0, 0, 0))) {
                    $counts = ldap_get_entries($this->conn, $result_count);
                    for ($vlv_count = $j = 0; $j < $counts['count']; $j++)
                        $vlv_count += $counts[$j]['numsubordinates'][0];
                    $this->_debug("D: total numsubordinates = " . $vlv_count);
                }
                // ...or by fetching all records dn and count them
                else if (!function_exists('ldap_parse_virtuallist_control')) {
                    $vlv_count = $this->search($base_dn, $filter, $scope, array('dn'), $prop, true);
                }
                $this->vlv_active = $this->_vlv_set_controls($vlv_sort, $this->list_page, $this->page_size, $prop['search']);
            }
            else {
                $this->vlv_active = false;
            }
            // only fetch dn for count (should keep the payload low)
            if ($ldap_result = @$function($this->conn, $base_dn, $filter,
                $attrs, 0, (int)$this->config['sizelimit'], (int)$this->config['timelimit'])
            ) {
                // when running on a patched PHP we can use the extended functions to retrieve the total count from the LDAP search result
                if ($this->vlv_active && function_exists('ldap_parse_virtuallist_control')) {
                    if (ldap_parse_result($this->conn, $ldap_result, $errcode, $matcheddn, $errmsg, $referrals, $serverctrls)) {
                        ldap_parse_virtuallist_control($this->conn, $serverctrls, $last_offset, $vlv_count, $vresult);
                        $this->_debug("S: VLV result: last_offset=$last_offset; content_count=$vlv_count");
                    }
                    else {
                        $this->_debug("S: ".($errmsg ? $errmsg : ldap_error($this->conn)));
                    }
                }
                else if ($this->debug) {
                    $this->_debug("S: ".ldap_count_entries($this->conn, $ldap_result)." record(s) found");
                }
                $this->result = new rcube_ldap_result($this->conn, $ldap_result, $base_dn, $filter, $vlv_count);
                return $count_only ? $this->result->count() : $this->result;
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            }
        }
        return false;
    }
    /**
     * Modify an LDAP entry on the server
     *
     * @param string $dn      Entry DN
     * @param array  $params  Hash array of entry attributes
     * @param int    $mode    Update mode (UPDATE_MOD_ADD | UPDATE_MOD_DELETE | UPDATE_MOD_REPLACE)
     */
    public function modify($dn, $parms, $mode = 255)
    {
        // TODO: implement this
        return false;
    }
    /**
     * Wrapper for ldap_add()
     *
     * @see ldap_add()
     */
    public function add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        $res = ldap_add($this->conn, $dn, $entry);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_delete()
     *
     * @see ldap_delete()
     */
    public function delete($dn)
    {
        $this->_debug("C: Delete [dn: $dn]");
        $res = ldap_delete($this->conn, $dn);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_replace()
     *
     * @see ldap_mod_replace()
     */
    public function mod_replace($dn, $entry)
    {
        $this->_debug("C: Replace [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_replace($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_add()
     *
     * @see ldap_mod_add()
     */
    public function mod_add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_add($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_del()
     *
     * @see ldap_mod_del()
     */
    public function mod_del($dn, $entry)
    {
        $this->_debug("C: Delete [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_del($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_rename()
     *
     * @see ldap_rename()
     */
    public function rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true)
    {
        $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
        if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_list() + ldap_get_entries()
     *
     * @see ldap_list()
     * @see ldap_get_entries()
     */
    public function list_entries($dn, $filter, $attributes = array('dn'))
    {
        $list = array();
        $this->_debug("C: List [dn: $dn] [{$filter}]");
        if ($result = ldap_list($this->conn, $dn, $filter, $attributes)) {
            $list = ldap_get_entries($this->conn, $result);
            if ($list === false) {
                $this->_debug("S: ".ldap_error($this->conn));
                return array();
            }
            $count = $list['count'];
            unset($list['count']);
            $this->_debug("S: $count record(s)");
        }
        else {
            $this->_debug("S: ".ldap_error($this->conn));
        }
        return $list;
    }
    /**
     * 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: $dn] [{$filter}]");
        if ($this->conn && $dn) {
            if (!$attributes)
                $attributes = $this->attributes;
            $result = @ldap_read($this->conn, $dn, $filter, $attributes, 0, (int)$this->config['sizelimit'], (int)$this->config['timelimit']);
            if ($result === false) {
                $this->_debug("S: ".ldap_error($this->conn));
                return false;
            }
            $this->_debug("S: OK");
            return ldap_get_entries($this->conn, $result);
        }
        return false;
    }
    /**
     * Choose the right PHP function according to scope property
     *
     * @param string $scope         The LDAP scope (sub|base|list)
     * @param string $ns_function   Function to be used for numSubOrdinates queries
     * @return string  PHP function to be used to query directory
     */
    public static function scope2func($scope, &$ns_function = null)
    {
        switch ($scope) {
          case 'sub':
            $function = $ns_function  = 'ldap_search';
            break;
          case 'base':
            $function = $ns_function = 'ldap_read';
            break;
          default:
            $function = 'ldap_list';
            $ns_function = 'ldap_read';
            break;
        }
        return $function;
    }
    /**
     * Convert the given scope integer value to a string representation
     */
    public static function scopeint2str($scope)
    {
        switch ($scope) {
            case 2:  return 'sub';
            case 1:  return 'one';
            case 0:  return 'base';
            default: $this->_debug("Scope $scope is not a valid scope integer");
        }
        return '';
    }
    /**
     * Escapes the given value according to RFC 2254 so that it can be safely used in LDAP filters.
     *
     * @param string $val Value to quote
     * @return string The escaped value
     */
    public static function escape_value($val)
    {
        return strtr($str, array('*'=>'\2a', '('=>'\28', ')'=>'\29',
            '\\'=>'\5c', '/'=>'\2f'));
    }
    /**
     * Escapes a DN value according to RFC 2253
     *
     * @param string $dn DN value o quote
     * @return string The escaped value
     */
    public static function escape_dn($dn)
    {
        return strtr($str, array(','=>'\2c', '='=>'\3d', '+'=>'\2b',
            '<'=>'\3c', '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c',
            '"'=>'\22', '#'=>'\23'));
    }
    /**
     * Normalize a LDAP result by converting entry attributes arrays into single values
     *
     * @param array $result LDAP result set fetched with ldap_get_entries()
     * @return array        Hash array with normalized entries, indexed by their DNs
     */
    public static function normalize_result($result)
    {
        if (!is_array($result)) {
            return array();
        }
        $entries  = array();
        for ($i = 0; $i < $result['count']; $i++) {
            $key = $result[$i]['dn'] ? $result[$i]['dn'] : $i;
            $entries[$key] = self::normalize_entry($result[$i]);
        }
        return $entries;
    }
    /**
     * 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()
     * @return array       Hash array with attributes as keys
     */
    public static function normalize_entry($entry)
    {
        $rec = array();
        for ($i=0; $i < $entry['count']; $i++) {
            $attr = $entry[$i];
            if ($entry[$attr]['count'] == 1) {
                switch ($attr) {
                    case 'objectclass':
                        $rec[$attr] = array(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;
    }
    /**
     * Set server controls for Virtual List View (paginated listing)
     */
    private function _vlv_set_controls($sort, $list_page, $page_size, $search = null)
    {
        $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473",  'value' => self::_sort_ber_encode((array)$sort));
        $vlv_ctrl  = array('oid' => "2.16.840.1.113730.3.4.9", 'value' => self::_vlv_ber_encode(($offset = ($list_page-1) * $page_size + 1), $page_size, $search), 'iscritical' => true);
        $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ($sort[0]);"
            . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset/$page_size; $search)");
        if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) {
            $this->_debug("S: ".ldap_error($this->conn));
            $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported');
            return false;
        }
        return true;
    }
    /**
     * Returns unified attribute name (resolving aliases)
     */
    private static function _attr_name($namev)
    {
        // list of known attribute aliases
        static $aliases = array(
            'gn' => 'givenname',
            'rfc822mailbox' => 'email',
            'userid' => 'uid',
            'emailaddress' => 'email',
            'pkcs9email' => 'email',
        );
        list($name, $limit) = explode(':', $namev, 2);
        $suffix = $limit ? ':'.$limit : '';
        return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix;
    }
    /**
     * Quotes attribute value string
     *
     * @param string $str Attribute value
     * @param bool   $dn  True if the attribute is a DN
     *
     * @return string Quoted string
     */
    public static function quote_string($str, $dn=false)
    {
        // take firt entry if array given
        if (is_array($str))
            $str = reset($str);
        if ($dn)
            $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
                '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
        else
            $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
                '/'=>'\2f');
        return strtr($str, $replace);
    }
    /**
     * Prints debug info to the log
     */
    private function _debug($str)
    {
        if ($this->debug && class_exists('rcube')) {
            rcube::write_log('ldap', $str);
        }
    }
    /*****************  Virtual List View (VLV) related utility functions  **************** */
    /**
     * Return the search string value to be used in VLV controls
     */
    private function _vlv_search($sort, $search)
    {
        foreach ($search as $attr => $value) {
            if (!in_array(strtolower($attr), $sort)) {
                $this->_debug("d: Cannot use VLV search using attribute not indexed: $attr (not in " . var_export($sort, true) . ")");
                return null;
            } else {
                return $value;
            }
        }
    }
    /**
     * Find a VLV index matching the given query attributes
     *
     * @return string Sort attribute or False if no match
     */
    private function _find_vlv($base_dn, $filter, $scope, $sort_attrs = null)
    {
        if (!$this->config['vlv'] || $scope == 'base') {
            return false;
        }
        // get vlv config
        $vlv_config = $this->_read_vlv_config();
        if ($vlv = $vlv_config[$base_dn]) {
            $this->_debug("D: Found a VLV for base_dn: " . $base_dn);
            if ($vlv['filter'] == strtolower($filter) || stripos($filter, '(&'.$vlv['filter'].'(') === 0) {
                $this->_debug("D: Filter matches");
                if ($vlv['scope'] == $scope) {
                    // Not passing any sort attributes means you don't care
                    if (empty($sort_attrs) || in_array($sort_attrs, $vlv['sort'])) {
                        return $vlv['sort'][0];
                    }
                }
                else {
                    $this->_debug("D: Scope does not match");
                }
            }
            else {
                $this->_debug("D: Filter does not match");
            }
        }
        else {
            $this->_debug("D: No VLV for base dn " . $base_dn);
        }
        return false;
    }
    /**
     * Return VLV indexes and searches including necessary configuration
     * details.
     */
    private function _read_vlv_config()
    {
        if (empty($this->config['vlv']) || empty($this->config['config_root_dn'])) {
            return array();
        }
        // return hard-coded VLV config
        else if (is_array($this->config['vlv'])) {
            return $this->config['vlv'];
        }
        // return cached result
        if (is_array($this->vlv_config)) {
            return $this->vlv_config;
        }
        if ($this->cache && ($cached_config = $this->cache->get('vlvconfig'))) {
            $this->vlv_config = $cached_config;
            return $this->vlv_config;
        }
        $this->vlv_config = array();
        $ldap_result = ldap_search($this->conn, $this->config['config_root_dn'], '(objectclass=vlvsearch)', array('*'), 0, 0, 0);
        $vlv_searches = new rcube_ldap_result($this->conn, $ldap_result, $this->config['config_root_dn'], '(objectclass=vlvsearch)');
        if ($vlv_searches->count() < 1) {
            $this->_debug("D: Empty result from search for '(objectclass=vlvsearch)' on '$config_root_dn'");
            return array();
        }
        foreach ($vlv_searches->entries(true) as $vlv_search_dn => $vlv_search_attrs) {
            // Multiple indexes may exist
            $ldap_result = ldap_search($this->conn, $vlv_search_dn, '(objectclass=vlvindex)', array('*'), 0, 0, 0);
            $vlv_indexes = new rcube_ldap_result($this->conn, $ldap_result, $vlv_search_dn, '(objectclass=vlvindex)');
            // Reset this one for each VLV search.
            $_vlv_sort = array();
            foreach ($vlv_indexes->entries(true) as $vlv_index_dn => $vlv_index_attrs) {
                $_vlv_sort[] = explode(' ', $vlv_index_attrs['vlvsort']);
            }
            $this->vlv_config[$vlv_search_attrs['vlvbase']] = array(
                'scope'  => self::scopeint2str($vlv_search_attrs['vlvscope']),
                'filter' => strtolower($vlv_search_attrs['vlvfilter']),
                'sort'   => $_vlv_sort,
            );
        }
        // cache this
        if ($this->cache)
            $this->cache->set('vlvconfig', $this->vlv_config);
        $this->_debug("D: Refreshed VLV config: " . var_export($this->vlv_config, true));
        return $this->vlv_config;
    }
    /**
     * Generate BER encoded string for Virtual List View option
     *
     * @param integer List offset (first record)
     * @param integer Records per page
     * @return string BER encoded option value
     */
    private static function _vlv_ber_encode($offset, $rpp, $search = '')
    {
        # this string is ber-encoded, php will prefix this value with:
        # 04 (octet string) and 10 (length of 16 bytes)
        # the code behind this string is broken down as follows:
        # 30 = ber sequence with a length of 0e (14) bytes following
        # 02 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0)
        # 02 = type integer (in two's complement form) with 2 bytes following (afterCount):  01 18 (ie 25-1=24)
        # a0 = type context-specific/constructed with a length of 06 (6) bytes following
        # 02 = type integer with 2 bytes following (offset): 01 01 (ie 1)
        # 02 = type integer with 2 bytes following (contentCount):  01 00
        # whith a search string present:
        # 81 = type context-specific/constructed with a length of 04 (4) bytes following (the length will change here)
        # 81 indicates a user string is present where as a a0 indicates just a offset search
        # 81 = type context-specific/constructed with a length of 06 (6) bytes following
        # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the
        # encoding of integer values (note: these values are in
        # two-complement form so since offset will never be negative bit 8 of the
        # leftmost octet should never by set to 1):
        # 8.3.2: If the contents octets of an integer value encoding consist
        # of more than one octet, then the bits of the first octet (rightmost) and bit 8
        # of the second (to the left of first octet) octet:
        # a) shall not all be ones; and
        # b) shall not all be zero
        if ($search)
        {
            $search = preg_replace('/[^-[:alpha:] ,.()0-9]+/', '', $search);
            $ber_val = self::_string2hex($search);
            $str = self::_ber_addseq($ber_val, '81');
        }
        else
        {
            # construct the string from right to left
            $str = "020100"; # contentCount
            $ber_val = self::_ber_encode_int($offset);  // returns encoded integer value in hex format
            // calculate octet length of $ber_val
            $str = self::_ber_addseq($ber_val, '02') . $str;
            // now compute length over $str
            $str = self::_ber_addseq($str, 'a0');
        }
        // now tack on records per page
        $str = "020100" . self::_ber_addseq(self::_ber_encode_int($rpp-1), '02') . $str;
        // now tack on sequence identifier and length
        $str = self::_ber_addseq($str, '30');
        return pack('H'.strlen($str), $str);
    }
    /**
     * create ber encoding for sort control
     *
     * @param array List of cols to sort by
     * @return string BER encoded option value
     */
    private static function _sort_ber_encode($sortcols)
    {
        $str = '';
        foreach (array_reverse((array)$sortcols) as $col) {
            $ber_val = self::_string2hex($col);
            # 30 = ber sequence with a length of octet value
            # 04 = octet string with a length of the ascii value
            $oct = self::_ber_addseq($ber_val, '04');
            $str = self::_ber_addseq($oct, '30') . $str;
        }
        // now tack on sequence identifier and length
        $str = self::_ber_addseq($str, '30');
        return pack('H'.strlen($str), $str);
    }
    /**
     * Add BER sequence with correct length and the given identifier
     */
    private static function _ber_addseq($str, $identifier)
    {
        $len = dechex(strlen($str)/2);
        if (strlen($len) % 2 != 0)
            $len = '0'.$len;
        return $identifier . $len . $str;
    }
    /**
     * Returns BER encoded integer value in hex format
     */
    private static function _ber_encode_int($offset)
    {
        $val = dechex($offset);
        $prefix = '';
        // check if bit 8 of high byte is 1
        if (preg_match('/^[89abcdef]/', $val))
            $prefix = '00';
        if (strlen($val)%2 != 0)
            $prefix .= '0';
        return $prefix . $val;
    }
    /**
     * Returns ascii string encoded in hex
     */
    private static function _string2hex($str)
    {
        $hex = '';
        for ($i=0; $i < strlen($str); $i++)
            $hex .= dechex(ord($str[$i]));
        return $hex;
    }
}
program/lib/Roundcube/rcube_ldap_result.php
New file
@@ -0,0 +1,130 @@
<?php
/*
 +-----------------------------------------------------------------------+
 | Roundcube/rcube_ldap_result.php                                       |
 |                                                                       |
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2006-2013, The Roundcube Dev Team                       |
 | Copyright (C) 2013, 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:                                                              |
 |   Model class that represents an LDAP search result                   |
 |                                                                       |
 +-----------------------------------------------------------------------+
 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
 +-----------------------------------------------------------------------+
*/
/**
 * Model class representing an LDAP search result
 *
 * @package    Framework
 * @subpackage LDAP
 */
class rcube_ldap_result implements Iterator
{
    public $conn;
    public $ldap;
    public $base_dn;
    public $filter;
    private $count = null;
    private $current = null;
    private $iteratorkey = 0;
    /**
     * Default constructor
     *
     * @param resource $conn LDAP link identifier
     * @param resource $ldap LDAP result entry identifier
     * @param string   $base_dn   Base DN used to get this result
     * @param string   $filter    Filter query used to get this result
     * @param integer  $count     Record count value (pre-calculated)
     */
    function __construct($conn, $ldap, $base_dn, $filter, $count = null)
    {
        $this->conn = $conn;
        $this->ldap = $ldap;
        $this->base_dn = $base_dn;
        $this->filter = $filter;
        $this->count = $count;
    }
    /**
     * Wrapper for ldap_sort()
     */
    public function sort($attr)
    {
        return ldap_sort($this->conn, $this->ldap, $attr);
    }
    /**
     * Get entries count
     */
    public function count()
    {
        if (!isset($this->count))
            $this->count = ldap_count_entries($this->conn, $this->ldap);
        return $this->count;
    }
    /**
     * Wrapper for ldap_get_entries()
     *
     * @param boolean $normalize Optionally normalize the entries to a list of hash arrays
     * @return array  List of LDAP entries
     */
    public function entries($normalize = false)
    {
        $entries = ldap_get_entries($this->conn, $this->ldap);
        return $normalize ? rcube_ldap_generic::normalize_result($entries) : $entries;
    }
    /**
     * Wrapper for ldap_get_dn() using the current entry pointer
     */
    public function get_dn()
    {
        return $this->current ? ldap_get_dn($this->conn, $this->current) : null;
    }
    /***  Implements the PHP 5 Iterator interface to make foreach work  ***/
    function current()
    {
        $attrib = ldap_get_attributes($this->conn, $this->current);
        $attrib['dn'] = ldap_get_dn($this->conn, $this->current);
        return $attrib;
    }
    function key()
    {
        return $this->iteratorkey;
    }
    function rewind()
    {
        $this->iteratorkey = 0;
        $this->current = ldap_first_entry($this->conn, $this->ldap);
    }
    function next()
    {
        $this->iteratorkey++;
        $this->current = ldap_next_entry($this->conn, $this->current);
    }
    function valid()
    {
        return (bool)$this->current;
    }
}
program/localization/en_US/labels.inc
@@ -348,6 +348,7 @@
$labels['group'] = 'Group';
$labels['groups'] = 'Groups';
$labels['listgroup'] = 'List group members';
$labels['personaladrbook'] = 'Personal Addresses';
$labels['searchsave'] = 'Save search';
program/steps/addressbook/copy.inc
@@ -57,6 +57,10 @@
    foreach ($cid as $cid) {
        $a_record = $CONTACTS->get_record($cid, true);
        // avoid copying groups
        if ($a_record['_type'] == 'group')
            continue;
        // Check if contact exists, if so, we'll need it's ID
        // Note: Some addressbooks allows empty email address field
        if (!empty($a_record['email']))
program/steps/addressbook/func.inc
@@ -187,7 +187,7 @@
    $jsdata = array();
    $line_templ = html::tag('li', array(
        'id' => 'rcmli%s', 'class' => '%s'),
        'id' => 'rcmli%s', 'class' => '%s', 'noclose' => true),
        html::a(array('href' => '%s',
            'rel' => '%s',
            'onclick' => "return ".JS_OBJECT_NAME.".command('list','%s',this)"), '%s'));
@@ -213,7 +213,7 @@
        $name = !empty($source['name']) ? $source['name'] : $id;
        $out .= sprintf($line_templ,
            html_identifier($id),
            rcube_utils::html_identifier($id, true),
            $class_name,
            Q(rcmail_url(null, array('_source' => $id))),
            $source['id'],
@@ -224,10 +224,11 @@
            $groupdata = rcmail_contact_groups($groupdata);
        $jsdata = $groupdata['jsdata'];
        $out = $groupdata['out'];
        $out .= '</li>';
    }
    $line_templ = html::tag('li', array(
        'id' => 'rcmliS%s', 'class' => '%s'),
        'id' => 'rcmli%s', 'class' => '%s'),
        html::a(array('href' => '#', 'rel' => 'S%s',
            'onclick' => "return ".JS_OBJECT_NAME.".command('listsearch', '%s', this)"), '%s'));
@@ -245,14 +246,17 @@
            $class_name .= ' ' . $source['class_name'];
        $out .= sprintf($line_templ,
            html_identifier($id),
            rcube_utils::html_identifier('S'.$id, true),
            $class_name,
            $id,
            $js_id, (!empty($source['name']) ? Q($source['name']) : Q($id)));
    }
    $OUTPUT->set_env('contactgroups', $jsdata);
    $OUTPUT->set_env('collapsed_abooks', (string)$RCMAIL->config->get('collapsed_abooks',''));
    $OUTPUT->add_gui_object('folderlist', $attrib['id']);
    $OUTPUT->include_script('treelist.js');
    // add some labels to client
    $OUTPUT->add_label('deletegroupconfirm', 'groupdeleting', 'addingmember', 'removingmember');
@@ -265,18 +269,24 @@
    global $RCMAIL;
    $groups = $RCMAIL->get_address_book($args['source'])->list_groups();
    $js_id = $RCMAIL->JQ($args['source']);
    $groups_html = '';
    if (!empty($groups)) {
        $line_templ = html::tag('li', array(
            'id' => 'rcmliG%s', 'class' => 'contactgroup'),
            'id' => 'rcmli%s', 'class' => 'contactgroup'),
            html::a(array('href' => '#',
                'rel' => '%s:%s',
                'onclick' => "return ".JS_OBJECT_NAME.".command('listgroup',{'source':'%s','id':'%s'},this)"), '%s'));
        // append collapse/expand toggle and open a new <ul>
        $is_collapsed = strpos($RCMAIL->config->get('collapsed_abooks',''), '&'.rawurlencode($args['source']).'&') !== false;
        $args['out'] .= html::div('treetoggle ' . ($is_collapsed ? 'collapsed' : 'expanded'), '&nbsp;');
        $jsdata = array();
        foreach ($groups as $group) {
            $args['out'] .= sprintf($line_templ,
                html_identifier($args['source'] . $group['ID']),
            $groups_html .= sprintf($line_templ,
                rcube_utils::html_identifier('G' . $args['source'] . $group['ID'], true),
                $args['source'], $group['ID'],
                $args['source'], $group['ID'], Q($group['name'])
            );
@@ -285,6 +295,10 @@
                'name' => $group['name'], 'type' => 'group');
        }
    }
    $args['out'] .= html::tag('ul',
      array('class' => 'groups', 'style' => ($is_collapsed || empty($groups) ? "display:none;" : null)),
      $groups_html);
    return $args;
}
@@ -296,7 +310,7 @@
    global $CONTACTS, $OUTPUT;
    // define list of cols to be displayed
    $a_show_cols = array('name');
    $a_show_cols = array('name','action');
    // add id to message list table if not specified
    if (!strlen($attrib['id']))
@@ -325,28 +339,70 @@
        return;
    // define list of cols to be displayed
    $a_show_cols = array('name');
    $a_show_cols = array('name','action');
    while ($row = $result->next()) {
        $row['CID'] = $row['ID'];
        $row['email'] = reset(rcube_addressbook::get_col_values('email', $row, true));
        $source_id = $OUTPUT->get_env('source');
        $a_row_cols = array();
        $classes = array('person');  // org records will follow some day
        $classes = array($row['_type'] ? $row['_type'] : 'person');
        // build contact ID with source ID
        if (isset($row['sourceid'])) {
            $row['ID'] = $row['ID'].'-'.$row['sourceid'];
            $source_id = $row['sourceid'];
        }
        // format each col
        foreach ($a_show_cols as $col) {
            $val = $col == 'name' ? rcube_addressbook::compose_list_name($row) : $row[$col];
            $a_row_cols[$col] = Q($val);
            $val = '';
            switch ($col) {
                case 'name':
                    $val = Q(rcube_addressbook::compose_list_name($row));
                    break;
                case 'action':
                    if ($row['_type'] == 'group') {
                        $val = html::a(array(
                            'href' => '#list',
                            'rel' => $row['ID'],
                            'title' => rcube_label('listgroup'),
                            'onclick' => sprintf("return %s.command('pushgroup',{'source':'%s','id':'%s'},this,event)", JS_OBJECT_NAME, $source_id, $row['CID']),
                        ), '&raquo;');
                    }
                    else
                        $val = '&nbsp;';
                    break;
                default:
                    $val = Q($row[$col]);
                    break;
            }
            $a_row_cols[$col] = $val;
        }
        if ($row['readonly'])
            $classes[] = 'readonly';
        $OUTPUT->command($prefix.'add_contact_row', $row['ID'], $a_row_cols, join(' ', $classes));
        $OUTPUT->command($prefix.'add_contact_row', $row['ID'], $a_row_cols, join(' ', $classes), array_intersect_key($row, array('ID'=>1,'readonly'=>1,'_type'=>1,'email'=>1,'name'=>1)));
    }
}
function rcmail_contacts_list_title($attrib)
{
    global $OUTPUT;
    $attrib += array('label' => 'contacts', 'id' => 'rcmabooklisttitle', 'tag' => 'span');
    unset($attrib['name']);
    $OUTPUT->add_gui_object('addresslist_title', $attrib['id']);
    $OUTPUT->add_label('contacts');
    return html::tag($attrib['tag'], $attrib, rcube_label($attrib['label']), html::$common_attrib);
}
@@ -418,7 +474,7 @@
function rcmail_contact_form($form, $record, $attrib = null)
{
    global $RCMAIL, $CONFIG;
    global $RCMAIL;
    // Allow plugins to modify contact form content
    $plugin = $RCMAIL->plugins->exec_hook('contact_form', array(
@@ -427,7 +483,7 @@
    $form = $plugin['form'];
    $record = $plugin['record'];
    $edit_mode = $RCMAIL->action != 'show';
    $del_button = $attrib['deleteicon'] ? html::img(array('src' => $CONFIG['skin_path'] . $attrib['deleteicon'], 'alt' => rcube_label('delete'))) : rcube_label('delete');
    $del_button = $attrib['deleteicon'] ? html::img(array('src' => $RCMAIL->output->get_skin_file($attrib['deleteicon']), 'alt' => rcube_label('delete'))) : rcube_label('delete');
    unset($attrib['deleteicon']);
    $out = '';
@@ -693,12 +749,15 @@
function rcmail_contact_photo($attrib)
{
    global $SOURCE_ID, $CONTACTS, $CONTACT_COLTYPES, $RCMAIL, $CONFIG;
    global $SOURCE_ID, $CONTACTS, $CONTACT_COLTYPES, $RCMAIL;
    if ($result = $CONTACTS->get_result())
        $record = $result->first();
    $photo_img = $attrib['placeholder'] ? $CONFIG['skin_path'] . $attrib['placeholder'] : 'program/resources/blank.gif';
    $photo_img = $attrib['placeholder'] ? $RCMAIL->output->get_skin_file($attrib['placeholder']) : 'program/resources/blank.gif';
    if ($record['_type'] == 'group' && $attrib['placeholdergroup'])
        $photo_img = $RCMAIL->output->get_skin_file($attrib['placeholdergroup']);
    $RCMAIL->output->set_env('photo_placeholder', $photo_img);
    unset($attrib['placeholder']);
@@ -789,6 +848,7 @@
    'directorylist' => 'rcmail_directory_list',
//  'groupslist' => 'rcmail_contact_groups',
    'addresslist' => 'rcmail_contacts_list',
    'addresslisttitle' => 'rcmail_contacts_list_title',
    'addressframe' => 'rcmail_contact_frame',
    'recordscountdisplay' => 'rcmail_rowcount_display',
    'searchform' => array($OUTPUT, 'search_form')
program/steps/addressbook/list.inc
@@ -81,6 +81,11 @@
        $OUTPUT->show_message('contactsearchonly', 'notice');
        $OUTPUT->command('command', 'advanced-search');
    }
    if ($CONTACTS->group_id) {
        $OUTPUT->command('set_group_prop', array('ID' => $CONTACTS->group_id)
            + array_intersect_key((array)$CONTACTS->get_group($CONTACTS->group_id), array('name'=>1,'email'=>1)));
    }
}
// update message count display
program/steps/addressbook/save.inc
@@ -134,11 +134,11 @@
    $record['email'] = reset($CONTACTS->get_col_values('email', $record, true));
    $record['name']  = rcube_addressbook::compose_list_name($record);
    foreach (array('name', 'email') as $col)
    foreach (array('name') as $col)
      $a_js_cols[] = Q((string)$record[$col]);
    // update the changed col in list
    $OUTPUT->command('parent.update_contact_row', $cid, $a_js_cols, $newcid, $source);
    $OUTPUT->command('parent.update_contact_row', $cid, $a_js_cols, $newcid, $source, $record);
    // show confirmation
    $OUTPUT->show_message('successfullysaved', 'confirmation', null, false);
program/steps/addressbook/show.inc
@@ -215,8 +215,11 @@
    $checkbox = new html_checkbox(array('name' => '_gid[]',
        'class' => 'groupmember', 'disabled' => $CONTACTS->readonly));
    foreach ($GROUPS as $group) {
    foreach (array_merge($GROUPS, $members) as $group) {
        $gid = $group['ID'];
        if ($seen[$gid]++)
            continue;
        $table->add(null, $checkbox->show($members[$gid] ? $gid : null,
            array('value' => $gid, 'id' => 'ff_gid' . $gid)));
        $table->add(null, html::label('ff_gid' . $gid, Q($group['name'])));
program/steps/mail/list_contacts.inc
@@ -73,8 +73,11 @@
        $CONTACTS->set_pagesize($page_size);
        $CONTACTS->set_page($list_page);
        if ($group_id = get_input_value('_gid', RCUBE_INPUT_GPC)) {
            $CONTACTS->set_group($group_id);
        }
        // list groups of this source (on page one)
        if ($CONTACTS->groups && $CONTACTS->list_page == 1) {
        else if ($CONTACTS->groups && $CONTACTS->list_page == 1) {
            foreach ($CONTACTS->list_groups() as $group) {
                $CONTACTS->reset();
                $CONTACTS->set_group($group['ID']);
@@ -89,6 +92,19 @@
                            'contactgroup' => html::span(array('title' => $email), Q($group['name']))), 'group');
                    }
                }
                // make virtual groups clickable to list their members
                else if ($group_prop['virtual']) {
                    $row_id = 'G'.$group['ID'];
                    $OUTPUT->command('add_contact_row', $row_id, array(
                        'contactgroup' => html::a(array(
                            'href' => '#list',
                            'rel' => $row['ID'],
                            'title' => rcube_label('listgroup'),
                            'onclick' => sprintf("return %s.command('pushgroup',{'source':'%s','id':'%s'},this,event)", JS_OBJECT_NAME, $source, $group['ID']),
                        ), Q($group['name']) . '&nbsp;' . html::span('action', '&raquo;'))),
                        'group',
                        array('ID' => $group['ID'], 'name' => $group['name'], 'virtual' => true));
                }
                // show group with count
                else if (($result = $CONTACTS->count()) && $result->count) {
                    $row_id = 'E'.$group['ID'];
@@ -97,10 +113,12 @@
                        'contactgroup' => Q($group['name'] . ' (' . intval($result->count) . ')')), 'group');
                }
            }
            $CONTACTS->reset();
            $CONTACTS->set_group(0);
        }
        // get contacts for this user
        $CONTACTS->set_group(0);
        $result = $CONTACTS->list_records($afields);
    }
}
@@ -118,10 +136,13 @@
        foreach ($emails as $i => $email) {
            $row_id = $row['ID'].$i;
            $jsresult[$row_id] = format_email_recipient($email, $name);
            $classname = $row['_type'] == 'group' ? 'group' : 'person';
            $keyname = $row['_type'] == 'group' ? 'contactgroup' : 'contact';
            $OUTPUT->command('add_contact_row', $row_id, array(
                'contact' => html::span(array('title' => $email), Q($name ? $name : $email) .
                $keyname => html::span(array('title' => $email), Q($name ? $name : $email) .
                    ($name && count($emails) > 1 ? '&nbsp;' . html::span('email', Q($email)) : '')
                )), 'person');
                )), $classname);
        }
    }
}
skins/classic/addressbook.css
@@ -110,7 +110,7 @@
#directorylistbox input
{
  margin: 0px;
  margin: 0 0 0 20px;
  font-size: 11px;
  width: 90%;
}
@@ -136,7 +136,8 @@
  width: 280px;
}
#directorylist
#directorylist,
#directorylist li ul
{
  list-style: none;
  margin: 0;
@@ -144,11 +145,15 @@
  background-color: #FFFFFF;
}
#directorylist li ul
{
  border-top: 1px solid #EBEBEB;
}
#directorylist li
{
  display: block;
  font-size: 11px;
  background: url(images/icons/folders.png) 5px -108px no-repeat;
  border-bottom: 1px solid #EBEBEB;
  white-space: nowrap;
}
@@ -160,31 +165,37 @@
  padding-left: 25px;
  padding-top: 2px;
  padding-bottom: 2px;
  height: 16px;
  text-decoration: none;
  white-space: nowrap;
  background: url(images/icons/folders.png) 5px -108px no-repeat;
}
#directorylist li.contactgroup
#directorylist li ul li a
{
  padding-left: 15px;
  background-position: 20px -143px;
  padding-left: 45px;
}
#directorylist li.contactsearch
#directorylist li ul li:last-child
{
  border-bottom: 0;
}
#directorylist li.contactgroup a
{
  background-position: 22px -143px;
}
#directorylist li.contactsearch a
{
  background-position: 6px -162px;
}
#directorylist li.selected
{
  background-color: #929292;
  border-bottom: 1px solid #898989;
}
#directorylist li.selected a
#directorylist li.selected > a
{
  color: #FFF;
  font-weight: bold;
  background-color: #929292;
}
#directorylist li.droptarget
@@ -205,6 +216,37 @@
  -o-text-overflow: ellipsis;
}
#contacts-table .contact.readonly td
{
  font-style: italic;
}
#contacts-table td.name
{
  width: 95%;
}
#contacts-table td.action
{
  width: 12px;
  padding: 0px 6px 0 4px;
  text-align: right;
}
#contacts-table td.action a
{
  font-size: 16px;
  font-weight: bold;
  font-style: normal;
  text-decoration: none;
  color: #333;
}
#contacts-table .selected td.action a
{
  color: #fff;
}
#contacts-box
{
  position: absolute;
skins/classic/common.css
@@ -622,6 +622,32 @@
  background-color: #929292;
}
ul.treelist li
{
  position: relative;
}
ul.treelist li div.treetoggle
{
  position: absolute;
  left: 8px !important;
  left: -16px;
  top: 1px;
  width: 14px;
  height: 16px;
  cursor: pointer;
}
ul.treelist li div.collapsed
{
  background: url(images/icons/collapsed.png) bottom right no-repeat;
}
ul.treelist li div.expanded
{
  background: url(images/icons/expanded.png) bottom right no-repeat;
}
/***** mac-style quicksearch field *****/
@@ -666,6 +692,7 @@
  font-size: 11px;
  padding: 0px;
  border: none;
  outline: none;
}
/***** roundcube webmail pre-defined classes *****/
skins/classic/mail.css
@@ -393,32 +393,6 @@
  border-bottom: none;
}
#mailboxlist li div
{
  position: absolute;
  left: 8px !important;
  left: -16px;
  top: 1px;
  width: 14px;
  height: 16px;
}
#mailboxlist li div.collapsed,
#mailboxlist li div.expanded
{
  cursor: pointer;
}
#mailboxlist li div.collapsed
{
  background: url(images/icons/collapsed.png) bottom right no-repeat;
}
#mailboxlist li div.expanded
{
  background: url(images/icons/expanded.png) bottom right no-repeat;
}
#mailboxlist li.inbox
{
  background-position: 5px -18px;
@@ -1693,6 +1667,14 @@
  -o-text-overflow: ellipsis;
}
#contacts-table td span.email
{
  display: inline;
  color: #ccc;
  font-style: italic;
  margin-left: 0.5em;
}
#abookcountbar
{
  margin-top: 4px;
skins/classic/templates/addressbook.html
@@ -54,8 +54,7 @@
<div id="directorylistbox">
<div id="directorylist-title" class="boxtitle"><roundcube:label name="groups" /></div>
<div id="directorylist-content" class="boxlistcontent">
  <roundcube:object name="directorylist" id="directorylist" />
  <roundcube:object name="groupslist" id="contactgroupslist" />
  <roundcube:object name="directorylist" id="directorylist" class="treelist" />
</div>
<div id="directorylist-footer" class="boxfooter">
  <roundcube:button command="group-create" type="link" title="newcontactgroup" class="buttonPas addgroup" classAct="button addgroup" content=" " />
@@ -66,7 +65,7 @@
<div id="addressscreen">
<div id="addresslist">
<div class="boxtitle"><roundcube:label name="contacts" /></div>
<roundcube:object name="addresslisttitle" label="contacts" tag="div" class="boxtitle" />
<div class="boxlistcontent">
<roundcube:object name="addresslist" id="contacts-table" class="records-table" cellspacing="0" summary="Contacts list" noheader="true" />
</div>
skins/classic/templates/contact.html
@@ -13,7 +13,7 @@
    <div id="sourcename"><roundcube:label name="addressbook" />: <roundcube:var name="env:sourcename" /></div>
  <roundcube:endif />
  <div id="contactphoto"><roundcube:object name="contactphoto" id="contactpic" placeholder="/images/contactpic.png" /></div>
  <div id="contactphoto"><roundcube:object name="contactphoto" id="contactpic" placeholder="/images/contactpic.png" placeholderGroup="/images/contactgroup.png" /></div>
  <roundcube:object name="contacthead" id="contacthead" />
  <div style="clear:both"></div>
  <div id="contacttabs">
skins/classic/templates/mail.html
@@ -28,7 +28,7 @@
<div id="mailboxlist-container">
<div id="mailboxlist-title" class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div id="mailboxlist-content"  class="boxlistcontent">
<roundcube:object name="mailboxlist" id="mailboxlist" folder_filter="mail" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" folder_filter="mail" />
</div>
<div id="mailboxlist-footer" class="boxfooter">
  <roundcube:button name="mailboxmenulink" id="mailboxmenulink" type="link" title="folderactions" class="button groupactions" onclick="rcmail_ui.show_popup('mailboxmenu');return false" content=" " />
skins/classic/templates/message.html
@@ -30,7 +30,7 @@
<div id="mailboxlist-container">
<div id="mailboxlist-title" class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div class="boxlistcontent">
    <roundcube:object name="mailboxlist" id="mailboxlist" maxlength="25" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" maxlength="25" />
</div>
<div class="boxfooter"></div>
</div>
skins/classic/templates/messageerror.html
@@ -42,7 +42,7 @@
<div id="mailboxlist-container">
<div class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div class="boxlistcontent">
    <roundcube:object name="mailboxlist" id="mailboxlist" folder_filter="mail" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" folder_filter="mail" />
</div>
<div class="boxfooter"></div>
</div>
skins/larry/addressbook.css
@@ -75,16 +75,25 @@
    text-overflow: ellipsis;
}
#contacts-table .contact.readonly td {
    font-style: italic;
}
#directorylist li.addressbook a {
    background-position: 6px -766px;
}
#directorylist li.addressbook.selected a {
#directorylist li.addressbook.selected > a {
    background-position: 6px -791px;
}
#directorylist li.addressbook ul li:last-child {
    border-bottom: 0;
}
#directorylist li.addressbook ul.groups {
    margin: 0;
    padding: 0;
}
#directorylist li.addressbook ul.groups li {
    width: 100%;
}
#directorylist li.contactgroup a {
@@ -112,6 +121,34 @@
    margin-left: 8px;
}
#directorylist li.addressbook div.collapsed,
#directorylist li.addressbook div.expanded {
    top: 15px;
    left: 20px;
}
#contacts-table .contact.readonly td {
    font-style: italic;
}
#contacts-table td.name {
    width: 95%;
}
#contacts-table td.action {
    width: 24px;
    padding: 4px;
}
#contacts-table td.action a {
    display: block;
    width: 16px;
    height: 14px;
    text-indent: -5000px;
    overflow: hidden;
    background: url(images/listicons.png) -2px -1180px no-repeat;
}
#contacts-table .contact td.name {
    background-position: 6px -1603px;
}
@@ -122,6 +159,29 @@
    font-weight: bold;
}
#contacts-table .group td.name {
    background-position: 6px -1555px;
}
#contacts-table .group.selected td.name,
#contacts-table .group.unfocused td.name {
    background-position: 6px -1579px;
    font-weight: bold;
}
#addresslist .boxtitle {
    padding-right: 95px;
    overflow: hidden;
    text-overflow: ellipsis;
}
#addresslist .boxtitle a.poplink {
    color: #004458;
    font-size: 14px;
    line-height: 12px;
    text-decoration: none;
}
#contact-frame {
    position: absolute;
    top: 0;
skins/larry/ie7hacks.css
@@ -41,6 +41,7 @@
.boxfooter .listbutton .inner,
.attachmentslist li a.delete,
.attachmentslist li a.cancelupload,
#contacts-table td.action a,
.previewheader .iconlink,
.minimal #taskbar .button-inner {
    /* workaround for text-indent which also offsets the background image */
skins/larry/images/contactgroup.png
skins/larry/images/listicons.png

skins/larry/includes/footer.html
@@ -8,4 +8,16 @@
});
</script>
<!--[if lte IE 8]>
<script type="text/javascript">
// fix missing :last-child selectors
$(document).ready(function(){
    $('ul.treelist ul').each(function(i,ul){
        $('li:last-child', ul).css('border-bottom', 0);
    });
});
</script>
<![endif]-->
skins/larry/mail.css
@@ -141,7 +141,7 @@
    background-position: 6px 2px;
}
#mailboxlist li:first-child {
#mailboxlist > li:first-child {
    border-radius: 4px 4px 0 0;
    border-top: 0;
}
@@ -156,7 +156,7 @@
    background-position: 6px 3px;
}
#mailboxlist li.mailbox.unread a {
#mailboxlist li.mailbox.unread > a {
    padding-right: 36px;
}
@@ -224,6 +224,17 @@
    color: #017cb4;
}
#mailboxlist li.mailbox div.treetoggle {
    top: 13px;
    left: 19px;
}
#mailboxlist li.mailbox ul li:last-child {
    border-bottom: 0;
}
/* nested mailboxes */
#mailboxlist li.mailbox ul {
    list-style: none;
    margin: 0;
@@ -231,50 +242,57 @@
    border-top: 1px solid #bbd3da;
}
#mailboxlist li.mailbox ul li {
    padding-left: 26px;
}
#mailboxlist li.mailbox ul li a {
    background-position: 6px -93px;
    padding-left: 52px;  /* 36 + 1 x 16 */
    background-position: 22px -93px;  /* 6 + 1 x 16 */
}
#mailboxlist li.mailbox ul li.selected > a {
    background-position: 6px -117px;
    background-position: 22px -117px;
}
#mailboxlist li.mailbox ul li:last-child {
    border-bottom: 0;
}
#mailboxlist li.mailbox div.collapsed,
#mailboxlist li.mailbox div.expanded {
    position: absolute;
    top: 13px;
    left: 19px;
    width: 13px;
    height: 13px;
    background: url(images/listicons.png) -3px -144px no-repeat;
    cursor: pointer;
}
#mailboxlist li.mailbox div.expanded {
    background-position: -3px -168px;
}
#mailboxlist li.mailbox.selected > div.collapsed {
    background-position: -23px -144px;
}
#mailboxlist li.mailbox.selected > div.expanded {
    background-position: -23px -168px;
}
#mailboxlist li.mailbox ul li div.collapsed,
#mailboxlist li.mailbox ul li div.expanded {
    left: 43px;
#mailboxlist li.mailbox ul li div.treetoggle {
    left: 33px;
    top: 14px;
}
#mailboxlist li.mailbox ul ul li.mailbox a {
    padding-left: 68px;  /* 2x */
    background-position: 38px -93px;
}
#mailboxlist li.mailbox ul ul li.selected > a {
    background-position: 38px -117px;
}
#mailboxlist li.mailbox ul ul li div.treetoggle {
    left: 48px;
}
#mailboxlist li.mailbox ul ul ul li.mailbox a {
    padding-left: 84px;  /* 3x */
    background-position: 54px -93px;
}
#mailboxlist li.mailbox ul ul ul li.selected > a {
    background-position: 54px -117px;
}
#mailboxlist li.mailbox ul ul ul li div.treetoggle {
    left: 64px;
}
#mailboxlist li.mailbox ul ul ul ul li.mailbox a {
    padding-left: 100px;  /* 4x */
    background-position: 70px -93px;
}
#mailboxlist li.mailbox ul ul ul ul li.selected > a {
    background-position: 70px -117px;
}
#mailboxlist li.mailbox ul ul ul ul li div.treetoggle {
    left: 80px;
}
/* indent folders on levels > 4 */
#mailboxlist li.mailbox ul ul ul ul ul li {
    padding-left: 16px;
}
#mailboxlist li.mailbox ul ul ul ul ul li div.treetoggle {
    left: 96px;
}
#mailboxlist li.mailbox .unreadcount {
@@ -1210,6 +1228,19 @@
    text-overflow: ellipsis;
}
#contacts-table td.contactgroup a {
    color: #376572;
    text-decoration: none;
}
#contacts-table td.contactgroup a span {
    display: inline-block;
    font-size: 16px;
    font-weight: bold;
    line-height: 11px;
    margin-left: 0.3em;
}
#contacts-table tr:first-child td {
    border-top: 0;
}
skins/larry/styles.css
@@ -995,9 +995,17 @@
    background-color: #d9ecf4;
}
ul.listing li ul {
    border-top: 1px solid #bbd3da;
}
ul.listing li.droptarget,
table.listing tr.droptarget td {
    background-color: #e8e798;
}
.listbox table.listing {
    background-color: #d9ecf4;
}
table.listing,
@@ -1011,6 +1019,32 @@
    vertical-align: top;
}
ul.treelist li {
    position: relative;
}
ul.treelist li div.treetoggle {
    position: absolute;
    top: 13px;
    left: 19px;
    width: 13px;
    height: 13px;
    background: url(images/listicons.png) -3px -144px no-repeat;
    cursor: pointer;
}
ul.treelist li div.treetoggle.expanded {
    background-position: -3px -168px;
}
ul.treelist li.selected > div.collapsed {
    background-position: -23px -144px;
}
ul.treelist li.selected > div.expanded {
    background-position: -23px -168px;
}
.listbox .boxfooter {
    position: absolute;
    bottom: 0;
skins/larry/templates/addressbook.html
@@ -26,7 +26,7 @@
<div id="directorylistbox" class="uibox listbox">
<h2 id="directorylist-header" class="boxtitle"><roundcube:label name="groups" /></h2>
<div id="directorylist-content" class="scroller withfooter">
    <roundcube:object name="directorylist" id="directorylist" class="listing" />
    <roundcube:object name="directorylist" id="directorylist" class="treelist listing" />
</div>
<div id="directorylist-footer" class="boxfooter">
    <roundcube:button command="group-create" type="link" title="newcontactgroup" class="listbutton add disabled" classAct="listbutton add" innerClass="inner" content="+" /><roundcube:button name="groupoptions" id="groupoptionslink" type="link" title="moreactions" class="listbutton groupactions" onclick="UI.show_popup('groupoptions');return false" innerClass="inner" content="&#9881;" />
@@ -46,7 +46,7 @@
<!-- contacts list -->
<div id="addresslist" class="uibox listbox">
<h2 class="boxtitle"><roundcube:label name="contacts" /></h2>
<roundcube:object name="addresslisttitle" label="contacts" tag="h2" class="boxtitle" />
<div class="scroller withfooter">
<roundcube:object name="addresslist" id="contacts-table" class="listing" noheader="true" />
</div>
skins/larry/templates/contact.html
@@ -13,7 +13,7 @@
        <div id="sourcename"><roundcube:label name="addressbook" />: <roundcube:var name="env:sourcename" /></div>
    <roundcube:endif />
    <div id="contactphoto"><roundcube:object name="contactphoto" id="contactpic" placeholder="/images/contactpic.png" /></div>
    <div id="contactphoto"><roundcube:object name="contactphoto" id="contactpic" placeholder="/images/contactpic.png" placeholderGroup="/images/contactgroup.png" /></div>
    <roundcube:object name="contacthead" id="contacthead" />
    <br style="clear:both" />
skins/larry/templates/mail.html
@@ -30,7 +30,7 @@
<div id="folderlist-header"></div>
<div id="mailboxcontainer" class="uibox listbox">
<div id="folderlist-content" class="scroller withfooter">
<roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
<div id="folderlist-footer" class="boxfooter">
    <roundcube:button name="mailboxmenulink" id="mailboxmenulink" type="link" title="folderactions" class="listbutton groupactions" onclick="UI.show_popup('mailboxmenu');return false" innerClass="inner" content="&#9881;" />
skins/larry/templates/message.html
@@ -28,7 +28,7 @@
<!-- folders list -->
<div id="mailboxcontainer" class="uibox listbox">
<div class="scroller">
<roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
</div>
skins/larry/templates/messageerror.html
@@ -28,7 +28,7 @@
<!-- folders list -->
<div id="mailboxcontainer" class="uibox listbox">
<div class="scroller">
    <roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
</div>
skins/larry/ui.js
@@ -1,7 +1,7 @@
/**
 * Roundcube functions for default skin interface
 *
 * Copyright (c) 2013, The Roundcube Dev Team
 * Copyright (c) 2011, The Roundcube Dev Team
 *
 * The contents are subject to the Creative Commons Attribution-ShareAlike
 * License. It is allowed to copy, distribute, transmit and to adapt the work
@@ -180,6 +180,8 @@
    /***  addressbook task  ***/
    else if (rcmail.env.task == 'addressbook') {
      rcmail.addEventListener('afterupload-photo', show_uploadform);
      rcmail.addEventListener('beforepushgroup', push_contactgroup);
      rcmail.addEventListener('beforepopgroup', pop_contactgroup);
      if (rcmail.env.action == '') {
        new rcube_splitter({ id:'addressviewsplitterd', p1:'#addressview-left', p2:'#addressview-right',
@@ -752,6 +754,35 @@
    });
  }
  function push_contactgroup(p)
  {
    // lets the contacts list swipe to the left, nice!
    var table = $('#contacts-table'),
      scroller = table.parent().css('overflow', 'hidden');
    table.clone()
      .css({ position:'absolute', top:'0', left:'0', width:table.width()+'px', 'z-index':10 })
      .appendTo(scroller)
      .animate({ left: -(table.width()+5) + 'px' }, 300, 'swing', function(){
        $(this).remove();
        scroller.css('overflow', 'auto')
      });
  }
  function pop_contactgroup(p)
  {
    // lets the contacts list swipe to the left, nice!
    var table = $('#contacts-table'),
      scroller = table.parent().css('overflow', 'hidden'),
      clone = table.clone().appendTo(scroller);
      table.css({ position:'absolute', top:'0', left:-(table.width()+5) + 'px', width:table.width()+'px', height:table.height()+'px', 'z-index':10 })
        .animate({ left:'0' }, 300, 'linear', function(){
        clone.remove();
        $(this).css({ position:'relative', left:'0', width:'100%', height:'auto', 'z-index':1 });
        scroller.css('overflow', 'auto')
      });
  }
  function show_uploadform()
  {
@@ -762,7 +793,7 @@
      $dialog.dialog('close');
      return;
    }
    // add icons to clone file input field
    if (rcmail.env.action == 'compose' && !$dialog.data('extended')) {
      $('<a>')
@@ -947,11 +978,11 @@
  this.delay = 500;
  this.top
    .mouseenter(function() { if (rcmail.drag_active) ref.ts = window.setTimeout(function() { ref.scroll('down'); }, ref.delay); })
    .mouseenter(function() { ref.ts = window.setTimeout(function() { ref.scroll('down'); }, ref.delay); })
    .mouseout(function() { if (ref.ts) window.clearTimeout(ref.ts); });
  this.bottom
    .mouseenter(function() { if (rcmail.drag_active) ref.ts = window.setTimeout(function() { ref.scroll('up'); }, ref.delay); })
    .mouseenter(function() { ref.ts = window.setTimeout(function() { ref.scroll('up'); }, ref.delay); })
    .mouseout(function() { if (ref.ts) window.clearTimeout(ref.ts); });
  this.scroll = function(dir)