diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php
index 4112e9d9..9ec2d117 100644
--- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php
+++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php
@@ -1,1241 +1,1269 @@
 <?php
 
 /**
  * Backend class for a custom address book using CardDAV service.
  *
  * This part of the Roundcube+Kolab integration and connects the
  * rcube_addressbook interface with the kolab_storage_dav wrapper from libkolab
  *
  * @author Thomas Bruederli <bruederli@kolabsys.com>
  * @author Aleksander Machniak <machniak@apheleia-it.chm>
  *
  * Copyright (C) 2011-2022, Kolab Systems AG <contact@apheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
  *
  * This program is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  * GNU Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  * @see rcube_addressbook
  */
 class carddav_contacts extends rcube_addressbook
 {
     public $primary_key = 'ID';
     public $rights   = 'lrs';
     public $readonly = true;
     public $undelete = false;
-    public $groups = false; // TODO
+    public $groups   = true;
 
     public $coltypes = [
         'name'         => ['limit' => 1],
         'firstname'    => ['limit' => 1],
         'surname'      => ['limit' => 1],
         'middlename'   => ['limit' => 1],
         'prefix'       => ['limit' => 1],
         'suffix'       => ['limit' => 1],
         'nickname'     => ['limit' => 1],
         'jobtitle'     => ['limit' => 1],
         'organization' => ['limit' => 1],
         'department'   => ['limit' => 1],
         'email'        => ['subtypes' => ['home','work','other']],
         'phone'        => [],
         'address'      => ['subtypes' => ['home','work','office']],
         'website'      => ['subtypes' => ['homepage','blog']],
         'im'           => ['subtypes' => null],
         'gender'       => ['limit' => 1],
         'birthday'     => ['limit' => 1],
         'anniversary'  => ['limit' => 1],
         'manager'      => ['limit' => null],
         'assistant'    => ['limit' => null],
         'spouse'       => ['limit' => 1],
         'notes'        => ['limit' => 1],
         'photo'        => ['limit' => 1],
     ];
 
     public $vcard_map = [
     //    'profession'     => 'X-PROFESSION',
     //    'officelocation' => 'X-OFFICE-LOCATION',
     //    'initials'       => 'X-INITIALS',
     //    'children'       => 'X-CHILDREN',
     //    'freebusyurl'    => 'X-FREEBUSY-URL',
     //    'pgppublickey'   => 'KEY',
         'uid'            => 'UID',
     ];
 
     /**
      * List of date type fields
      */
     public $date_cols = ['birthday', 'anniversary'];
 
     public $fulltext_cols  = ['name', 'firstname', 'surname', 'middlename', 'email'];
 
     private $gid;
     private $storage;
     private $dataset;
     private $sortindex;
     private $contacts;
     private $distlists;
     private $groupmembers;
     private $filter;
     private $result;
     private $namespace;
     private $action;
 
     // list of fields used for searching in "All fields" mode
     private $search_fields = [
         'name',
         'firstname',
         'surname',
         'middlename',
         'prefix',
         'suffix',
         'nickname',
         'jobtitle',
         'organization',
         'department',
         'email',
         'phone',
         'address',
 //        'profession',
         'manager',
         'assistant',
         'spouse',
-        'children',
+//        'children',
         'notes',
     ];
 
 
     /**
      * Object constructor
      */
     public function __construct($dav_folder = null)
     {
         $this->storage = $dav_folder;
         $this->ready   = !empty($this->storage);
 
         // Set readonly and rights flags according to folder permissions
         if ($this->ready) {
             if ($this->storage->get_owner() == $_SESSION['username']) {
                 $this->readonly = false;
                 $this->rights = 'lrswikxtea';
             }
             else {
                 $rights = $this->storage->get_myrights();
                 if ($rights && !PEAR::isError($rights)) {
                     $this->rights = $rights;
                     if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) {
                         $this->readonly = false;
                     }
                 }
             }
         }
 
         $this->action = rcube::get_instance()->action;
     }
 
     /**
      * Getter for the address book name to be displayed
      *
      * @return string Name of this address book
      */
     public function get_name()
     {
         return $this->storage->get_name();
     }
 
     /**
      * Wrapper for kolab_storage_folder::get_foldername()
      */
     public function get_foldername()
     {
         return $this->storage->get_foldername();
     }
 
     /**
      * Getter for the folder name
      *
      * @return string Name of the folder
      */
     public function get_realname()
     {
         return $this->get_name();
     }
 
     /**
      * Getter for the name of the namespace to which the IMAP folder belongs
      *
      * @return string Name of the namespace (personal, other, shared)
      */
     public function get_namespace()
     {
         if ($this->namespace === null && $this->ready) {
             $this->namespace = $this->storage->get_namespace();
         }
 
         return $this->namespace;
     }
 
     /**
      * Getter for parent folder path
      *
      * @return string Full path to parent folder
      */
     public function get_parent()
     {
         return $this->storage->get_parent();
     }
 
     /**
      * Check subscription status of this folder
      *
      * @return boolean True if subscribed, false if not
      */
     public function is_subscribed()
     {
         return true;
     }
 
     /**
      * Compose an URL for CardDAV access to this address book (if configured)
      */
     public function get_carddav_url()
     {
 /*
         $rcmail = rcmail::get_instance();
         if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) {
             return strtr($template, [
                     '%h' => $_SERVER['HTTP_HOST'],
                     '%u' => urlencode($rcmail->get_user_name()),
                     '%i' => urlencode($this->storage->get_uid()),
                     '%n' => urlencode($this->imap_folder),
             ]);
         }
 */
         return false;
     }
 
     /**
      * Setter for the current group
      */
     public function set_group($gid)
     {
         $this->gid = $gid;
     }
 
     /**
      * Save a search string for future listings
      *
      * @param mixed Search params to use in listing method, obtained by get_search_set()
      */
     public function set_search_set($filter)
     {
         $this->filter = $filter;
     }
 
     /**
      * Getter for saved search properties
      *
      * @return mixed Search properties used by this class
      */
     public function get_search_set()
     {
         return $this->filter;
     }
 
     /**
      * Reset saved results and search parameters
      */
     public function reset()
     {
         $this->result = null;
         $this->filter = null;
     }
 
     /**
      * List all active contact groups of this source
      *
      * @param string Optional search string to match group name
      * @param int    Search mode. Sum of self::SEARCH_*
      *
      * @return array Indexed list of contact groups, each a hash array
      */
     function list_groups($search = null, $mode = 0)
     {
         $this->_fetch_groups();
         $groups = [];
 
         foreach ((array)$this->distlists as $group) {
             if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) {
                 $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']];
             }
         }
 
         // sort groups by name
         uasort($groups, function($a, $b) { return strcoll($a['name'], $b['name']); });
 
         return array_values($groups);
     }
 
     /**
      * List the current set of contact records
      *
      * @param array List of cols to show
      * @param int   Only return this number of records, use negative values for tail
      * @param bool  True to skip the count query (select only)
      *
      * @return array Indexed list of contact records, each a hash array
      */
     public function list_records($cols = null, $subset = 0, $nocount = false)
     {
         $this->result = new rcube_result_set(0, ($this->list_page-1) * $this->page_size);
 
         $fetch_all = false;
         $fast_mode = !empty($cols) && is_array($cols);
 
         // list member of the selected group
         if ($this->gid) {
             $this->_fetch_groups();
 
             $this->sortindex = [];
             $this->contacts  = [];
             $local_sortindex = [];
             $uids            = [];
 
             // get members with email specified
             foreach ((array)$this->distlists[$this->gid]['member'] as $member) {
                 // skip member that don't match the search filter
                 if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) {
                     continue;
                 }
 
                 if (!empty($member['uid'])) {
                     $uids[] = $member['uid'];
                 }
                 else if (!empty($member['email'])) {
                     $this->contacts[$member['ID']] = $member;
                     $local_sortindex[$member['ID']] = $this->_sort_string($member);
                     $fetch_all = true;
                 }
             }
 
             // get members by UID
             if (!empty($uids)) {
                 $this->_fetch_contacts($query = [['uid', '=', $uids]], $fetch_all ? false : count($uids), $fast_mode);
                 $this->sortindex = array_merge($this->sortindex, $local_sortindex);
             }
         }
         else if (is_array($this->filter['ids'])) {
             $ids = $this->filter['ids'];
             if (count($ids)) {
                 $uids = array_map([$this, 'id2uid'], $this->filter['ids']);
                 $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode);
             }
         }
         else {
             $this->_fetch_contacts($query = 'contact', true, $fast_mode);
         }
 
         if ($fetch_all) {
             // sort results (index only)
             asort($this->sortindex, SORT_LOCALE_STRING);
             $ids = array_keys($this->sortindex);
 
             // fill contact data into the current result set
             $this->result->count = count($ids);
             $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first;
             $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count);
 
             for ($i = $start_row; $i < $last_row; $i++) {
                 if (array_key_exists($i, $ids)) {
                     $idx = $ids[$i];
                     $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx]));
                 }
             }
         }
         else if (!empty($this->dataset)) {
             // get all records count, skip the query if possible
             if (!isset($query) || count($this->dataset) < $this->page_size) {
                 $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1);
             }
             else {
                 $this->result->count = $this->storage->count($query);
             }
 
             $start_row = $subset < 0 ? $this->page_size + $subset : 0;
             $last_row  = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count);
 
             for ($i = $start_row; $i < $last_row; $i++) {
                 $this->result->add($this->_to_rcube_contact($this->dataset[$i]));
             }
         }
 
         return $this->result;
     }
 
     /**
      * Search records
      *
      * @param mixed $fields   The field name of array of field names to search in
      * @param mixed $value    Search value (or array of values when $fields is array)
      * @param int   $mode     Matching mode:
      *                        0 - partial (*abc*),
      *                        1 - strict (=),
      *                        2 - prefix (abc*)
      *                        4 - include groups (if supported)
      * @param bool  $select   True if results are requested, False if count only
      * @param bool  $nocount  True to skip the count query (select only)
      * @param array $required List of fields that cannot be empty
      *
      * @return rcube_result_set List of contact records and 'count' value
      */
     public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
     {
         // search by ID
         if ($fields == $this->primary_key) {
             $ids    = !is_array($value) ? explode(',', $value) : $value;
             $result = new rcube_result_set();
 
             foreach ($ids as $id) {
                 if ($rec = $this->get_record($id, true)) {
                     $result->add($rec);
                     $result->count++;
                 }
             }
             return $result;
         }
         else if ($fields == '*') {
             $fields = $this->search_fields;
         }
 
         if (!is_array($fields)) {
             $fields = [$fields];
         }
         if (!is_array($required) && !empty($required)) {
             $required = [$required];
         }
 
         // advanced search
         if (is_array($value)) {
             $advanced = true;
             $value = array_map('mb_strtolower', $value);
         }
         else {
             $value = mb_strtolower($value);
         }
 
         $scount = count($fields);
         // build key name regexp
         $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/';
 
         // pass query to storage if only indexed cols are involved
         // NOTE: this is only some rough pre-filtering but probably includes false positives
         $squery = $this->_search_query($fields, $value, $mode);
 
         // add magic selector to select contacts with birthday dates only
         if (in_array('birthday', $required)) {
             $squery[] = ['tags', '=', 'x-has-birthday'];
         }
 
         $squery[] = ['type', '=', 'contact'];
 
         // get all/matching records
         $this->_fetch_contacts($squery);
 
         // save searching conditions
         $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []];
 
         // search by iterating over all records in dataset
         foreach ($this->dataset as $record) {
             $contact = $this->_to_rcube_contact($record);
             $id = $contact['ID'];
 
             // check if current contact has required values, otherwise skip it
             if ($required) {
                 foreach ($required as $f) {
                     // required field might be 'email', but contact might contain 'email:home'
                     if (!($v = rcube_addressbook::get_col_values($f, $contact, true)) || empty($v)) {
                         continue 2;
                     }
                 }
             }
 
             $found    = [];
             $contents = '';
 
             foreach (preg_grep($regexp, array_keys($contact)) as $col) {
                 $pos     = strpos($col, ':');
                 $colname = $pos ? substr($col, 0, $pos) : $col;
 
                 foreach ((array)$contact[$col] as $val) {
                     if ($advanced) {
                         $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode);
                     }
                     else {
                         $contents .= ' ' . join(' ', (array)$val);
                     }
                 }
             }
 
             // compare matches
             if (($advanced && count($found) >= $scount) ||
                 (!$advanced && rcube_utils::words_match(mb_strtolower($contents), $value))) {
                 $this->filter['ids'][] = $id;
             }
         }
 
         // dummy result with contacts count
         if (!$select) {
             return new rcube_result_set(count($this->filter['ids']), ($this->list_page-1) * $this->page_size);
         }
 
         // list records (now limited by $this->filter)
         return $this->list_records();
     }
 
     /**
      * Refresh saved search results after data has changed
      */
     public function refresh_search()
     {
         if ($this->filter) {
             $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']);
         }
 
         return $this->get_search_set();
     }
 
     /**
      * Count number of available contacts in database
      *
      * @return rcube_result_set Result set with values for 'count' and 'first'
      */
     public function count()
     {
         if ($this->gid) {
             $this->_fetch_groups();
             $count = count($this->distlists[$this->gid]['member']);
         }
         else if (is_array($this->filter['ids'])) {
             $count = count($this->filter['ids']);
         }
         else {
             $count = $this->storage->count('contact');
         }
 
         return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
     }
 
     /**
      * Return the last result set
      *
      * @return rcube_result_set Current result set or NULL if nothing selected yet
      */
     public function get_result()
     {
         return $this->result;
     }
 
     /**
      * Get a specific contact record
      *
      * @param mixed Record identifier(s)
      * @param bool  True to return record as associative array, otherwise a result set is returned
      *
      * @return mixed Result object with all record fields or False if not found
      */
     public function get_record($id, $assoc = false)
     {
         $rec = null;
         $uid = $this->id2uid($id);
         $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC);
 
         if (strpos($uid, 'mailto:') === 0) {
             $this->_fetch_groups(true);
             $rec = $this->contacts[$id];
             $this->readonly = true;  // set source to read-only
         }
 /*
         else if (!empty($rev)) {
             $rcmail = rcube::get_instance();
             $plugin = $rcmail->plugins->get_plugin('kolab_addressbook');
             if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) {
                 $rec = $this->_to_rcube_contact($object);
                 $rec['rev'] = $rev;
             }
             $this->readonly = true;  // set source to read-only
         }
 */
         else if ($object = $this->storage->get_object($uid)) {
             $rec = $this->_to_rcube_contact($object);
         }
 
         if ($rec) {
             $this->result = new rcube_result_set(1);
             $this->result->add($rec);
             return $assoc ? $rec : $this->result;
         }
 
         return false;
     }
 
     /**
      * Get group assignments of a specific contact record
      *
      * @param mixed Record identifier
      *
      * @return array List of assigned groups as ID=>Name pairs
      */
     public function get_record_groups($id)
     {
         $out = [];
         $this->_fetch_groups();
 
         if (!empty($this->groupmembers[$id])) {
             foreach ((array) $this->groupmembers[$id] as $gid) {
                 if (!empty($this->distlists[$gid])) {
                     $group = $this->distlists[$gid];
                     $out[$gid] = $group['name'];
                 }
             }
         }
 
         return $out;
     }
 
     /**
      * Create a new contact record
      *
      * @param array Associative array with save data
      *  Keys:   Field name with optional section in the form FIELD:SECTION
      *  Values: Field value. Can be either a string or an array of strings for multiple values
      * @param bool  True to check for duplicates first
      *
      * @return mixed The created record ID on success, False on error
      */
     public function insert($save_data, $check = false)
     {
         if (!is_array($save_data)) {
             return false;
         }
 
         $insert_id = $existing = false;
 
         // check for existing records by e-mail comparison
         if ($check) {
             foreach ($this->get_col_values('email', $save_data, true) as $email) {
                 if (($res = $this->search('email', $email, true, false)) && $res->count) {
                     $existing = true;
                     break;
                 }
             }
         }
 
         if (!$existing) {
             // Unset contact ID (e.g. when copying/moving from another addressbook)
             unset($save_data['ID'], $save_data['uid'], $save_data['_type']);
 
             // generate new Kolab contact item
             $object = $this->_from_rcube_contact($save_data);
             $saved  = $this->storage->save($object, 'contact');
 
             if (!$saved) {
                 rcube::raise_error([
                         'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
                         'message' => "Error saving contact object to CardDAV server"
                     ],
                     true, false);
             }
             else {
                 $insert_id = $object['uid'];
             }
         }
 
         return $insert_id;
     }
 
     /**
      * Update a specific contact record
      *
      * @param mixed Record identifier
      * @param array Associative array with save data
      *  Keys:   Field name with optional section in the form FIELD:SECTION
      *  Values: Field value. Can be either a string or an array of strings for multiple values
      *
      * @return bool True on success, False on error
      */
     public function update($id, $save_data)
     {
         $updated = false;
         if ($old = $this->storage->get_object($this->id2uid($id))) {
             $object = $this->_from_rcube_contact($save_data, $old);
 
             if (!$this->storage->save($object, 'contact', $old['uid'])) {
                 rcube::raise_error([
                         'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
                         'message' => "Error saving contact object to CardDAV server"
                     ],
                     true, false
                 );
             }
             else {
                 $updated = true;
 
                 // TODO: update data in groups this contact is member of
             }
         }
 
         return $updated;
     }
 
     /**
      * Mark one or more contact records as deleted
      *
      * @param array Record identifiers
      * @param bool  Remove record(s) irreversible (mark as deleted otherwise)
      *
      * @return int Number of records deleted
      */
     public function delete($ids, $force = true)
     {
         $this->_fetch_groups();
 
         if (!is_array($ids)) {
             $ids = explode(',', $ids);
         }
 
         $count = 0;
         foreach ($ids as $id) {
             if ($uid = $this->id2uid($id)) {
                 $is_mailto = strpos($uid, 'mailto:') === 0;
                 $deleted = $is_mailto || $this->storage->delete($uid, $force);
 
                 if (!$deleted) {
                     rcube::raise_error([
                             'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
                             'message' => "Error deleting a contact object $uid from the CardDAV server"
                         ],
                         true, false
                     );
                 }
                 else {
                     // remove from distribution lists
                     foreach ((array) $this->groupmembers[$id] as $gid) {
                         if (!$is_mailto || $gid == $this->gid) {
                             $this->remove_from_group($gid, $id);
                         }
                     }
 
                     // clear internal cache
                     unset($this->groupmembers[$id]);
                     $count++;
                 }
             }
         }
 
         return $count;
     }
 
     /**
      * Undelete one or more contact records.
      * Only possible just after delete (see 2nd argument of delete() method).
      *
      * @param array Record identifiers
      *
      * @return int Number of records restored
      */
     public function undelete($ids)
     {
         if (!is_array($ids)) {
             $ids = explode(',', $ids);
         }
 
         $count = 0;
         foreach ($ids as $id) {
             $uid = $this->id2uid($id);
             if ($this->storage->undelete($uid)) {
                 $count++;
             }
             else {
                 rcube::raise_error([
                         'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
                         'message' => "Error undeleting a contact object $uid from the CardDav server"
                     ],
                     true, false
                 );
             }
         }
 
         return $count;
     }
 
     /**
      * Remove all records from the database
      *
      * @param bool $with_groups Remove also groups
      */
     public function delete_all($with_groups = false)
     {
         if ($this->storage->delete_all()) {
             $this->contacts  = [];
             $this->sortindex = [];
             $this->dataset   = null;
             $this->result    = null;
         }
     }
 
     /**
      * Close connection to source
      * Called on script shutdown
      */
     public function close()
     {
         // NOP
     }
 
     /**
      * Create a contact group with the given name
      *
      * @param string The group name
      *
      * @return mixed False on error, array with record props in success
      */
     function create_group($name)
     {
         $this->_fetch_groups();
-        $result = false;
+
+        $rcube = rcube::get_instance();
 
         $list = [
-            'name' => $name,
+            'uid'    => strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)),
+            'name'   => $name,
+            'kind'   => 'group',
             'member' => [],
         ];
-        $saved = $this->storage->save($list, 'distribution-list');
+
+        $saved = $this->storage->save($list, 'contact');
 
         if (!$saved) {
             rcube::raise_error([
                     'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
-                    'message' => "Error saving distribution-list object to CardDAV server"
+                    'message' => "Error saving a contact group to CardDAV server"
                 ],
                 true, false
             );
+
             return false;
         }
-        else {
-            $id = $this->uid2id($list['uid']);
-            $this->distlists[$id] = $list;
-            $result = ['id' => $id, 'name' => $name];
-        }
 
-        return $result;
+        $id = $this->uid2id($list['uid']);
+
+        $this->distlists[$id] = $list;
+
+        return ['id' => $id, 'name' => $name];
     }
 
     /**
      * Delete the given group and all linked group members
      *
      * @param string Group identifier
      *
      * @return bool True on success, false if no data was changed
      */
     function delete_group($gid)
     {
         $this->_fetch_groups();
-        $result = false;
 
-        if ($list = $this->distlists[$gid]) {
-            $deleted = $this->storage->delete($list['uid']);
+        $list = $this->distlists[$gid];
+
+        if (!$list) {
+            return false;
         }
 
+        $deleted = $this->storage->delete($list['uid']);
+
         if (!$deleted) {
             rcube::raise_error([
                     'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
-                    'message' => "Error deleting distribution-list object from the CardDAV server"
+                    'message' => "Error deleting a contact group from the CardDAV server"
                 ],
                 true, false
             );
         }
-        else {
-            $result = true;
-        }
 
-        return $result;
+        return $deleted;
     }
 
     /**
      * Rename a specific contact group
      *
      * @param string Group identifier
      * @param string New name to set for this group
      * @param string New group identifier (if changed, otherwise don't set)
      *
-     * @return bool New name on success, false if no data was changed
+     * @return string|false New name on success, false if no data was changed
      */
     function rename_group($gid, $newname, &$newid)
     {
         $this->_fetch_groups();
+
         $list = $this->distlists[$gid];
 
-        if ($newname != $list['name']) {
-            $list['name'] = $newname;
-            $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+        if (!$list) {
+            return false;
+        }
+
+        if ($newname === $list['name']) {
+            return $newname;
         }
 
+        $list['name'] = $newname;
+        $saved = $this->storage->save($list, 'contact', $list['uid']);
+
         if (!$saved) {
             rcube::raise_error([
                     'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
-                    'message' => "Error saving distribution-list object to CardDAV server"
+                    'message' => "Error saving a contact group to CardDAV server"
                 ],
                 true, false
             );
+
             return false;
         }
 
         return $newname;
     }
 
     /**
      * Add the given contact records the a certain group
      *
      * @param string Group identifier
      * @param array  List of contact identifiers to be added
-     * @return int   Number of contacts added
+     *
+     * @return int Number of contacts added
      */
     function add_to_group($gid, $ids)
     {
         if (!is_array($ids)) {
             $ids = explode(',', $ids);
         }
 
         $this->_fetch_groups(true);
 
-        $list   = $this->distlists[$gid];
+        $list = $this->distlists[$gid];
+
+        if (!$list) {
+            return 0;
+        }
+
         $added  = 0;
         $uids   = [];
         $exists = [];
 
-        foreach ((array)$list['member'] as $member) {
+        foreach ((array) $list['member'] as $member) {
             $exists[] = $member['ID'];
         }
 
         // substract existing assignments from list
         $ids = array_unique(array_diff($ids, $exists));
 
         // add mailto: members
         foreach ($ids as $contact_id) {
             $uid = $this->id2uid($contact_id);
             if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) {
                 $list['member'][] = [
                     'email' => $contact['email'],
                     'name'  => $contact['name'],
                 ];
                 $this->groupmembers[$contact_id][] = $gid;
                 $added++;
             }
             else {
                 $uids[$uid] = $contact_id;
             }
         }
 
         // add members with UID
         if (!empty($uids)) {
             foreach ($uids as $uid => $contact_id) {
                 $list['member'][] = ['uid' => $uid];
                 $this->groupmembers[$contact_id][] = $gid;
                 $added++;
             }
         }
 
         if ($added) {
-            $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+            $saved = $this->storage->save($list, 'contact', $list['uid']);
         }
         else {
             $saved = true;
         }
 
         if (!$saved) {
             rcube::raise_error([
                     'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
-                    'message' => "Error saving distribution-list to CardDAV server"
+                    'message' => "Error saving a contact-group to CardDAV server"
                 ],
                 true, false
             );
 
             $added = false;
             $this->set_error(self::ERROR_SAVING, 'errorsaving');
         }
         else {
             $this->distlists[$gid] = $list;
         }
 
         return $added;
     }
 
     /**
      * Remove the given contact records from a certain group
      *
      * @param string Group identifier
      * @param array  List of contact identifiers to be removed
      *
      * @return bool
      */
     function remove_from_group($gid, $ids)
     {
-        if (!is_array($ids)) {
-            $ids = explode(',', $ids);
-        }
-
         $this->_fetch_groups();
-        if (!($list = $this->distlists[$gid])) {
+
+        $list = $this->distlists[$gid];
+
+        if (!$list) {
             return false;
         }
 
+        if (!is_array($ids)) {
+            $ids = explode(',', $ids);
+        }
+
         $new_member = [];
         foreach ((array) $list['member'] as $member) {
             if (!in_array($member['ID'], $ids)) {
                 $new_member[] = $member;
             }
         }
 
         // write distribution list back to server
         $list['member'] = $new_member;
-        $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+        $saved = $this->storage->save($list, 'contact', $list['uid']);
 
         if (!$saved) {
             rcube::raise_error([
                     'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
-                    'message' => "Error saving distribution-list object to CardDAV server"
+                    'message' => "Error saving a contact group to CardDAV server"
                 ],
                 true, false
             );
         }
         else {
             // remove group assigments in local cache
             foreach ($ids as $id) {
                 $j = array_search($gid, $this->groupmembers[$id]);
                 unset($this->groupmembers[$id][$j]);
             }
+
             $this->distlists[$gid] = $list;
+
             return true;
         }
 
         return false;
     }
 
     /**
      * Check the given data before saving.
      * If input not valid, the message to display can be fetched using get_error()
      *
      * @param array Associative array with contact data to save
      * @param bool  Attempt to fix/complete data automatically
      *
      * @return bool True if input is valid, False if not.
      */
     public function validate(&$save_data, $autofix = false)
     {
         // validate e-mail addresses
         $valid = parent::validate($save_data);
 
         // require at least one e-mail address if there's no name
         // (syntax check is already done)
         if ($valid) {
             if (!strlen($save_data['name'])
                 && !strlen($save_data['organization'])
                 && !array_filter($this->get_col_values('email', $save_data, true))
             ) {
                 $this->set_error('warning', 'kolab_addressbook.noemailnamewarning');
                 $valid = false;
             }
         }
 
         return $valid;
     }
 
     /**
      * Query storage layer and store records in private member var
      */
     private function _fetch_contacts($query = [], $limit = false, $fast_mode = false)
     {
         if (!isset($this->dataset) || !empty($query)) {
             if ($limit) {
                 $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size;
                 $this->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size);
             }
 
             $this->sortindex = [];
             $this->dataset   = $this->storage->select($query, $fast_mode);
 
             foreach ($this->dataset as $idx => $record) {
                 $contact = $this->_to_rcube_contact($record);
                 $this->sortindex[$idx] = $this->_sort_string($contact);
             }
         }
     }
 
     /**
      * Extract a string for sorting from the given contact record
      */
     private function _sort_string($rec)
     {
         $str = '';
 
         switch ($this->sort_col) {
         case 'name':
             $str = $rec['name'] . $rec['prefix'];
         case 'firstname':
             $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname'];
             break;
 
         case 'surname':
             $str = $rec['surname'] . $rec['firstname'] . $rec['middlename'];
             break;
 
         default:
             $str = $rec[$this->sort_col];
             break;
         }
 
         $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email'];
 
         return mb_strtolower($str);
     }
 
     /**
      * Return the cache table columns to order by
      */
     private function _sort_columns()
     {
         $sortcols = [];
 
         switch ($this->sort_col) {
         case 'name':
             $sortcols[] = 'name';
 
         case 'firstname':
             $sortcols[] = 'firstname';
             break;
 
         case 'surname':
             $sortcols[] = 'surname';
             break;
         }
 
         $sortcols[] = 'email';
         return $sortcols;
     }
 
     /**
-     * Read distribution-lists AKA groups from server
+     * Read contact groups from server
      */
     private function _fetch_groups($with_contacts = false)
     {
-        return; // TODO
-
         if (!isset($this->distlists)) {
             $this->distlists = $this->groupmembers = [];
-            foreach ($this->storage->select('distribution-list', true) as $record) {
+
+            // Set order (and LIMIT to skip the count(*) select)
+            $this->storage->set_order_and_limit(['name'], 200, 0);
+
+            foreach ($this->storage->select('group', true) as $record) {
                 $record['ID'] = $this->uid2id($record['uid']);
                 foreach ((array)$record['member'] as $i => $member) {
                     $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']);
                     $record['member'][$i]['ID'] = $mid;
                     $record['member'][$i]['readonly'] = empty($member['uid']);
                     $this->groupmembers[$mid][] = $record['ID'];
 
                     if ($with_contacts && empty($member['uid'])) {
                         $this->contacts[$mid] = $record['member'][$i];
                     }
                 }
+
                 $this->distlists[$record['ID']] = $record;
             }
+
+            $this->storage->set_order_and_limit($this->_sort_columns(), null, 0);
         }
     }
 
     /**
      * Encode object UID into a safe identifier
      */
     public function uid2id($uid)
     {
         return rtrim(strtr(base64_encode($uid), '+/', '-_'), '=');
     }
 
     /**
      * Convert Roundcube object identifier back into the original UID
      */
     public function id2uid($id)
     {
         return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT));
     }
 
     /**
      * Build SQL query for fulltext matches
      */
     private function _search_query($fields, $value, $mode)
     {
         $query = [];
         $cols  = [];
 
         $cols = array_intersect($fields, $this->fulltext_cols);
 
         if (count($cols)) {
             if ($mode & rcube_addressbook::SEARCH_STRICT) {
                 $prefix = '^'; $suffix = '$';
             }
             else if ($mode & rcube_addressbook::SEARCH_PREFIX) {
                 $prefix = '^'; $suffix = '';
             }
             else {
                 $prefix = ''; $suffix = '';
             }
 
             $search_string = is_array($value) ? join(' ', $value) : $value;
             foreach (rcube_utils::normalize_string($search_string, true) as $word) {
                 $query[] = ['words', 'LIKE', $prefix . $word . $suffix];
             }
         }
 
         return $query;
     }
 
     /**
      * Map fields from internal Kolab_Format to Roundcube contact format
      */
     private function _to_rcube_contact($record)
     {
         $record['ID'] = $this->uid2id($record['uid']);
 
         // remove empty fields
         $record = array_filter($record);
 
         // Set _type for proper icon on the list
         $record['_type'] = 'person';
 
         return $record;
     }
 
     /**
      * Map fields from Roundcube format to internal kolab_format_contact properties
      */
     private function _from_rcube_contact($contact, $old = [])
     {
         if (empty($contact['uid']) && !empty($contact['ID'])) {
             $contact['uid'] = $this->id2uid($contact['ID']);
         }
         else if (empty($contact['uid']) && !empty($old['uid'])) {
             $contact['uid'] = $old['uid'];
         }
         else if (empty($contact['uid'])) {
             $rcube = rcube::get_instance();
             $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16));
         }
 
         // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711)
         unset($contact['vcard']);
 
         return $contact;
     }
 }
diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php
index fecff647..1837ff0a 100644
--- a/plugins/libkolab/lib/kolab_storage_dav.php
+++ b/plugins/libkolab/lib/kolab_storage_dav.php
@@ -1,547 +1,547 @@
 <?php
 
 /**
  * Kolab storage class providing access to groupware objects on a *DAV server.
  *
  * @author Aleksander Machniak <machniak@apheleia-it.ch>
  *
  * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
  *
  * This program is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  * GNU Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class kolab_storage_dav
 {
     const ERROR_DAV_CONN       = 1;
     const ERROR_CACHE_DB       = 2;
     const ERROR_NO_PERMISSION  = 3;
     const ERROR_INVALID_FOLDER = 4;
 
     protected $dav;
     protected $url;
 
 
     /**
      * Object constructor
      */
     public function __construct($url)
     {
         $this->url = $url;
         $this->setup();
     }
 
     /**
      * Setup the environment
      */
     public function setup()
     {
         $rcmail = rcube::get_instance();
 
         $this->config = $rcmail->config;
         $this->dav = new kolab_dav_client($this->url);
     }
 
     /**
      * Get a list of storage folders for the given data type
      *
-     * @param string Data type to list folders for (contact,distribution-list,event,task,note)
+     * @param string Data type to list folders for (contact,event,task,note)
      *
      * @return array List of kolab_storage_dav_folder objects
      */
     public function get_folders($type)
     {
         // TODO: This should be cached
         $folders = $this->dav->listFolders($this->get_dav_type($type));
 
         if (is_array($folders)) {
             foreach ($folders as $idx => $folder) {
                 // Exclude some special folders
                 if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) {
                     unset($folders[$idx]);
                     continue;
                 }
 
                 $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type);
             }
 
 
             usort($folders, function ($a, $b) {
                 return strcoll($a->get_foldername(), $b->get_foldername());
             });
         }
 
         return $folders ?: [];
     }
 
     /**
      * Getter for the storage folder for the given type
      *
-     * @param string Data type to list folders for (contact,distribution-list,event,task,note)
+     * @param string Data type to list folders for (contact,event,task,note)
      *
      * @return object kolab_storage_dav_folder The folder object
      */
     public function get_default_folder($type)
     {
         // TODO: Not used
     }
 
     /**
      * Getter for a specific storage folder
      *
      * @param string $id   Folder to access
      * @param string $type Expected folder type
      *
      * @return ?object kolab_storage_folder The folder object
      */
     public function get_folder($id, $type)
     {
         foreach ($this->get_folders($type) as $folder) {
             if ($folder->id == $id) {
                 return $folder;
             }
         }
     }
 
     /**
      * Getter for a single Kolab object, identified by its UID.
      * This will search all folders storing objects of the given type.
      *
      * @param string Object UID
      * @param string Object type (contact,event,task,journal,file,note,configuration)
      *
      * @return array The Kolab object represented as hash array or false if not found
      */
     public function get_object($uid, $type)
     {
         // TODO
         return false;
     }
 
     /**
      * Execute cross-folder searches with the given query.
      *
      * @param array  Pseudo-SQL query as list of filter parameter triplets
      * @param string Folder type (contact,event,task,journal,file,note,configuration)
      * @param int    Expected number of records or limit (for performance reasons)
      *
      * @return array List of Kolab data objects (each represented as hash array)
      */
     public function select($query, $type, $limit = null)
     {
         $result = [];
 
         foreach ($this->get_folders($type) as $folder) {
             if ($limit) {
                 $folder->set_order_and_limit(null, $limit);
             }
 
             foreach ($folder->select($query) as $object) {
                 $result[] = $object;
             }
         }
 
         return $result;
     }
 
     /**
      * Compose an URL to query the free/busy status for the given user
      *
      * @param string Email address of the user to get free/busy data for
      * @param object DateTime Start of the query range (optional)
      * @param object DateTime End of the query range (optional)
      *
      * @return string Fully qualified URL to query free/busy data
      */
     public static function get_freebusy_url($email, $start = null, $end = null)
     {
         return kolab_storage::get_freebusy_url($email, $start, $end);
     }
 
     /**
      * Deletes a folder
      *
      * @param string $id   Folder ID
      * @param string $type Folder type (contact,event,task,journal,file,note,configuration)
      *
      * @return bool True on success, false on failure
      */
     public function folder_delete($id, $type)
     {
         if ($folder = $this->get_folder($id, $type)) {
             return $this->dav->folderDelete($folder->href);
         }
 
         return false;
     }
 
     /**
      * Creates a folder
      *
      * @param string $name       Folder name (UTF7-IMAP)
      * @param string $type       Folder type
      * @param bool   $subscribed Sets folder subscription
      * @param bool   $active     Sets folder state (client-side subscription)
      *
      * @return bool True on success, false on failure
      */
     public function folder_create($name, $type = null, $subscribed = false, $active = false)
     {
         // TODO
     }
 
     /**
      * Renames DAV folder
      *
      * @param string $oldname Old folder name (UTF7-IMAP)
      * @param string $newname New folder name (UTF7-IMAP)
      *
      * @return bool True on success, false on failure
      */
     public function folder_rename($oldname, $newname)
     {
         // TODO ??
     }
 
     /**
      * Update or Create a new folder.
      *
      * Does additional checks for permissions and folder name restrictions
      *
      * @param array &$prop Hash array with folder properties and metadata
      *  - name:       Folder name
      *  - oldname:    Old folder name when changed
      *  - parent:     Parent folder to create the new one in
      *  - type:       Folder type to create
      *  - subscribed: Subscribed flag (IMAP subscription)
      *  - active:     Activation flag (client-side subscription)
      *
      * @return string|false New folder ID or False on failure
      */
     public function folder_update(&$prop)
     {
         // TODO: Folder hierarchies, active and subscribed state
 
         // sanity checks
         if (!isset($prop['name']) || !is_string($prop['name']) || !strlen($prop['name'])) {
             self::$last_error = 'cannotbeempty';
             return false;
         }
         else if (strlen($prop['name']) > 256) {
             self::$last_error = 'nametoolong';
             return false;
         }
 
         if (!empty($prop['id'])) {
             if ($folder = $this->get_folder($prop['id'], $prop['type'])) {
                 $result = $this->dav->folderUpdate($folder->href, $folder->get_dav_type(), $prop);
             }
             else {
                 $result = false;
             }
         }
         else {
             $rcube = rcube::get_instance();
             $uid   = rtrim(chunk_split(md5($prop['name'] . $rcube->get_user_name() . uniqid('-', true)), 12, '-'), '-');
             $type  = $this->get_dav_type($prop['type']);
             $home  = $this->dav->discover($type);
 
             if ($home === false) {
                 return false;
             }
 
             $location = unslashify($home) . '/' . $uid;
             $result   = $this->dav->folderCreate($location, $type, $prop);
 
             if ($result !== false) {
                 $result = md5($this->dav->url . '/' . $location);
             }
         }
 
         return $result;
     }
 
     /**
      * Getter for human-readable name of a folder
      *
      * @param string $folder    Folder name (UTF7-IMAP)
      * @param string $folder_ns Will be set to namespace name of the folder
      *
      * @return string Name of the folder-object
      */
     public static function object_name($folder, &$folder_ns = null)
     {
         // TODO: Shared folders
         $folder_ns = 'personal';
         return $folder;
     }
 
     /**
      * Creates a SELECT field with folders list
      *
      * @param string $type    Folder type
      * @param array  $attrs   SELECT field attributes (e.g. name)
      * @param string $current The name of current folder (to skip it)
      *
      * @return html_select SELECT object
      */
     public function folder_selector($type, $attrs, $current = '')
     {
         // TODO
     }
 
     /**
      * Returns a list of folder names
      *
      * @param string  Optional root folder
      * @param string  Optional name pattern
      * @param string  Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
      * @param bool    Enable to return subscribed folders only (null to use configured subscription mode)
      * @param array   Will be filled with folder-types data
      *
      * @return array List of folders
      */
     public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array())
     {
         // TODO
     }
 
     /**
      * Search for shared or otherwise not listed groupware folders the user has access
      *
      * @param string Folder type of folders to search for
      * @param string Search string
      * @param array  Namespace(s) to exclude results from
      *
      * @return array List of matching kolab_storage_folder objects
      */
     public function search_folders($type, $query, $exclude_ns = [])
     {
         // TODO
         return [];
     }
 
     /**
      * Sort the given list of folders by namespace/name
      *
      * @param array List of kolab_storage_dav_folder objects
      *
      * @return array Sorted list of folders
      */
     public static function sort_folders($folders)
     {
         // TODO
         return $folders;
     }
 
     /**
      * Returns folder types indexed by folder name
      *
      * @param string $prefix Folder prefix (Default '*' for all folders)
      *
      * @return array|bool List of folders, False on failure
      */
     public function folders_typedata($prefix = '*')
     {
         // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation
         return [];
     }
 
     /**
      * Returns type of a DAV folder
      *
      * @param string $folder Folder name (UTF7-IMAP)
      *
      * @return string Folder type
      */
     public function folder_type($folder)
     {
         // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation
         return '';
     }
 
     /**
      * Sets folder content-type.
      *
      * @param string $folder Folder name
      * @param string $type   Content type
      *
      * @return bool True on success, False otherwise
      */
     public function set_folder_type($folder, $type = 'mail')
     {
         // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation
         return false;
     }
 
     /**
      * Check subscription status of this folder
      *
      * @param string $folder Folder name
      * @param bool   $temp   Include temporary/session subscriptions
      *
      * @return bool True if subscribed, false if not
      */
     public function folder_is_subscribed($folder, $temp = false)
     {
         // NOP
         return true;
     }
 
     /**
      * Change subscription status of this folder
      *
      * @param string $folder Folder name
      * @param bool   $temp   Only subscribe temporarily for the current session
      *
      * @return True on success, false on error
      */
     public function folder_subscribe($folder, $temp = false)
     {
         // NOP
         return true;
     }
 
     /**
      * Change subscription status of this folder
      *
      * @param string $folder Folder name
      * @param bool   $temp  Only remove temporary subscription
      *
      * @return True on success, false on error
      */
     public function folder_unsubscribe($folder, $temp = false)
     {
         // NOP
         return false;
     }
 
     /**
      * Check activation status of this folder
      *
      * @param string $folder Folder name
      *
      * @return bool True if active, false if not
      */
     public function folder_is_active($folder)
     {
         return true; // TODO
     }
 
     /**
      * Change activation status of this folder
      *
      * @param string $folder Folder name
      *
      * @return True on success, false on error
      */
     public function folder_activate($folder)
     {
         return true; // TODO
     }
 
     /**
      * Change activation status of this folder
      *
      * @param string $folder Folder name
      *
      * @return True on success, false on error
      */
     public function folder_deactivate($folder)
     {
         return false; // TODO
     }
 
     /**
      * Creates default folder of specified type
      * To be run when none of subscribed folders (of specified type) is found
      *
      * @param string $type  Folder type
      * @param string $props Folder properties (color, etc)
      *
      * @return string Folder name
      */
     public function create_default_folder($type, $props = [])
     {
         // TODO: For kolab_addressbook??
         return '';
     }
 
     /**
      * Returns a list of IMAP folders shared by the given user
      *
      * @param array  User entry from LDAP
      * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
      * @param int    1 - subscribed folders only, 0 - all folders, 2 - all non-active
      * @param array  Will be filled with folder-types data
      *
      * @return array List of folders
      */
     public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = [])
     {
         // TODO
         return [];
     }
 
     /**
      * Get a list of (virtual) top-level folders from the other users namespace
      *
      * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
      * @param bool   Enable to return subscribed folders only (null to use configured subscription mode)
      *
      * @return array List of kolab_storage_folder_user objects
      */
     public function get_user_folders($type, $subscribed)
     {
         // TODO
         return [];
     }
 
     /**
      * Handler for user_delete plugin hooks
      *
      * Remove all cache data from the local database related to the given user.
      */
     public static function delete_user_folders($args)
     {
         $db = rcmail::get_instance()->get_dbh();
         $table  = $db->table_name('kolab_folders', true);
         $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%';
 
         $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix);
     }
 
     /**
      * Get folder METADATA for all supported keys
      * Do this in one go for better caching performance
      */
     public function folder_metadata($folder)
     {
         // TODO ?
         return [];
     }
 
     /**
      * Get a folder DAV content type
      */
     public static function get_dav_type($type)
     {
         $types = [
             'event' => 'VEVENT',
             'task'  => 'VTODO',
             'contact' => 'VCARD',
         ];
 
         return $types[$type];
     }
 }
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php
index 0ca118dd..ed55e21e 100644
--- a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php
@@ -1,113 +1,117 @@
 <?php
 
 /**
  * Kolab storage cache class for contact objects
  *
  * @author Aleksander Machniak <machniak@apcheleia-it.ch>
  *
  * Copyright (C) 2013-2022, Apheleia IT AG <contact@apcheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
  *
  * This program is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  * GNU Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class kolab_storage_dav_cache_contact extends kolab_storage_dav_cache
 {
     protected $extra_cols_max = 255;
     protected $extra_cols     = ['type', 'name', 'firstname', 'surname', 'email'];
     protected $data_props     = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member'];
     protected $fulltext_cols  = ['name', 'firstname', 'surname', 'middlename', 'email'];
 
     /**
      * Helper method to convert the given Kolab object into a dataset to be written to cache
      *
      * @override
      */
     protected function _serialize($object)
     {
         $sql_data = parent::_serialize($object);
         $sql_data['type'] = $object['_type'] ?: 'contact';
 
+        if ($sql_data['type'] == 'group' || (!empty($object['kind']) && $object['kind'] == 'group')) {
+            $sql_data['type'] = 'group';
+        }
+
         // columns for sorting
         $sql_data['name']      = rcube_charset::clean($object['name'] . $object['prefix']);
         $sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']);
         $sql_data['surname']   = rcube_charset::clean($object['surname']   . $object['firstname']  . $object['middlename']);
         $sql_data['email']     = '';
 
         foreach ($object as $colname => $value) {
             list($col, $field) = explode(':', $colname);
             if ($col == 'email' && !empty($value)) {
                 $sql_data['email'] = is_array($value) ? $value[0] : $value;
                 break;
             }
         }
 
         // use organization if name is empty
         if (empty($sql_data['name']) && !empty($object['organization'])) {
             $sql_data['name'] = rcube_charset::clean($object['organization']);
         }
 
         // make sure some data is not longer that database limit (#5291)
         foreach ($this->extra_cols as $col) {
             if (strlen($sql_data[$col]) > $this->extra_cols_max) {
                 $sql_data[$col] = rcube_charset::clean(substr($sql_data[$col], 0,  $this->extra_cols_max));
             }
         }
 
         $sql_data['tags']  = ' ' . join(' ', $this->get_tags($object)) . ' ';  // pad with spaces for strict/prefix search
         $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' ';
 
         return $sql_data;
     }
 
     /**
      * Callback to get words to index for fulltext search
      *
      * @return array List of words to save in cache
      */
     public function get_words($object)
     {
         $data = '';
 
         foreach ($object as $colname => $value) {
             list($col, $field) = explode(':', $colname);
 
             $val = '';
             if (in_array($col, $this->fulltext_cols)) {
                 $val = is_array($value) ? join(' ', $value) : $value;
             }
 
             if (strlen($val)) {
                 $data .= $val . ' ';
             }
         }
 
         return array_unique(rcube_utils::normalize_string($data, true));
     }
 
     /**
      * Callback to get object specific tags to cache
      *
      * @return array List of tags to save in cache
      */
     public function get_tags($object)
     {
         $tags = [];
 
         if (!empty($object['birthday'])) {
             $tags[] = 'x-has-birthday';
         }
 
         return $tags;
     }
 }
diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php
index a588b95b..a8ea5794 100644
--- a/plugins/libkolab/lib/kolab_storage_dav_folder.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php
@@ -1,587 +1,672 @@
 <?php
 
 /**
  * A class representing a DAV folder object.
  *
  * @author Aleksander Machniak <machniak@apheleia-it.ch>
  *
  * Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
  *
  * This program is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  * GNU Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 class kolab_storage_dav_folder extends kolab_storage_folder
 {
     public $dav;
     public $href;
     public $attributes;
 
     /**
      * Object constructor
      */
     public function __construct($dav, $attributes, $type_annotation = '')
     {
         $this->attributes = $attributes;
 
         $this->href  = $this->attributes['href'];
         $this->id    = md5($this->dav->url . '/' . $this->href);
         $this->dav   = $dav;
         $this->valid = true;
 
         list($this->type, $suffix) = explode('.', $type_annotation);
         $this->default = $suffix == 'default';
         $this->subtype = $this->default ? '' : $suffix;
 
         // Init cache
         $this->cache = kolab_storage_dav_cache::factory($this);
     }
 
     /**
      * Returns the owner of the folder.
      *
      * @param bool Return a fully qualified owner name (i.e. including domain for shared folders)
      *
      * @return string The owner of this folder.
      */
     public function get_owner($fully_qualified = false)
     {
         // return cached value
         if (isset($this->owner)) {
             return $this->owner;
         }
 
         $rcube = rcube::get_instance();
         $this->owner = $rcube->get_user_name();
         $this->valid = true;
 
         // TODO: Support shared folders
 
         return $this->owner;
     }
 
     /**
      * Get a folder Etag identifier
      */
     public function get_ctag()
     {
         return $this->attributes['ctag'];
     }
 
     /**
      * Getter for the name of the namespace to which the folder belongs
      *
      * @return string Name of the namespace (personal, other, shared)
      */
     public function get_namespace()
     {
         // TODO: Support shared folders
         return 'personal';
     }
 
     /**
      * Get the display name value of this folder
      *
      * @return string Folder name
      */
     public function get_name()
     {
         return kolab_storage_dav::object_name($this->attributes['name']);
     }
 
     /**
      * Getter for the top-end folder name (not the entire path)
      *
      * @return string Name of this folder
      */
     public function get_foldername()
     {
         return $this->attributes['name'];
     }
 
     public function get_folder_info()
     {
         return []; // todo ?
     }
 
     /**
      * Getter for parent folder path
      *
      * @return string Full path to parent folder
      */
     public function get_parent()
     {
         // TODO
         return '';
     }
 
     /**
      * Compose a unique resource URI for this folder
      */
     public function get_resource_uri()
     {
         if (!empty($this->resource_uri)) {
             return $this->resource_uri;
         }
 
         // compose fully qualified resource uri for this instance
         $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url);
         $path = $this->href[0] == '/' ? $this->href : "/{$this->href}";
 
         $host_path = parse_url($host, PHP_URL_PATH);
         if ($host_path && strpos($path, $host_path) === 0) {
             $path = substr($path, strlen($host_path));
         }
 
         $this->resource_uri = unslashify($host) . $path;
 
         return $this->resource_uri;
     }
 
     /**
      * Getter for the Cyrus mailbox identifier corresponding to this folder
      * (e.g. user/john.doe/Calendar/Personal@example.org)
      *
      * @return string Mailbox ID
      */
     public function get_mailbox_id()
     {
         // TODO: This is used with Bonnie related features
         return '';
     }
 
     /**
      * Get the color value stored in metadata
      *
      * @param string Default color value to return if not set
      *
      * @return mixed Color value from the folder metadata or $default if not set
      */
     public function get_color($default = null)
     {
         return !empty($this->attributes['color']) ? $this->attributes['color'] : $default;
     }
 
     /**
      * Get ACL information for this folder
      *
      * @return string Permissions as string
      */
     public function get_myrights()
     {
         // TODO
         return '';
     }
 
     /**
      * Helper method to extract folder UID
      *
      * @return string Folder's UID
      */
     public function get_uid()
     {
         // TODO ???
         return '';
     }
 
     /**
      * Check activation status of this folder
      *
      * @return bool True if enabled, false if not
      */
     public function is_active()
     {
         return true; // Unused
     }
 
     /**
      * Change activation status of this folder
      *
      * @param bool The desired subscription status: true = active, false = not active
      *
      * @return bool True on success, false on error
      */
     public function activate($active)
     {
         return true; // Unused
     }
 
     /**
      * Check subscription status of this folder
      *
      * @return bool True if subscribed, false if not
      */
     public function is_subscribed()
     {
         return true; // TODO
     }
 
     /**
      * Change subscription status of this folder
      *
      * @param bool The desired subscription status: true = subscribed, false = not subscribed
      *
      * @return True on success, false on error
      */
     public function subscribe($subscribed)
     {
         return true; // TODO
     }
 
     /**
      * Delete the specified object from this folder.
      *
      * @param array|string $object  The Kolab object to delete or object UID
      * @param bool         $expunge Should the folder be expunged?
      *
      * @return bool True if successful, false on error
      */
     public function delete($object, $expunge = true)
     {
         if (!$this->valid) {
             return false;
         }
 
         $uid = is_array($object) ? $object['uid'] : $object;
 
         $success = $this->dav->delete($this->object_location($uid));
 
         if ($success) {
             $this->cache->set($uid, false);
         }
 
         return $success;
     }
 
     /**
      * Delete all objects in a folder.
      *
      * Note: This method is used by kolab_addressbook plugin only
      *
      * @return bool True if successful, false on error
      */
     public function delete_all()
     {
         if (!$this->valid) {
             return false;
         }
 
         // TODO: Maybe just deleting and re-creating a folder would be
         //       better, but probably might not always work (ACL)
 
         $this->cache->synchronize();
 
         foreach (array_keys($this->cache->folder_index()) as $uid) {
             $this->dav->delete($this->object_location($uid));
         }
 
         $this->cache->purge();
 
         return true;
     }
 
     /**
      * Restore a previously deleted object
      *
      * @param string $uid Object UID
      *
      * @return mixed Message UID on success, false on error
      */
     public function undelete($uid)
     {
         if (!$this->valid) {
             return false;
         }
 
         // TODO
 
         return false;
     }
 
     /**
      * Move a Kolab object message to another IMAP folder
      *
      * @param string Object UID
      * @param string IMAP folder to move object to
      *
      * @return bool True on success, false on failure
      */
     public function move($uid, $target_folder)
     {
         if (!$this->valid) {
             return false;
         }
 
         // TODO
 
         return false;
     }
 
     /**
      * Save an object in this folder.
      *
      * @param array  $object The array that holds the data of the object.
      * @param string $type   The type of the kolab object.
      * @param string $uid    The UID of the old object if it existed before
      *
      * @return mixed False on error or object UID on success
      */
     public function save(&$object, $type = null, $uid = null)
     {
         if (!$this->valid || empty($object)) {
             return false;
         }
 
         if (!$type) {
             $type = $this->type;
         }
 /*
         // copy attachments from old message
         $copyfrom = $object['_copyfrom'] ?: $object['_msguid'];
         if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) {
             foreach ((array)$old['_attachments'] as $key => $att) {
                 if (!isset($object['_attachments'][$key])) {
                     $object['_attachments'][$key] = $old['_attachments'][$key];
                 }
                 // unset deleted attachment entries
                 if ($object['_attachments'][$key] == false) {
                     unset($object['_attachments'][$key]);
                 }
                 // load photo.attachment from old Kolab2 format to be directly embedded in xcard block
                 else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) {
                     if (!isset($object['photo']))
                         $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']);
                     unset($object['_attachments'][$key]);
                 }
             }
         }
 
         // process attachments
         if (is_array($object['_attachments'])) {
             $numatt = count($object['_attachments']);
             foreach ($object['_attachments'] as $key => $attachment) {
                 // FIXME: kolab_storage and Roundcube attachment hooks use different fields!
                 if (empty($attachment['content']) && !empty($attachment['data'])) {
                     $attachment['content'] = $attachment['data'];
                     unset($attachment['data'], $object['_attachments'][$key]['data']);
                 }
 
                 // make sure size is set, so object saved in cache contains this info
                 if (!isset($attachment['size'])) {
                     if (!empty($attachment['content'])) {
                         if (is_resource($attachment['content'])) {
                             // this need to be a seekable resource, otherwise
                             // fstat() failes and we're unable to determine size
                             // here nor in rcube_imap_generic before IMAP APPEND
                             $stat = fstat($attachment['content']);
                             $attachment['size'] = $stat ? $stat['size'] : 0;
                         }
                         else {
                             $attachment['size'] = strlen($attachment['content']);
                         }
                     }
                     else if (!empty($attachment['path'])) {
                         $attachment['size'] = filesize($attachment['path']);
                     }
                     $object['_attachments'][$key] = $attachment;
                 }
 
                 // generate unique keys (used as content-id) for attachments
                 if (is_numeric($key) && $key < $numatt) {
                     // derrive content-id from attachment file name
                     $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null;
                     $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext));  // to 7bit ascii
                     if (!$basename) $basename = 'noname';
                     $cid = $basename . '.' . microtime(true) . $key . $ext;
 
                     $object['_attachments'][$cid] = $attachment;
                     unset($object['_attachments'][$key]);
                 }
             }
         }
 */
         $rcmail = rcube::get_instance();
         $result = false;
 
         // generate and save object message
         if ($content = $this->to_dav($object)) {
             $method   = $uid ? 'update' : 'create';
             $dav_type = $this->get_dav_type();
             $result   = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type);
 
             // Note: $result can be NULL if the request was successful, but ETag wasn't returned
             if ($result !== false) {
                 // insert/update object in the cache
                 $object['etag'] = $result;
                 $this->cache->save($object, $uid);
                 $result = true;
             }
         }
 
         return $result;
     }
 
     /**
      * Fetch the object the DAV server and convert to internal format
      *
      * @param string The object UID to fetch
      * @param string The object type expected (use wildcard '*' to accept all types)
      * @param string Unused (kept for compat. with the parent class)
      *
      * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found
      */
     public function read_object($uid, $type = null, $folder = null)
     {
         if (!$this->valid) {
             return false;
         }
 
         $href    = $this->object_location($uid);
         $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]);
 
         if (!is_array($objects) || count($objects) != 1) {
             rcube::raise_error([
                     'code' => 900,
                     'message' => "Failed to fetch {$href}"
                 ], true);
             return false;
         }
 
         return $this->from_dav($objects[0]);
     }
 
     /**
      * Convert DAV object into PHP array
      *
      * @param array Object data in kolab_dav_client::fetchData() format
      *
      * @return array Object properties
      */
     public function from_dav($object)
     {
         if ($this->type == 'event') {
             $ical = libcalendaring::get_ical();
             $events = $ical->import($object['data']);
 
             if (!count($events) || empty($events[0]['uid'])) {
                 return false;
             }
 
             $result = $events[0];
         }
         else if ($this->type == 'contact') {
             if (stripos($object['data'], 'BEGIN:VCARD') !== 0) {
                 return false;
             }
 
-            $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false);
+            // vCard properties not supported by rcube_vcard
+            $map = [
+                'uid'      => 'UID',
+                'kind'     => 'KIND',
+                'member'   => 'MEMBER',
+                'x-kind'   => 'X-ADDRESSBOOKSERVER-KIND',
+                'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER',
+            ];
+
+            // TODO: We should probably use Sabre/Vobject to parse the vCard
+
+            $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map);
 
             if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) {
                 $result = $vcard->get_assoc();
+
+                // Contact groups
+                if (!empty($result['x-kind']) && implode($result['x-kind']) == 'group') {
+                    $result['_type'] = 'group';
+                    $members = isset($result['x-member']) ? $result['x-member'] : [];
+                    unset($result['x-kind'], $result['x-member']);
+                }
+                else if (!empty($result['kind']) && implode($result['kind']) == 'group') {
+                    $result['_type'] = 'group';
+                    $members = isset($result['member']) ? $result['member'] : [];
+                    unset($result['kind'], $result['member']);
+                }
+
+                if (isset($members)) {
+                    $result['member'] = [];
+                    foreach ($members as $member) {
+                        if (strpos($member, 'urn:uuid:') === 0) {
+                            $result['member'][] = ['uid' => substr($member, 9)];
+                        }
+                        else if (strpos($member, 'mailto:') === 0) {
+                            $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7))));
+                            if (!empty($member['mailto'])) {
+                                $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']];
+                            }
+                        }
+                    }
+                }
+
+                if (!empty($result['uid'])) {
+                    $result['uid'] = preg_replace('/^urn:uuid:/', '', implode($result['uid']));
+                }
             }
             else {
                 return false;
             }
         }
 
         $result['etag'] = $object['etag'];
         $result['href'] = $object['href'];
         $result['uid']  = $object['uid'] ?: $result['uid'];
 
         return $result;
     }
 
     /**
      * Convert Kolab object into DAV format (iCalendar)
      */
     public function to_dav($object)
     {
         $result = '';
 
         if ($this->type == 'event') {
             $ical = libcalendaring::get_ical();
             if (!empty($object['exceptions'])) {
                 $object['recurrence']['EXCEPTIONS'] = $object['exceptions'];
             }
 
             $result = $ical->export([$object]);
         }
         else if ($this->type == 'contact') {
             // copy values into vcard object
-            $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']);
+            // TODO: We should probably use Sabre/Vobject to create the vCard
+
+            // vCard properties not supported by rcube_vcard
+            $map   = ['uid' => 'UID', 'kind' => 'KIND'];
+            $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map);
 
-            $vcard->set('groups', null);
+            if ((!empty($object['_type']) && $object['_type'] == 'group')
+                || (!empty($object['type']) && $object['type'] == 'group')
+            ) {
+                $object['kind'] = 'group';
+            }
 
             foreach ($object as $key => $values) {
                 list($field, $section) = rcube_utils::explode(':', $key);
 
                 // avoid casting DateTime objects to array
                 if (is_object($values) && is_a($values, 'DateTime')) {
                     $values = [$values];
                 }
 
                 foreach ((array) $values as $value) {
                     if (isset($value)) {
                         $vcard->set($field, $value, $section);
                     }
                 }
             }
 
             $result = $vcard->export(false);
+
+            if (!empty($object['kind']) && $object['kind'] == 'group') {
+                $members = '';
+                foreach ((array) $object['member'] as $member) {
+                    $value = null;
+                    if (!empty($member['uid'])) {
+                        $value = 'urn:uuid:' . $member['uid'];
+                    }
+                    else if (!empty($member['email']) && !empty($member['name'])) {
+                        $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email']));
+                    }
+                    else if (!empty($member['email'])) {
+                        $value = 'mailto:' . $member['email'];
+                    }
+
+                    if ($value) {
+                        $members .= "MEMBER:{$value}\r\n";
+                    }
+                }
+
+                if ($members) {
+                    $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result);
+                }
+
+                /**
+                    Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now
+
+                $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result);
+                $result = preg_replace('/\r\nN:[^\r]+/', '', $result);
+                $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result);
+                */
+
+                $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result);
+                $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result);
+            }
         }
 
         if ($result) {
             // The content must be UTF-8, otherwise if we try to fetch the object
             // from server XML parsing would fail.
             $result = rcube_charset::clean($result);
         }
 
         return $result;
     }
 
     protected function object_location($uid)
     {
         return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext();
     }
 
     /**
      * Get a folder DAV content type
      */
     public function get_dav_type()
     {
         return kolab_storage_dav::get_dav_type($this->type);
     }
 
     /**
      * Get a DAV file extension for specified Kolab type
      */
     public function get_dav_ext()
     {
         $types = [
             'event' => 'ics',
             'task'  => 'ics',
             'contact' => 'vcf',
         ];
 
         return $types[$this->type];
     }
 
     /**
      * Return folder name as string representation of this object
      *
-     * @return string Full IMAP folder name
+     * @return string Folder display name
      */
     public function __toString()
     {
         return $this->attributes['name'];
     }
 }