diff --git a/plugins/kolab_activesync/kolab_activesync.php b/plugins/kolab_activesync/kolab_activesync.php index 0ac8fc92..98a22d86 100644 --- a/plugins/kolab_activesync/kolab_activesync.php +++ b/plugins/kolab_activesync/kolab_activesync.php @@ -1,585 +1,585 @@ * @author Thomas Bruederli * * Copyright (C) 2011-2013, Kolab Systems AG * * 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 . */ class kolab_activesync extends rcube_plugin { public $task = 'settings'; public $urlbase; public $backend; private $rc; private $ui; private $folder_meta; private $root_meta; public const ROOT_MAILBOX = 'INBOX'; public const ASYNC_KEY = '/private/vendor/kolab/activesync'; /** * Plugin initialization. */ public function init() { $this->rc = rcmail::get_instance(); $this->require_plugin('libkolab'); $this->register_action('plugin.activesync', [$this, 'config_view']); $this->register_action('plugin.activesync-config', [$this, 'config_frame']); $this->register_action('plugin.activesync-json', [$this, 'json_command']); $this->add_hook('settings_actions', [$this, 'settings_actions']); $this->add_hook('folder_form', [$this, 'folder_form']); $this->add_texts('localization/'); if (preg_match('/^(plugin.activesync|edit-folder|save-folder)/', $this->rc->action)) { $this->add_label('devicedeleteconfirm', 'savingdata'); $this->include_script('kolab_activesync.js'); } } /** * Adds Activesync section in Settings */ public function settings_actions($args) { $args['actions'][] = [ 'action' => 'plugin.activesync', 'class' => 'activesync', 'label' => 'tabtitle', 'domain' => 'kolab_activesync', 'title' => 'activesynctitle', ]; return $args; } /** * Handler for folder info/edit form (folder_form hook). * Adds ActiveSync section. */ public function folder_form($args) { $mbox_imap = $args['options']['name'] ?? ''; // Edited folder name (empty in create-folder mode) if (!strlen($mbox_imap)) { return $args; } $devices = $this->list_devices(); // no registered devices if (empty($devices)) { return $args; } [$type, ] = explode('.', (string) kolab_storage::folder_type($mbox_imap)); if ($type && !in_array($type, ['mail', 'event', 'contact', 'task', 'note'])) { return $args; } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); if ($content = $this->ui->folder_options_table($mbox_imap, $devices, $type)) { $args['form']['activesync'] = [ 'name' => $this->gettext('tabtitle'), 'content' => $content, ]; } return $args; } /** * Handle JSON requests */ public function json_command() { $cmd = rcube_utils::get_input_value('cmd', rcube_utils::INPUT_POST); $imei = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); switch ($cmd) { case 'save': $devices = $this->list_devices(); $device = $devices[$imei]; $subscriptions = (array) rcube_utils::get_input_value('subscribed', rcube_utils::INPUT_POST); $devicealias = rcube_utils::get_input_value('devicealias', rcube_utils::INPUT_POST, true); $device['ALIAS'] = $devicealias; $err = !$this->device_update($device, $imei); if (!$err) { // iterate over folders list and update metadata if necessary // old subscriptions foreach (array_keys($this->folder_meta()) as $folder) { - $err |= !$this->folder_set($folder, $imei, intval($subscriptions[$folder])); + $err |= !$this->folder_set($folder, $imei, intval($subscriptions[$folder] ?? 0)); unset($subscriptions[$folder]); } // new subscription foreach ($subscriptions as $folder => $flag) { $err |= !$this->folder_set($folder, $imei, intval($flag)); } $this->rc->output->command('plugin.activesync_save_complete', [ 'success' => !$err, 'id' => $imei, 'alias' => rcube::Q($devicealias)]); } if ($err) { $this->rc->output->show_message($this->gettext('savingerror'), 'error'); } else { $this->rc->output->show_message($this->gettext('successfullysaved'), 'confirmation'); } break; case 'delete': foreach ((array) $imei as $id) { $success = $this->device_delete($id); } if (!empty($success)) { $this->rc->output->show_message($this->gettext('successfullydeleted'), 'confirmation'); $this->rc->output->command('plugin.activesync_save_complete', [ 'success' => true, 'delete' => true, 'id' => count($imei) > 1 ? 'ALL' : $imei[0], ]); } else { $this->rc->output->show_message($this->gettext('savingerror'), 'error'); } break; case 'update': $subscription = (int) rcube_utils::get_input_value('flag', rcube_utils::INPUT_POST); $folder = rcube_utils::get_input_value('folder', rcube_utils::INPUT_POST); $err = !$this->folder_set($folder, $imei, $subscription); if ($err) { $this->rc->output->show_message($this->gettext('savingerror'), 'error'); } else { $this->rc->output->show_message($this->gettext('successfullysaved'), 'confirmation'); } break; } $this->rc->output->send(); } /** * Render main UI for devices configuration */ public function config_view() { $storage = $this->rc->get_storage(); // checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 if (!($storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'))) { $this->rc->output->show_message($this->gettext('notsupported'), 'error'); } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); $this->register_handler('plugin.devicelist', [$this->ui, 'device_list']); $this->rc->output->send('kolab_activesync.config'); } /** * Render device configuration form */ public function config_frame() { $storage = $this->rc->get_storage(); // checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 if (!($storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'))) { $this->rc->output->show_message($this->gettext('notsupported'), 'error'); } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); if (!empty($_GET['_init'])) { return $this->ui->init_message(); } $this->register_handler('plugin.deviceconfigform', [$this->ui, 'device_config_form']); $this->register_handler('plugin.foldersubscriptions', [$this->ui, 'folder_subscriptions']); $imei = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $devices = $this->list_devices(); if ($device = $devices[$imei]) { $this->ui->device = $device; $this->ui->device['_id'] = $imei; $this->rc->output->set_env('active_device', $imei); $this->rc->output->command('parent.enable_command', 'plugin.delete-device', true); } else { $this->rc->output->show_message($this->gettext('devicenotfound'), 'error'); } $this->rc->output->send('kolab_activesync.configedit'); } /** * Get list of all folders available for sync * * @return array List of mailbox folders */ public function list_folders() { $storage = $this->rc->get_storage(); return $storage->list_folders(); } /** * List known devices * * @return array Device list as hash array */ public function list_devices() { if ($this->root_meta === null) { $storage = $this->rc->get_storage(); // @TODO: consider server annotation instead of INBOX if ($meta = $storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); } else { $this->root_meta = []; } } if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { return $this->root_meta['DEVICE']; } return []; } /** * Getter for folder metadata * * @return array Hash array with meta data for each folder */ public function folder_meta() { if (!isset($this->folder_meta)) { $this->folder_meta = []; $storage = $this->rc->get_storage(); // get folders activesync config $folderdata = $storage->get_metadata("*", self::ASYNC_KEY); foreach ($folderdata as $folder => $meta) { if ($asyncdata = $meta[self::ASYNC_KEY]) { if ($metadata = $this->unserialize_metadata($asyncdata)) { $this->folder_meta[$folder] = $metadata; } } } } return $this->folder_meta; } /** * Sets ActiveSync subscription flag on a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $deviceid Device identifier * @param int $flag Flag value (0|1|2) */ public function folder_set($name, $deviceid, $flag) { if (empty($deviceid)) { return false; } // get folders activesync config $metadata = $this->folder_meta(); - $metadata = $metadata[$name]; + $metadata = $metadata[$name] ?? null; if ($flag) { if (empty($metadata)) { $metadata = []; } if (empty($metadata['FOLDER'])) { $metadata['FOLDER'] = []; } if (empty($metadata['FOLDER'][$deviceid])) { $metadata['FOLDER'][$deviceid] = []; } // Z-Push uses: // 1 - synchronize, no alarms // 2 - synchronize with alarms $metadata['FOLDER'][$deviceid]['S'] = $flag; } if (!$flag) { unset($metadata['FOLDER'][$deviceid]['S']); if (empty($metadata['FOLDER'][$deviceid])) { unset($metadata['FOLDER'][$deviceid]); } if (empty($metadata['FOLDER'])) { unset($metadata['FOLDER']); } if (empty($metadata)) { $metadata = null; } } // Return if nothing's been changed - if (!self::data_array_diff($this->folder_meta[$name], $metadata)) { + if (!self::data_array_diff($this->folder_meta[$name] ?? null, $metadata)) { return true; } $this->folder_meta[$name] = $metadata; $storage = $this->rc->get_storage(); return $storage->set_metadata($name, [ self::ASYNC_KEY => $this->serialize_metadata($metadata)]); } /** * Device update * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_update($device, $id) { $devices_list = $this->list_devices(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } // Do nothing if nothing is changed if (!self::data_array_diff($old_device, $device)) { return true; } $device = array_merge($old_device, $device); $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; $storage = $this->rc->get_storage(); $result = $storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; } return $result; } /** * Device delete. * * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_delete($id) { $devices_list = $this->list_devices(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); if (empty($this->root_meta['DEVICE'])) { unset($this->root_meta['DEVICE']); } if (empty($this->root_meta['FOLDER'])) { unset($this->root_meta['FOLDER']); } $metadata = $this->serialize_metadata($this->root_meta); $metadata = [self::ASYNC_KEY => $metadata]; $storage = $this->rc->get_storage(); // update meta data $result = $storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // remove device annotation for every folder foreach ($this->folder_meta() as $folder => $meta) { // skip root folder (already handled above) if ($folder == self::ROOT_MAILBOX) { continue; } if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { unset($meta['FOLDER'][$id]); if (empty($meta['FOLDER'])) { unset($this->folder_meta[$folder]['FOLDER']); unset($meta['FOLDER']); } if (empty($meta)) { unset($this->folder_meta[$folder]); $meta = null; } $metadata = [self::ASYNC_KEY => $this->serialize_metadata($meta)]; $res = $storage->set_metadata($folder, $metadata); if ($res && $meta) { $this->folder_meta[$folder] = $meta; } } } // remove device data from syncroton database $db = $this->rc->get_dbh(); $table = $db->table_name('syncroton_device'); if (in_array($table, $db->list_tables())) { $db->query( "DELETE FROM $table WHERE owner_id = ? AND deviceid = ?", $this->rc->user->ID, $id ); } } return $result; } /** * Device information (from syncroton database) * * @param string $id Device ID * * @return array|null Device data */ public function device_info($id) { $db = $this->rc->get_dbh(); $table = $db->table_name('syncroton_device'); if (in_array($table, $db->list_tables())) { $fields = ['devicetype', 'acsversion', 'useragent', 'friendlyname', 'os', 'oslanguage', 'phonenumber']; $result = $db->query( "SELECT " . $db->array2list($fields, 'ident') . " FROM $table WHERE owner_id = ? AND id = ?", $this->rc->user->ID, $id ); if ($result && ($sql_arr = $db->fetch_assoc($result))) { return $sql_arr; } } return null; } /** * Helper method to decode saved IMAP metadata */ private function unserialize_metadata($str) { if (!empty($str)) { $data = @json_decode($str, true); return $data; } return null; } /** * Helper method to encode IMAP metadata for saving */ private function serialize_metadata($data) { if (!empty($data) && is_array($data)) { $data = json_encode($data); return $data; } return null; } /** * Compares two arrays * * @param array $array1 * @param array $array2 * * @return bool True if arrays differs, False otherwise */ private static function data_array_diff($array1, $array2) { if (!is_array($array1) || !is_array($array2)) { return $array1 != $array2; } if (count($array1) != count($array2)) { return true; } foreach ($array1 as $key => $val) { if (!array_key_exists($key, $array2)) { return true; } if ($val !== $array2[$key]) { return true; } } return false; } } diff --git a/plugins/kolab_activesync/kolab_activesync_ui.php b/plugins/kolab_activesync/kolab_activesync_ui.php index 5e637760..2ec82800 100644 --- a/plugins/kolab_activesync/kolab_activesync_ui.php +++ b/plugins/kolab_activesync/kolab_activesync_ui.php @@ -1,325 +1,325 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2013, Kolab Systems AG * * 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 . */ class kolab_activesync_ui { public $device = []; private $rc; private $plugin; private $force_subscriptions = []; private $skin_path; public const SETUP_URL = 'https://kb.kolabenterprise.com/documentation/setting-up-an-activesync-client'; public function __construct($plugin) { $this->plugin = $plugin; $this->rc = rcube::get_instance(); $skin_path = $this->plugin->local_skin_path() . '/'; $this->skin_path = 'plugins/kolab_activesync/' . $skin_path; $this->plugin->load_config(); $this->force_subscriptions = $this->rc->config->get('activesync_force_subscriptions', []); $this->plugin->include_stylesheet($skin_path . 'config.css'); } public function device_list($attrib = []) { $attrib += ['id' => 'devices-list']; $devices = $this->plugin->list_devices(); $table = new html_table(); foreach ($devices as $id => $device) { $name = $device['ALIAS'] ? $device['ALIAS'] : $id; $table->add_row(['id' => 'rcmrow' . $id]); $table->add(null, html::span('devicealias', rcube::Q($name)) . ' ' . html::span('devicetype secondary', rcube::Q($device['TYPE']))); } $this->rc->output->add_gui_object('devicelist', $attrib['id']); $this->rc->output->set_env('devicecount', count($devices)); $this->rc->output->include_script('list.js'); return $table->show($attrib); } public function device_config_form($attrib = []) { $table = new html_table(['cols' => 2]); $field_id = 'config-device-alias'; $input = new html_inputfield(['name' => 'devicealias', 'id' => $field_id, 'size' => 40]); $table->add('title', html::label($field_id, $this->plugin->gettext('devicealias'))); $table->add(null, $input->show($this->device['ALIAS'] ? $this->device['ALIAS'] : $this->device['_id'])); // read-only device information $info = $this->plugin->device_info($this->device['ID']); if (!empty($info)) { foreach ($info as $key => $value) { if ($value) { $table->add('title', html::label(null, rcube::Q($this->plugin->gettext($key)))); $table->add(null, rcube::Q($value)); } } } if ($attrib['form']) { $this->rc->output->add_gui_object('editform', $attrib['form']); } return $table->show($attrib); } private function is_protected($folder, $devicetype) { $devicetype = strtolower($devicetype); if (array_key_exists($devicetype, $this->force_subscriptions)) { return array_key_exists($folder, $this->force_subscriptions[$devicetype]); } return false; } public function folder_subscriptions($attrib = []) { if (empty($attrib['id'])) { $attrib['id'] = 'foldersubscriptions'; } // group folders by type (show only known types) $folder_groups = ['mail' => [], 'contact' => [], 'event' => [], 'task' => [], 'note' => []]; $folder_types = kolab_storage::folders_typedata(); $use_fieldsets = rcube_utils::get_boolean($attrib['use-fieldsets'] ?? ''); $imei = $this->device['_id']; $subscribed = []; if ($imei) { $folder_meta = $this->plugin->folder_meta(); } $devicetype = strtolower($this->device['TYPE']); $device_force_subscriptions = $this->force_subscriptions[$devicetype] ?? []; foreach ($this->plugin->list_folders() as $folder) { if (!empty($folder_types[$folder])) { [$type, ] = explode('.', $folder_types[$folder]); } else { $type = 'mail'; } if (array_key_exists($type, $folder_groups)) { $folder_groups[$type][] = $folder; if ($device_force_subscriptions && array_key_exists($folder, $device_force_subscriptions)) { $subscribed[$folder] = intval($device_force_subscriptions[$folder]); } elseif (!empty($folder_meta[$folder]['FOLDER'][$imei]['S'])) { $subscribed[$folder] = intval($folder_meta[$folder]['FOLDER'][$imei]['S']); } } } // build block for every folder type $html = null; foreach ($folder_groups as $type => $group) { if (empty($group)) { continue; } $attrib['type'] = $type; $table = $this->folder_subscriptions_block($group, $attrib, $subscribed); $label = $this->plugin->gettext($type); if ($use_fieldsets) { $html .= html::tag('fieldset', 'subscriptionblock', html::tag('legend', $type, $label) . $table); } else { $html .= html::div('subscriptionblock', html::tag('h3', $type, $label) . $table); } } $this->rc->output->add_gui_object('subscriptionslist', $attrib['id']); return html::div($attrib, $html); } public function folder_subscriptions_block($a_folders, $attrib, $subscribed) { $alarms = ($attrib['type'] == 'event' || $attrib['type'] == 'task'); $table = new html_table(['cellspacing' => 0, 'class' => 'table-striped']); $table->add_header( [ 'class' => 'subscription checkbox-cell', 'title' => $this->plugin->gettext('synchronize'), 'tabindex' => 0, ], !empty($attrib['syncicon']) ? html::img(['src' => $this->skin_path . $attrib['syncicon']]) : $this->plugin->gettext('synchronize') ); if ($alarms) { $table->add_header( [ 'class' => 'alarm checkbox-cell', 'title' => $this->plugin->gettext('withalarms'), 'tabindex' => 0, ], !empty($attrib['alarmicon']) ? html::img(['src' => $this->skin_path . $attrib['alarmicon']]) : $this->plugin->gettext('withalarms') ); } $table->add_header('foldername', $this->plugin->gettext('folder')); $checkbox_sync = new html_checkbox(['name' => 'subscribed[]', 'class' => 'subscription']); $checkbox_alarm = new html_checkbox(['name' => 'alarm[]', 'class' => 'alarm']); $names = []; foreach ($a_folders as $folder) { $foldername = $origname = kolab_storage::object_prettyname($folder); // find folder prefix to truncate (the same code as in kolab_addressbook plugin) for ($i = count($names) - 1; $i >= 0; $i--) { if (strpos($foldername, $names[$i] . ' » ') === 0) { $length = strlen($names[$i] . ' » '); $prefix = substr($foldername, 0, $length); $count = count(explode(' » ', $prefix)); $foldername = str_repeat('  ', $count - 1) . '» ' . substr($foldername, $length); break; } } $folder_id = 'rcmf' . rcube_utils::html_identifier($folder); $names[] = $origname; $classes = ['mailbox']; if ($folder_class = $this->rc->folder_classname($folder)) { if ($this->rc->text_exists($folder_class)) { $foldername = html::quote($this->rc->gettext($folder_class)); } $classes[] = $folder_class; } $table->add_row(); $disabled = $this->is_protected($folder, $this->device['TYPE']); $table->add('subscription checkbox-cell', $checkbox_sync->show( !empty($subscribed[$folder]) ? $folder : null, ['value' => $folder, 'id' => $folder_id, 'disabled' => $disabled] )); if ($alarms) { $table->add('alarm checkbox-cell', $checkbox_alarm->show( intval($subscribed[$folder] ?? 0) > 1 ? $folder : null, ['value' => $folder, 'id' => $folder_id . '_alarm', 'disabled' => $disabled] )); } $table->add(implode(' ', $classes), html::label($folder_id, $foldername)); } return $table->show(); } public function folder_options_table($folder_name, $devices, $type) { $alarms = $type == 'event' || $type == 'task'; $meta = $this->plugin->folder_meta(); - $folder_data = (array) ($meta[$folder_name] ? $meta[$folder_name]['FOLDER'] : null); + $folder_data = (array) (isset($meta[$folder_name]) ? $meta[$folder_name]['FOLDER'] : null); $table = new html_table(['cellspacing' => 0, 'id' => 'folder-sync-options', 'class' => 'records-table']); // table header $table->add_header(['class' => 'device'], $this->plugin->gettext('devicealias')); $table->add_header(['class' => 'subscription'], $this->plugin->gettext('synchronize')); if ($alarms) { $table->add_header(['class' => 'alarm'], $this->plugin->gettext('withalarms')); } // table records foreach ($devices as $id => $device) { $info = $this->plugin->device_info($device['ID']); $name = $id; $title = ''; $checkbox = new html_checkbox(['name' => "_subscriptions[$id]", 'value' => 1, 'onchange' => 'return activesync_object.update_sync_data(this)']); if (!empty($info)) { $_name = trim($info['friendlyname'] . ' ' . $info['os']); $title = $info['useragent']; if ($_name) { $name .= " ($_name)"; } } $disabled = $this->is_protected($folder_name, $device['TYPE']); $table->add_row(); $table->add(['class' => 'device', 'title' => $title], $name); $table->add('subscription checkbox-cell', $checkbox->show(!empty($folder_data[$id]['S']) ? 1 : 0, ['disabled' => $disabled])); if ($alarms) { $checkbox_alarm = new html_checkbox(['name' => "_alarms[$id]", 'value' => 1, 'onchange' => 'return activesync_object.update_sync_data(this)']); $table->add('alarm checkbox-cell', $checkbox_alarm->show($folder_data[$id]['S'] > 1 ? 1 : 0, ['disabled' => $disabled])); } } return $table->show(); } /** * Displays initial page (when no devices are registered) */ public function init_message() { $this->plugin->load_config(); $this->rc->output->add_handlers([ 'initmessage' => [$this, 'init_message_content'], ]); $this->rc->output->send('kolab_activesync.configempty'); } /** * Handler for initmessage template object */ public function init_message_content() { $url = $this->rc->config->get('activesync_setup_url', self::SETUP_URL); $vars = ['url' => $url]; $msg = $this->plugin->gettext(['name' => 'nodevices', 'vars' => $vars]); return $msg; } } diff --git a/plugins/kolab_folders/kolab_folders.php b/plugins/kolab_folders/kolab_folders.php index 0a8ce533..ea5c0ba5 100644 --- a/plugins/kolab_folders/kolab_folders.php +++ b/plugins/kolab_folders/kolab_folders.php @@ -1,854 +1,854 @@ * * Copyright (C) 2011-2017, Kolab Systems AG * * 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 . */ class kolab_folders extends rcube_plugin { public $task = '?(?!login).*'; public $types = ['mail', 'event', 'journal', 'task', 'note', 'contact', 'configuration', 'file', 'freebusy']; public $subtypes = [ 'mail' => ['inbox', 'drafts', 'sentitems', 'outbox', 'wastebasket', 'junkemail'], 'event' => ['default'], 'task' => ['default'], 'journal' => ['default'], 'note' => ['default'], 'contact' => ['default'], 'configuration' => ['default'], 'file' => ['default'], 'freebusy' => ['default'], ]; public $act_types = ['event', 'task']; private $rc; private static $instance; private $expire_annotation = '/shared/vendor/cmu/cyrus-imapd/expire'; private $is_processing = false; /** * Plugin initialization. */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); // Folder listing hooks $this->add_hook('storage_folders', [$this, 'mailboxes_list']); // Folder manager hooks $this->add_hook('folder_form', [$this, 'folder_form']); $this->add_hook('folder_update', [$this, 'folder_save']); $this->add_hook('folder_create', [$this, 'folder_save']); $this->add_hook('folder_delete', [$this, 'folder_save']); $this->add_hook('folder_rename', [$this, 'folder_save']); $this->add_hook('folders_list', [$this, 'folders_list']); // Special folders setting $this->add_hook('preferences_save', [$this, 'prefs_save']); // ACL plugin hooks $this->add_hook('acl_rights_simple', [$this, 'acl_rights_simple']); $this->add_hook('acl_rights_supported', [$this, 'acl_rights_supported']); // Resolving other user folder names $this->add_hook('render_mailboxlist', [$this, 'render_folderlist']); $this->add_hook('render_folder_selector', [$this, 'render_folderlist']); $this->add_hook('folders_list', [$this, 'render_folderlist']); } /** * Handler for mailboxes_list hook. Enables type-aware lists filtering. */ public function mailboxes_list($args) { // infinite loop prevention if ($this->is_processing) { return $args; } if (!$this->metadata_support()) { return $args; } $this->is_processing = true; // get folders $folders = kolab_storage::list_folders($args['root'], $args['name'], $args['filter'], $args['mode'] == 'LSUB', $folderdata); $this->is_processing = false; if (!is_array($folders)) { return $args; } // Create default folders if ($args['root'] == '' && $args['name'] == '*') { $this->create_default_folders($folders, $args['filter'], $folderdata, $args['mode'] == 'LSUB'); } $args['folders'] = $folders; return $args; } /** * Handler for folders_list hook. Add css classes to folder rows. */ public function folders_list($args) { if (!$this->metadata_support()) { return $args; } // load translations $this->add_texts('localization/', false); // Add javascript script to the client $this->include_script('kolab_folders.js'); $this->add_label('folderctype'); foreach ($this->types as $type) { $this->add_label('foldertype' . $type); } $skip_namespace = $this->rc->config->get('kolab_skip_namespace'); $skip_roots = []; if (!empty($skip_namespace)) { $storage = $this->rc->get_storage(); foreach ((array)$skip_namespace as $ns) { foreach((array)$storage->get_namespace($ns) as $root) { $skip_roots[] = rtrim($root[0], $root[1]); } } } $this->rc->output->set_env('skip_roots', $skip_roots); $this->rc->output->set_env('foldertypes', $this->types); // get folders types $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return $args; } // Add type-based style for table rows // See kolab_folders::folder_class_name() if (!empty($args['table'])) { $table = $args['table']; for ($i = 1, $cnt = $table->size(); $i <= $cnt; $i++) { $attrib = $table->get_row_attribs($i); $folder = $attrib['foldername']; // UTF7-IMAP $type = $folderdata[$folder]; if (!$type) { $type = 'mail'; } $class_name = self::folder_class_name($type); $attrib['class'] = trim($attrib['class'] . ' ' . $class_name); $table->set_row_attribs($attrib, $i); } } // Add type-based class for list items if (!empty($args['list']) && is_array($args['list'])) { foreach ($args['list'] as $k => $item) { $folder = $item['folder_imap']; // UTF7-IMAP $type = $folderdata[$folder] ?? null; if (!$type) { $type = 'mail'; } $class_name = self::folder_class_name($type); $args['list'][$k]['class'] = trim($item['class'] . ' ' . $class_name); } } return $args; } /** * Handler for folder info/edit form (folder_form hook). * Adds folder type selector. */ public function folder_form($args) { if (!$this->metadata_support()) { return $args; } // load translations $this->add_texts('localization/', false); // INBOX folder is of type mail.inbox and this cannot be changed if ($args['name'] == 'INBOX') { $args['form']['props']['fieldsets']['settings']['content']['foldertype'] = [ 'label' => $this->gettext('folderctype'), 'value' => sprintf('%s (%s)', $this->gettext('foldertypemail'), $this->gettext('inbox')), ]; $this->add_expire_input($args['form'], 'INBOX'); return $args; } if (!empty($args['options']['is_root'])) { return $args; } $mbox = strlen($args['name']) ? $args['name'] : $args['parent_name']; if (isset($_POST['_ctype'])) { $new_ctype = trim(rcube_utils::get_input_value('_ctype', rcube_utils::INPUT_POST)); $new_subtype = trim(rcube_utils::get_input_value('_subtype', rcube_utils::INPUT_POST)); } // Get type of the folder or the parent $subtype = ''; if (strlen($mbox)) { [$ctype, $subtype] = $this->get_folder_type($mbox); if (isset($args['parent_name']) && strlen($args['parent_name']) && $subtype == 'default') { $subtype = ''; // there can be only one } } if (empty($ctype)) { $ctype = 'mail'; } $storage = $this->rc->get_storage(); // Don't allow changing type of shared folder, according to ACL if (strlen($mbox)) { $options = $storage->folder_info($mbox); - if ($options['namespace'] != 'personal' && !in_array('a', (array)$options['rights'])) { + if ($options['namespace'] != 'personal' && !in_array('a', (array)($options['rights'] ?? null))) { if (in_array($ctype, $this->types)) { $value = $this->gettext('foldertype' . $ctype); } else { $value = $ctype; } if ($subtype) { $value .= ' (' . ($subtype == 'default' ? $this->gettext('default') : $subtype) . ')'; } $args['form']['props']['fieldsets']['settings']['content']['foldertype'] = [ 'label' => $this->gettext('folderctype'), 'value' => $value, ]; return $args; } } // Add javascript script to the client $this->include_script('kolab_folders.js'); // build type SELECT fields $type_select = new html_select(['name' => '_ctype', 'id' => '_folderctype', 'onchange' => "\$('[name=\"_expire\"]').attr('disabled', \$(this).val() != 'mail')", ]); $sub_select = new html_select(['name' => '_subtype', 'id' => '_subtype']); $sub_select->add('', ''); foreach ($this->types as $type) { $type_select->add($this->gettext('foldertype' . $type), $type); } // add non-supported type if (!in_array($ctype, $this->types)) { $type_select->add($ctype, $ctype); } $sub_types = []; foreach ($this->subtypes as $ftype => $subtypes) { $sub_types[$ftype] = array_combine($subtypes, array_map([$this, 'gettext'], $subtypes)); // fill options for the current folder type if ($ftype == $ctype || (isset($new_ctype) && $ftype == $new_ctype)) { $sub_select->add(array_values($sub_types[$ftype]), $subtypes); } } $args['form']['props']['fieldsets']['settings']['content']['folderctype'] = [ 'label' => $this->gettext('folderctype'), 'value' => html::div( 'input-group', $type_select->show($new_ctype ?? $ctype) . $sub_select->show($new_subtype ?? $subtype) ), ]; $this->rc->output->set_env('kolab_folder_subtypes', $sub_types); $this->rc->output->set_env('kolab_folder_subtype', $new_subtype ?? $subtype); $this->add_expire_input($args['form'], $args['name'], $ctype); return $args; } /** * Handler for folder update/create action (folder_update/folder_create hook). */ public function folder_save($args) { // Folder actions from folders list if (empty($args['record'])) { return $args; } // Folder create/update with form $ctype = trim(rcube_utils::get_input_value('_ctype', rcube_utils::INPUT_POST)); $subtype = trim(rcube_utils::get_input_value('_subtype', rcube_utils::INPUT_POST)); $mbox = $args['record']['name']; $old_mbox = $args['record']['oldname'] ?? null; $subscribe = $args['record']['subscribe'] ?? true; if (empty($ctype)) { return $args; } // load translations $this->add_texts('localization/', false); // Skip folder creation/rename in core // @TODO: Maybe we should provide folder_create_after and folder_update_after hooks? // Using create_mailbox/rename_mailbox here looks bad $args['abort'] = true; // There can be only one default folder of specified type if ($subtype == 'default') { $default = $this->get_default_folder($ctype); if ($default !== null && $old_mbox != $default) { $args['result'] = false; $args['message'] = $this->gettext('defaultfolderexists'); return $args; } } // Subtype sanity-checks elseif ($subtype && (!($subtypes = $this->subtypes[$ctype]) || !in_array($subtype, $subtypes))) { $subtype = ''; } $ctype .= $subtype ? '.' . $subtype : ''; $storage = $this->rc->get_storage(); // Create folder if (!strlen($old_mbox)) { $result = $storage->create_folder($mbox, $subscribe); // Set folder type if ($result) { $this->set_folder_type($mbox, $ctype); } } // Rename folder else { if ($old_mbox != $mbox) { $result = $storage->rename_folder($old_mbox, $mbox); } else { $result = true; } if ($result) { [$oldtype, $oldsubtype] = $this->get_folder_type($mbox); $oldtype .= $oldsubtype ? '.' . $oldsubtype : ''; if ($ctype != $oldtype) { $this->set_folder_type($mbox, $ctype); } } } // Set messages expiration in days if ($result && isset($_POST['_expire'])) { $expire = trim(rcube_utils::get_input_value('_expire', rcube_utils::INPUT_POST)); $expire = intval($expire) && preg_match('/^mail/', $ctype) ? intval($expire) : null; $storage->set_metadata($mbox, [$this->expire_annotation => $expire]); } $args['record']['class'] = self::folder_class_name($ctype); $args['record']['subscribe'] = $subscribe; $args['result'] = $result; return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'folders') { return $args; } $dont_override = (array) $this->rc->config->get('dont_override', []); // map config option name to kolab folder type annotation $opts = [ 'drafts_mbox' => 'mail.drafts', 'sent_mbox' => 'mail.sentitems', 'junk_mbox' => 'mail.junkemail', 'trash_mbox' => 'mail.wastebasket', ]; // check if any of special folders has been changed foreach ($opts as $opt_name => $type) { $new = $args['prefs'][$opt_name]; $old = $this->rc->config->get($opt_name); if (!strlen($new) || $new === $old || in_array($opt_name, $dont_override)) { unset($opts[$opt_name]); } } if (empty($opts)) { return $args; } $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return $args; } foreach ($opts as $opt_name => $type) { $foldername = $args['prefs'][$opt_name]; // get all folders of specified type $folders = array_intersect($folderdata, [$type]); // folder already annotated with specified type if (!empty($folders[$foldername])) { continue; } // set type to the new folder $this->set_folder_type($foldername, $type); // unset old folder(s) type annotation [$maintype, $subtype] = explode('.', $type); foreach (array_keys($folders) as $folder) { $this->set_folder_type($folder, $maintype); } } return $args; } /** * Handler for ACL permissions listing (acl_rights_simple hook) * * This shall combine the write and delete permissions into one item for * groupware folders as updating groupware objects is an insert + delete operation. * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function acl_rights_simple($args) { if ($args['folder']) { [$type, ] = $this->get_folder_type($args['folder']); // we're dealing with a groupware folder here... if ($type && $type !== 'mail') { if ($args['rights']['write'] && $args['rights']['delete']) { $write_perms = $args['rights']['write'] . $args['rights']['delete']; $rw_perms = $write_perms . $args['rights']['read']; $args['rights']['write'] = $write_perms; $args['rights']['other'] = preg_replace("/[$rw_perms]/", '', $args['rights']['other']); // add localized labels and titles for the altered items $args['labels'] = [ 'other' => $this->rc->gettext('shortacla', 'acl'), ]; $args['titles'] = [ 'other' => $this->rc->gettext('longaclother', 'acl'), ]; } } } return $args; } /** * Handler for ACL permissions listing (acl_rights_supported hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function acl_rights_supported($args) { if ($args['folder']) { [$type, ] = $this->get_folder_type($args['folder']); // we're dealing with a groupware folder here... if ($type && $type !== 'mail') { // remove some irrelevant (for groupware objects) rights $args['rights'] = str_split(preg_replace('/[p]/', '', implode('', $args['rights']))); } } return $args; } /** * Checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 * * @return bool */ public function metadata_support() { $storage = $this->rc->get_storage(); return $storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'); } /** * Checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 * * @param string $folder Folder name * * @return array Folder content-type */ public function get_folder_type($folder) { $type = explode('.', (string)kolab_storage::folder_type($folder)); if (!isset($type[1])) { $type[1] = null; } return $type; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return bool True on success */ public function set_folder_type($folder, $type = 'mail') { return kolab_storage::set_folder_type($folder, $type); } /** * Returns the name of default folder * * @param string $type Folder type * * @return ?string Folder name */ public function get_default_folder($type) { $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return null; } // get all folders of specified type $folderdata = array_intersect($folderdata, [$type . '.default']); return key($folderdata); } /** * Returns CSS class name for specified folder type * * @param string $type Folder type * * @return string Class name */ public static function folder_class_name($type) { if ($type && strpos($type, '.')) { [$ctype, $subtype] = explode('.', $type); return 'type-' . $ctype . ' subtype-' . $subtype; } return 'type-' . ($type ? $type : 'mail'); } /** * Creates default folders if they doesn't exist */ private function create_default_folders(&$folders, $filter, $folderdata = null, $lsub = false) { $storage = $this->rc->get_storage(); $namespace = $storage->get_namespace(); $defaults = []; $prefix = ''; // Find personal namespace prefix if (is_array($namespace['personal']) && count($namespace['personal']) == 1) { $prefix = $namespace['personal'][0][0]; } $this->load_config(); // get configured defaults foreach ($this->types as $type) { foreach ((array)$this->subtypes[$type] as $subtype) { $opt_name = 'kolab_folders_' . $type . '_' . $subtype; if ($folder = $this->rc->config->get($opt_name)) { // convert configuration value to UTF7-IMAP charset $folder = rcube_charset::convert($folder, RCUBE_CHARSET, 'UTF7-IMAP'); // and namespace prefix if needed if ($prefix && strpos($folder, $prefix) === false && $folder != 'INBOX') { $folder = $prefix . $folder; } $defaults[$type . '.' . $subtype] = $folder; } } } if (empty($defaults)) { return; } if ($folderdata === null) { $folderdata = kolab_storage::folders_typedata(); } if (!is_array($folderdata)) { return; } // find default folders foreach ($defaults as $type => $foldername) { // get all folders of specified type $_folders = array_intersect($folderdata, [$type]); // default folder found if (!empty($_folders)) { continue; } [$type1, $type2] = explode('.', $type); $activate = in_array($type1, $this->act_types); $exists = false; $result = false; $subscribed = false; // check if folder exists if (!empty($folderdata[$foldername]) || $foldername == 'INBOX') { $exists = true; } elseif ((!$filter || $filter == $type1) && in_array($foldername, $folders)) { // this assumes also that subscribed folder exists $exists = true; } else { $exists = $storage->folder_exists($foldername); } // create folder if (!$exists) { $exists = $storage->create_folder($foldername); } // set type + subscribe + activate if ($exists) { if ($result = kolab_storage::set_folder_type($foldername, $type)) { // check if folder is subscribed if ((!$filter || $filter == $type1) && $lsub && in_array($foldername, $folders)) { // already subscribed $subscribed = true; } else { $subscribed = $storage->subscribe($foldername); } // activate folder if ($activate) { kolab_storage::folder_activate($foldername); } } } // add new folder to the result if ($result && (!$filter || $filter == $type1) && (!$lsub || $subscribed)) { $folders[] = $foldername; } } } /** * Static getter for default folder of the given type * * @param string $type Folder type * * @return string Folder name */ public static function default_folder($type) { return self::$instance->get_default_folder($type); } /** * Get /shared/vendor/cmu/cyrus-imapd/expire value * * @param string $folder IMAP folder name * * @return int|false The annotation value or False if not supported */ private function get_expire_annotation($folder) { $storage = $this->rc->get_storage(); if ($storage->get_vendor() != 'cyrus') { return false; } if (!strlen($folder)) { return 0; } $value = $storage->get_metadata($folder, $this->expire_annotation); if (is_array($value)) { return !empty($value[$folder]) ? intval($value[$folder][$this->expire_annotation] ?? 0) : 0; } return false; } /** * Add expiration time input to the form if supported */ private function add_expire_input(&$form, $folder, $type = null) { if (($expire = $this->get_expire_annotation($folder)) !== false) { $post = trim(rcube_utils::get_input_value('_expire', rcube_utils::INPUT_POST)); $is_mail = empty($type) || preg_match('/^mail/i', $type); $label = $this->gettext('xdays'); $input = new html_inputfield([ 'id' => '_kolabexpire', 'name' => '_expire', 'size' => 3, 'disabled' => !$is_mail, ]); if ($post && $is_mail) { $expire = (int) $post; } if (strpos($label, '$') === 0) { $label = str_replace('$x', '', $label); $html = $input->show($expire ?: '') . html::span('input-group-append', html::span('input-group-text', rcube::Q($label))); } else { $label = str_replace('$x', '', $label); $html = html::span('input-group-prepend', html::span('input-group-text', rcube::Q($label))) . $input->show($expire ?: ''); } $form['props']['fieldsets']['settings']['content']['kolabexpire'] = [ 'label' => $this->gettext('folderexpire'), 'value' => html::div('input-group', $html), ]; } } /** * Handler for various folders list widgets (hooks) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function render_folderlist($args) { $storage = $this->rc->get_storage(); $ns_other = $storage->get_namespace('other'); $is_fl = $this->rc->plugins->is_processing('folders_list'); foreach ((array) $ns_other as $root) { $delim = $root[1]; $prefix = rtrim($root[0], $delim); $length = strlen($prefix); if (!$length) { continue; } // folders_list hook mode if ($is_fl) { foreach ((array) $args['list'] as $folder_name => $folder) { if (strpos($folder_name, $root[0]) === 0 && !substr_count($folder_name, $root[1], $length + 1)) { if ($name = kolab_storage::folder_id2user(substr($folder_name, $length + 1), true)) { $old = $args['list'][$folder_name]['display']; $content = $args['list'][$folder_name]['content']; $name = rcube::Q($name); $content = str_replace(">$old<", ">$name<", $content); $args['list'][$folder_name]['display'] = $name; $args['list'][$folder_name]['content'] = $content; } } } // TODO: Re-sort the list } // render_* hooks mode elseif (!empty($args['list'][$prefix]) && !empty($args['list'][$prefix]['folders'])) { $map = []; foreach ($args['list'][$prefix]['folders'] as $folder_name => $folder) { if ($name = kolab_storage::folder_id2user($folder_name, true)) { $args['list'][$prefix]['folders'][$folder_name]['name'] = $name; } $map[$folder_name] = $name ?: $args['list'][$prefix]['folders'][$folder_name]['name']; } // Re-sort the list uasort($map, 'strcoll'); $args['list'][$prefix]['folders'] = array_replace($map, $args['list'][$prefix]['folders']); } } return $args; } }