Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F256670
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
31 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/plugins/kolab_delegation/kolab_delegation_engine.php b/plugins/kolab_delegation/kolab_delegation_engine.php
index 9d621239..bb6a71b8 100644
--- a/plugins/kolab_delegation/kolab_delegation_engine.php
+++ b/plugins/kolab_delegation/kolab_delegation_engine.php
@@ -1,968 +1,968 @@
<?php
/**
* Kolab Delegation Engine
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
* @author Aleksander Machniak <machniak@kolabsys.com>
*
* Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com>
*
* 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_delegation_engine
{
public $context;
private $rc;
private $ldap;
private $ldap_filter;
private $ldap_delegate_field;
private $ldap_login_field;
private $ldap_name_field;
private $ldap_email_field;
private $ldap_org_field;
private $ldap_dn;
private $cache = array();
private $folder_types = array('mail', 'event', 'task');
const ACL_READ = 1;
const ACL_WRITE = 2;
/**
* Class constructor
*/
public function __construct()
{
$this->rc = rcube::get_instance();
}
/**
* Add delegate
*
* @param string|array $delegate Delegate DN (encoded) or delegate data (result of delegate_get())
* @param array $acl List of folder->right map
*
* @return string On error returns an error label, on success returns null
*/
public function delegate_add($delegate, $acl)
{
if (!is_array($delegate)) {
$delegate = $this->delegate_get($delegate);
}
$dn = $delegate['ID'];
if (empty($delegate) || empty($dn)) {
return 'createerror';
}
$list = $this->list_delegates();
$list = array_keys((array)$list);
$list = array_filter($list);
if (in_array($dn, $list)) {
return 'delegationexisterror';
}
// add delegate to the list
$list[] = $dn;
$list = array_map(array('kolab_ldap', 'dn_decode'), $list);
// update user record
$result = $this->user_update_delegates($list);
// Set ACL on folders
if ($result && !empty($acl)) {
$this->delegate_acl_update($delegate['uid'], $acl);
}
return $result ? null : 'createerror';
}
/**
* Set/Update ACL on delegator's folders
*
* @param string $uid Delegate authentication identifier
* @param array $acl List of folder->right map
* @param bool $update Update (remove) old rights
*
* @return string On error returns an error label, on success returns null
*/
public function delegate_acl_update($uid, $acl, $update = false)
{
$storage = $this->rc->get_storage();
$right_types = $this->right_types();
$folders = $update ? $this->list_folders($uid) : array();
foreach ($acl as $folder_name => $rights) {
$r = $right_types[$rights];
if ($r) {
$storage->set_acl($folder_name, $uid, $r);
}
else {
$storage->delete_acl($folder_name, $uid);
}
if (!empty($folders) && isset($folders[$folder_name])) {
unset($folders[$folder_name]);
}
}
foreach ($folders as $folder_name => $folder) {
if ($folder['rights']) {
$storage->delete_acl($folder_name, $uid);
}
}
}
/**
* Delete delgate
*
* @param string $dn Delegate DN (encoded)
* @param bool $acl_del Enable ACL deletion on delegator folders
*
* @return string On error returns an error label, on success returns null
*/
public function delegate_delete($dn, $acl_del = false)
{
$delegate = $this->delegate_get($dn);
$list = $this->list_delegates();
$user = $this->user();
if (empty($delegate) || !isset($list[$dn])) {
return 'deleteerror';
}
// remove delegate from the list
unset($list[$dn]);
$list = array_keys($list);
$list = array_map(array('kolab_ldap', 'dn_decode'), $list);
$user[$this->ldap_delegate_field] = $list;
// update user record
$result = $this->user_update_delegates($list);
// remove ACL
if ($result && $acl_del) {
$this->delegate_acl_update($delegate['uid'], array(), true);
}
return $result ? null : 'deleteerror';
}
/**
* Return delegate data
*
* @param string $dn Delegate DN (encoded)
*
* @return array Delegate record (ID, name, uid, imap_uid)
*/
public function delegate_get($dn)
{
// use internal cache so we not query LDAP more than once per request
if (!isset($this->cache[$dn])) {
$ldap = $this->ldap();
if (!$ldap || empty($dn)) {
return array();
}
// Get delegate
$user = $ldap->get_record(kolab_ldap::dn_decode($dn));
if (empty($user)) {
return array();
}
$delegate = $this->parse_ldap_record($user);
$delegate['ID'] = $dn;
$this->cache[$dn] = $delegate;
}
return $this->cache[$dn];
}
/**
* Return delegate data
*
* @param string $login Delegate name (the 'uid' returned in get_users())
*
* @return array Delegate record (ID, name, uid, imap_uid)
*/
public function delegate_get_by_name($login)
{
$ldap = $this->ldap();
if (!$ldap || empty($login)) {
return array();
}
$list = $ldap->dosearch($this->ldap_login_field, $login, 1);
if (count($list) == 1) {
$dn = key($list);
$user = $list[$dn];
return $this->parse_ldap_record($user, $dn);
}
}
/**
* LDAP object getter
*/
private function ldap()
{
if ($this->ldap !== null) {
return $this->ldap;
}
$this->ldap = kolab_storage::ldap('kolab_delegation_addressbook');
if (!$this->ldap || !$this->ldap->ready) {
return null;
}
// Default filter of LDAP queries
$this->ldap_filter = $this->rc->config->get('kolab_delegation_filter', '(|(objectClass=kolabInetOrgPerson)(&(objectclass=kolabsharedfolder)(kolabFolderType=mail)))');
// Name of the LDAP field for delegates list
$this->ldap_delegate_field = $this->rc->config->get('kolab_delegation_delegate_field', 'kolabDelegate');
// Encoded LDAP DN of current user, set on login by kolab_auth plugin
$this->ldap_dn = $_SESSION['kolab_dn'];
// Name of the LDAP field with authentication ID
$this->ldap_login_field = $this->rc->config->get('kolab_delegation_login_field', $this->rc->config->get('kolab_auth_login'));
// Name of the LDAP field with user name used for identities
$this->ldap_name_field = $this->rc->config->get('kolab_delegation_name_field', $this->rc->config->get('kolab_auth_name'));
// Name of the LDAP field with email addresses used for identities
$this->ldap_email_field = $this->rc->config->get('kolab_delegation_email_field', $this->rc->config->get('kolab_auth_email'));
// Name of the LDAP field with organization name for identities
$this->ldap_org_field = $this->rc->config->get('kolab_delegation_organization_field', $this->rc->config->get('kolab_auth_organization'));
$this->ldap->set_filter($this->ldap_filter);
$this->ldap->extend_fieldmap(array($this->ldap_delegate_field => $this->ldap_delegate_field));
return $this->ldap;
}
/**
* List current user delegates
*/
public function list_delegates()
{
$result = array();
$ldap = $this->ldap();
$user = $this->user();
if (empty($ldap) || empty($user)) {
return array();
}
// Get delegates of current user
$delegates = $user[$this->ldap_delegate_field];
if (!empty($delegates)) {
foreach ((array)$delegates as $dn) {
$delegate = $ldap->get_record($dn);
$data = $this->parse_ldap_record($delegate, $dn);
if (!empty($data) && !empty($data['name'])) {
$result[$data['ID']] = $data['name'];
}
}
}
return $result;
}
/**
* List current user delegators
*
* @return array List of delegators
*/
public function list_delegators()
{
$result = array();
$ldap = $this->ldap();
if (empty($ldap) || empty($this->ldap_dn)) {
return array();
}
$list = $ldap->dosearch($this->ldap_delegate_field, $this->ldap_dn, 1);
foreach ($list as $dn => $delegator) {
$delegator = $this->parse_ldap_record($delegator, $dn);
$result[$delegator['ID']] = $delegator;
}
return $result;
}
/**
* List current user delegators in format compatible with Calendar plugin
*
* @return array List of delegators
*/
public function list_delegators_js()
{
$list = $this->list_delegators();
$result = array();
foreach ($list as $delegator) {
$name = $delegator['name'];
if ($pos = strrpos($name, '(')) {
$name = trim(substr($name, 0, $pos));
}
$result[$delegator['imap_uid']] = array(
'emails' => ';' . implode(';', $delegator['email']),
'email' => $delegator['email'][0],
'name' => $name,
);
}
return $result;
}
/**
* Prepare namespace prefixes for JS environment
*
* @return array List of prefixes
*/
public function namespace_js()
{
$storage = $this->rc->get_storage();
$ns = $storage->get_namespace('other');
if ($ns) {
foreach ($ns as $idx => $nsval) {
$ns[$idx] = kolab_storage::folder_id($nsval[0]);
}
}
return $ns;
}
/**
* Get all folders to which current user has admin access
*
* @param string $delegate IMAP user identifier
*
* @return array Folder type/rights
*/
public function list_folders($delegate = null)
{
$storage = $this->rc->get_storage();
$folders = $storage->list_folders();
$metadata = kolab_storage::folders_typedata();
$result = array();
if (!is_array($metadata)) {
return $result;
}
// Definition of read and write ACL
$right_types = $this->right_types();
- $delegate_lc = \strtolower($delegate);
+ $delegate_lc = strtolower($delegate);
foreach ($folders as $folder) {
// get only folders in personal namespace
if ($storage->folder_namespace($folder) != 'personal') {
continue;
}
$rights = null;
$type = $metadata[$folder] ?: 'mail';
list($class, $subclass) = explode('.', $type);
if (!in_array($class, $this->folder_types)) {
continue;
}
// in edit mode, get folder ACL
if ($delegate) {
// @TODO: cache ACL
- $acl = $storage->get_acl($folder);
- if (!empty($acl) && (($acl = $acl[$delegate]) || ($acl = $acl[$delegate_lc]))) {
+ $imap_acl = $storage->get_acl($folder);
+ if (!empty($imap_acl) && (($acl = $imap_acl[$delegate]) || ($acl = $imap_acl[$delegate_lc]))) {
if ($this->acl_compare($acl, $right_types[self::ACL_WRITE])) {
$rights = self::ACL_WRITE;
}
else if ($this->acl_compare($acl, $right_types[self::ACL_READ])) {
$rights = self::ACL_READ;
}
}
}
else if ($folder == 'INBOX' || $subclass == 'default' || $subclass == 'inbox') {
$rights = self::ACL_WRITE;
}
$result[$folder] = array(
'type' => $class,
'rights' => $rights,
);
}
return $result;
}
/**
* Returns list of users for autocompletion
*
* @param string $search Search string
*
* @return array Users list
*/
public function list_users($search)
{
$ldap = $this->ldap();
if (empty($ldap) || $search === '' || $search === null) {
return array();
}
$max = (int) $this->rc->config->get('autocomplete_max', 15);
$mode = (int) $this->rc->config->get('addressbook_search_mode');
$fields = array_unique(array_filter(array_merge((array)$this->ldap_name_field, (array)$this->ldap_login_field)));
$users = array();
$keys = array();
$result = $ldap->dosearch($fields, $search, $mode, (array)$this->ldap_login_field, $max);
foreach ($result as $record) {
// skip self
if ($record['dn'] == $_SESSION['kolab_dn']) {
continue;
}
$user = $this->parse_ldap_record($record);
if ($user['uid']) {
$display = rcube_addressbook::compose_search_name($record);
$user = array('name' => $user['uid'], 'display' => $display);
$users[] = $user;
$keys[] = $display ?: $user['uid'];
}
}
if (count($users)) {
// sort users index
asort($keys, SORT_LOCALE_STRING);
// re-sort users according to index
foreach (array_keys($keys) as $idx) {
$keys[$idx] = $users[$idx];
}
$users = array_values($keys);
}
return $users;
}
/**
* Extract delegate identifiers and pretty name from LDAP record
*/
private function parse_ldap_record($data, $dn = null)
{
$email = array();
$uid = $data[$this->ldap_login_field];
if (is_array($uid)) {
$uid = array_filter($uid);
$uid = $uid[0];
}
// User name for identity
foreach ((array)$this->ldap_name_field as $field) {
$name = is_array($data[$field]) ? $data[$field][0] : $data[$field];
if (!empty($name)) {
break;
}
}
// User email(s) for identity
foreach ((array)$this->ldap_email_field as $field) {
$user_email = is_array($data[$field]) ? array_filter($data[$field]) : $data[$field];
if (!empty($user_email)) {
$email = array_merge((array)$email, (array)$user_email);
}
}
// Organization for identity
foreach ((array)$this->ldap_org_field as $field) {
$organization = is_array($data[$field]) ? $data[$field][0] : $data[$field];
if (!empty($organization)) {
break;
}
}
$realname = $name;
if ($uid && $name) {
$name .= ' (' . $uid . ')';
}
else {
$name = $uid;
}
// get IMAP uid - identifier used in shared folder hierarchy
$imap_uid = $uid;
if ($pos = strpos($imap_uid, '@')) {
$imap_uid = substr($imap_uid, 0, $pos);
}
return array(
'ID' => kolab_ldap::dn_encode($dn),
'uid' => $uid,
'name' => $name,
'realname' => $realname,
'imap_uid' => $imap_uid,
'email' => $email,
'organization' => $organization,
);
}
/**
* Returns LDAP record of current user
*
* @return array User data
*/
public function user($parsed = false)
{
if (!isset($this->cache['user'])) {
$ldap = $this->ldap();
if (!$ldap) {
return array();
}
// Get current user record
$this->cache['user'] = $ldap->get_record($this->ldap_dn);
}
return $parsed ? $this->parse_ldap_record($this->cache['user']) : $this->cache['user'];
}
/**
* Update LDAP record of current user
*
* @param array List of delegates
*/
public function user_update_delegates($list)
{
$ldap = $this->ldap();
$pass = $this->rc->decrypt($_SESSION['password']);
if (!$ldap) {
return false;
}
// need to bind as self for sufficient privilages
if (!$ldap->bind($this->ldap_dn, $pass)) {
return false;
}
$user[$this->ldap_delegate_field] = $list;
unset($this->cache['user']);
// replace delegators list in user record
return $ldap->replace($this->ldap_dn, $user);
}
/**
* Manage delegation data on user login
*/
public function delegation_init()
{
// Fetch all delegators from LDAP who assigned the
// current user as their delegate and create identities
// a) if identity with delegator's email exists, continue
// b) create identity ($delegate on behalf of $delegator
// <$delegator-email>) for new delegators
// c) remove all other identities which do not match the user's primary
// or alias email if 'kolab_delegation_purge_identities' is set.
$delegators = $this->list_delegators();
$use_subs = $this->rc->config->get('kolab_use_subscriptions');
$identities = $this->rc->user->list_emails();
$emails = array();
$uids = array();
if (!empty($delegators)) {
$storage = $this->rc->get_storage();
$other_ns = $storage->get_namespace('other') ?: array();
$folders = $storage->list_folders();
}
// convert identities to simpler format for faster access
foreach ($identities as $idx => $ident) {
// get user name from default identity
if (!$idx) {
$default = array(
'name' => $ident['name'],
);
}
$emails[$ident['identity_id']] = $ident['email'];
}
// for every delegator...
foreach ($delegators as $delegator) {
$uids[$delegator['imap_uid']] = $email_arr = $delegator['email'];
$diff = array_intersect($emails, $email_arr);
// identity with delegator's email already exist, do nothing
if (count($diff)) {
$emails = array_diff($emails, $email_arr);
continue;
}
// create identities for delegator emails
foreach ($email_arr as $email) {
// @TODO: "Delegatorname" or "Username on behalf of Delegatorname"?
$default['name'] = $delegator['realname'];
$default['email'] = $email;
// Database field for organization is NOT NULL
$default['organization'] = empty($delegator['organization']) ? '' : $delegator['organization'];
$this->rc->user->insert_identity($default);
}
// IMAP folders shared by new delegators shall be subscribed on login,
// as well as existing subscriptions of previously shared folders shall
// be removed. I suppose the latter one is already done in Roundcube.
// for every accessible folder...
foreach ($folders as $folder) {
// for every 'other' namespace root...
foreach ($other_ns as $ns) {
$prefix = $ns[0] . $delegator['imap_uid'];
// subscribe delegator's folder
if ($folder === $prefix || strpos($folder, $prefix . substr($ns[0], -1)) === 0) {
// Event/Task folders need client-side activation
$type = kolab_storage::folder_type($folder);
if (preg_match('/^(event|task)/i', $type)) {
kolab_storage::folder_activate($folder);
}
// Subscribe to mail folders and (if system is configured
// to display only subscribed folders) to other
if ($use_subs || preg_match('/^mail/i', $type)) {
$storage->subscribe($folder);
}
}
}
}
}
// remove identities that "do not belong" to user nor delegators
if ($this->rc->config->get('kolab_delegation_purge_identities')) {
$user = $this->user(true);
$emails = array_diff($emails, $user['email']);
foreach (array_keys($emails) as $idx) {
$this->rc->user->delete_identity($idx);
}
}
$_SESSION['delegators'] = $uids;
}
/**
* Sets delegator context according to email message recipient
*
* @param rcube_message $message Email message object
*/
public function delegator_context_from_message($message)
{
if (empty($_SESSION['delegators'])) {
return;
}
// Match delegators' addresses with message To: address
// @TODO: Is this reliable enough?
// Roundcube sends invitations to every attendee separately,
// but maybe there's a software which sends with CC header or many addresses in To:
$emails = $message->get_header('to');
$emails = rcube_mime::decode_address_list($emails, null, false);
foreach ($emails as $email) {
foreach ($_SESSION['delegators'] as $uid => $addresses) {
if (in_array($email['mailto'], $addresses)) {
return $this->context = $uid;
}
}
}
}
/**
* Return (set) current delegator context
*
* @return string Delegator UID
*/
public function delegator_context()
{
if (!$this->context && !empty($_SESSION['delegators'])) {
$context = rcube_utils::get_input_value('_context', rcube_utils::INPUT_GPC);
if ($context && isset($_SESSION['delegators'][$context])) {
$this->context = $context;
}
}
return $this->context;
}
/**
* Set user identity according to delegator delegator
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_identity_filter(&$args)
{
$context = $this->delegator_context();
if (!$context) {
return;
}
$identities = $this->rc->user->list_emails();
$emails = $_SESSION['delegators'][$context];
foreach ($identities as $ident) {
if (in_array($ident['email'], $emails)) {
$args['identity'] = $ident;
return;
}
}
// fallback to default identity
$args['identity'] = array_shift($identities);
}
/**
* Filter user emails according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_emails_filter(&$args)
{
$context = $this->delegator_context();
// try to derive context from the given user email
if (!$context && !empty($args['emails'])) {
if (($user = preg_replace('/@.+$/', '', $args['emails'][0])) && isset($_SESSION['delegators'][$user])) {
$context = $user;
}
}
// return delegator's addresses
if ($context) {
$args['emails'] = $_SESSION['delegators'][$context];
$args['abort'] = true;
}
// return only user addresses (exclude all delegators addresses)
else if (!empty($_SESSION['delegators'])) {
$identities = $this->rc->user->list_emails();
$emails[] = $this->rc->user->get_username();
foreach ($identities as $identity) {
$emails[] = $identity['email'];
}
foreach ($_SESSION['delegators'] as $delegator_emails) {
$emails = array_diff($emails, $delegator_emails);
}
$args['emails'] = array_unique($emails);
$args['abort'] = true;
}
}
/**
* Filters list of calendar/task folders according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_folder_filter(&$args, $mode = 'calendars')
{
$context = $this->delegator_context();
if (empty($context)) {
return $args;
}
$storage = $this->rc->get_storage();
$other_ns = $storage->get_namespace('other') ?: array();
$delim = $storage->get_hierarchy_delimiter();
if ($mode == 'calendars') {
$editable = $args['filter'] & calendar_driver::FILTER_WRITEABLE;
$active = $args['filter'] & calendar_driver::FILTER_ACTIVE;
$personal = $args['filter'] & calendar_driver::FILTER_PERSONAL;
$shared = $args['filter'] & calendar_driver::FILTER_SHARED;
}
else {
$editable = $args['filter'] & tasklist_driver::FILTER_WRITEABLE;
$active = $args['filter'] & tasklist_driver::FILTER_ACTIVE;
$personal = $args['filter'] & tasklist_driver::FILTER_PERSONAL;
$shared = $args['filter'] & tasklist_driver::FILTER_SHARED;
}
$folders = array();
foreach ($args['list'] as $folder) {
if (isset($folder->ready) && !$folder->ready) {
continue;
}
if ($editable && !$folder->editable) {
continue;
}
if ($active && !$folder->storage->is_active()) {
continue;
}
if ($personal || $shared) {
$ns = $folder->get_namespace();
if ($personal && $ns == 'personal') {
continue;
}
else if ($personal && $ns == 'other') {
$found = false;
foreach ($other_ns as $ns) {
$c_folder = $ns[0] . $context . $delim;
if (strpos($folder->name, $c_folder) === 0) {
$found = true;
}
}
if (!$found) {
continue;
}
}
else if (!$shared || $ns != 'shared') {
continue;
}
}
$folders[$folder->id] = $folder;
}
$args[$mode] = $folders;
$args['abort'] = true;
}
/**
* Filters/updates message headers according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_delivery_filter(&$args)
{
// no context, but message still can be send on behalf of...
if (!empty($_SESSION['delegators'])) {
$message = $args['message'];
$headers = $message->headers();
// get email address from From: header
$from = rcube_mime::decode_address_list($headers['From']);
$from = array_shift($from);
$from = $from['mailto'];
foreach ($_SESSION['delegators'] as $uid => $addresses) {
if (in_array($from, $addresses)) {
$context = $uid;
break;
}
}
// add Sender: header with current user default identity
if ($context) {
$identity = $this->rc->user->get_identity();
$sender = format_email_recipient($identity['email'], $identity['name']);
$message->headers(array('Sender' => $sender), false, true);
}
}
}
/**
* Compares two ACLs (according to supported rights)
*
* @param array $acl1 ACL rights array (or string)
* @param array $acl2 ACL rights array (or string)
*
* @param bool True if $acl1 contains all rights from $acl2
*/
function acl_compare($acl1, $acl2)
{
if (!is_array($acl1)) $acl1 = str_split($acl1);
if (!is_array($acl2)) $acl2 = str_split($acl2);
$rights = $this->rights_supported();
$acl1 = array_intersect($acl1, $rights);
$acl2 = array_intersect($acl2, $rights);
$res = array_intersect($acl1, $acl2);
$cnt1 = count($res);
$cnt2 = count($acl2);
if ($cnt1 >= $cnt2) {
return true;
}
}
/**
* Get list of supported access rights (according to RIGHTS capability)
*
* @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap
*
* @return array List of supported access rights abbreviations
*/
public function rights_supported()
{
if ($this->supported !== null) {
return $this->supported;
}
$storage = $this->rc->get_storage();
$capa = $storage->get_capability('RIGHTS');
if (is_array($capa)) {
$rights = strtolower($capa[0]);
}
else {
$rights = 'cd';
}
return $this->supported = str_split('lrswi' . $rights . 'pa');
}
private function right_types()
{
// Get supported rights and build column names
$supported = $this->rights_supported();
// depending on server capability either use 'te' or 'd' for deleting msgs
$deleteright = implode(array_intersect(str_split('ted'), $supported));
return array(
self::ACL_READ => 'lrs',
self::ACL_WRITE => 'lrswi'.$deleteright,
);
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Jun 9, 3:37 AM (1 d, 9 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
196742
Default Alt Text
(31 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment