diff --git a/lib/kolab_sync_backend.php b/lib/kolab_sync_backend.php index 13a4e34..eb20906 100644 --- a/lib/kolab_sync_backend.php +++ b/lib/kolab_sync_backend.php @@ -1,1032 +1,1033 @@ | | | | 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 | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_sync_backend { /** * Singleton instace of kolab_sync_backend * * @var kolab_sync_backend */ static protected $instance; protected $storage; protected $folder_meta; protected $folder_uids; protected $root_meta; static protected $types = array( 1 => '', 2 => 'mail.inbox', 3 => 'mail.drafts', 4 => 'mail.wastebasket', 5 => 'mail.sentitems', 6 => 'mail.outbox', 7 => 'task.default', 8 => 'event.default', 9 => 'contact.default', 10 => 'note.default', 11 => 'journal.default', 12 => 'mail', 13 => 'event', 14 => 'contact', 15 => 'task', 16 => 'journal', 17 => 'note', ); static protected $classes = array( Syncroton_Data_Factory::CLASS_CALENDAR => 'event', Syncroton_Data_Factory::CLASS_CONTACTS => 'contact', Syncroton_Data_Factory::CLASS_EMAIL => 'mail', Syncroton_Data_Factory::CLASS_NOTES => 'note', Syncroton_Data_Factory::CLASS_TASKS => 'task', ); const ROOT_MAILBOX = 'INBOX'; // const ROOT_MAILBOX = ''; const ASYNC_KEY = '/private/vendor/kolab/activesync'; const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; /** * This implements the 'singleton' design pattern * * @return kolab_sync_backend The one and only instance */ static function get_instance() { if (!self::$instance) { self::$instance = new kolab_sync_backend; self::$instance->startup(); // init AFTER object was linked with self::$instance } return self::$instance; } /** * Class initialization */ public function startup() { $this->storage = rcube::get_instance()->get_storage(); // @TODO: reset cache? if we do this for every request the cache would be useless // There's no session here //$this->storage->clear_cache('mailboxes.', true); // set additional header used by libkolab $this->storage->set_options(array( // @TODO: there can be Roundcube plugins defining additional headers, // we maybe would need to add them here 'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION', 'skip_deleted' => true, 'threading' => false, )); // Disable paging $this->storage->set_pagesize(999999); } /** * List known devices * * @return array Device list as hash array */ public function devices_list() { if ($this->root_meta === null) { // @TODO: consider server annotation instead of INBOX if ($meta = $this->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 = array(); } } if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { return $this->root_meta['DEVICE']; } return array(); } /** * Get list of folders available for sync * * @param string $deviceid Device identifier * @param string $type Folder type * @param bool $flat_mode Enables flat-list mode * * @return array|bool List of mailbox folders, False on backend failure */ public function folders_list($deviceid, $type, $flat_mode = false) { // get all folders of specified type - $folders = (array) kolab_storage::list_folders('', '*', $type, false, $typedata); + $folders = kolab_storage::list_folders('', '*', $type, false, $typedata); // get folders activesync config $folderdata = $this->folder_meta(); if (!is_array($folders) || !is_array($folderdata)) { return false; } $folders_list = array(); // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } // force numeric folder name to be a string (T1283) $folder = (string) $folder; if (!empty($type) && !in_array($folder, $folders)) { continue; } // Activesync folder identifier (serverId) $folder_type = $typedata[$folder] ?: 'mail'; $folder_id = self::folder_id($folder, $folder_type); $folders_list[$folder_id] = $this->folder_data($folder, $folder_type); } if ($flat_mode) { $folders_list = $this->folders_list_flat($folders_list, $type, $typedata); } return $folders_list; } /** * Converts list of folders to a "flat" list */ private function folders_list_flat($folders, $type, $typedata) { $delim = $this->storage->get_hierarchy_delimiter(); foreach ($folders as $idx => $folder) { if ($folder['parentId']) { // for non-mail folders we make the list completely flat if ($type != 'mail') { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); $folders[$idx]['parentId'] = 0; $folders[$idx]['displayName'] = $display_name; } // for mail folders we modify only folders with non-existing parents else if (!isset($folders[$folder['parentId']])) { $items = explode($delim, $folder['imap_name']); $parent = 0; // find existing parent while (count($items) > 0) { array_pop($items); $parent_name = implode($items, $delim); $parent_type = $typedata[$parent_name] ?: 'mail'; $parent_id = self::folder_id($parent_name, $parent_type); if (isset($folders[$parent_id])) { $parent = $parent_id; break; } } if (!$parent) { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); } else { $parent_name = $folders[$parent_id]['imap_name']; $display_name = substr($folder['imap_name'], strlen($parent_name)+1); $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP'); $display_name = str_replace($delim, ' ยป ', $display_name); } $folders[$idx]['parentId'] = $parent; $folders[$idx]['displayName'] = $display_name; } } } return $folders; } /** * Getter for folder metadata * * @return array|bool Hash array with meta data for each folder, False on backend failure */ public function folder_meta() { if (!isset($this->folder_meta)) { - $this->folder_meta = array(); // get folders activesync config $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY); if (!is_array($folderdata)) { - return false; + return $this->folder_meta = false; } + $this->folder_meta = array(); + 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; } /** * Creates folder and subscribes to the device * * @param string $name Folder name (UTF7-IMAP) * @param int $type Folder (ActiveSync) type * @param string $deviceid Device identifier * * @return bool True on success, False on failure */ public function folder_create($name, $type, $deviceid) { if ($this->storage->folder_exists($name)) { $created = true; } else { $type = self::type_activesync2kolab($type); $created = kolab_storage::folder_create($name, $type, true); } if ($created) { // Set ActiveSync subscription flag $this->folder_set($name, $deviceid, 1); return true; } return false; } /** * Renames a folder * * @param string $old_name Old folder name (UTF7-IMAP) * @param string $new_name New folder name (UTF7-IMAP) * @param int $type Folder (ActiveSync) type * * @return bool True on success, False on failure */ public function folder_rename($old_name, $new_name, $type) { $this->folder_meta = null; $type = self::type_activesync2kolab($type); // don't use kolab_storage for moving mail folders if (preg_match('/^mail/', $type)) { return $this->storage->rename_folder($old_name, $new_name); } else { return kolab_storage::folder_rename($old_name, $new_name); } } /** * Deletes folder * * @param string $name Folder name (UTF7-IMAP) * @param string $deviceid Device identifier * */ public function folder_delete($name, $deviceid) { unset($this->folder_meta[$name]); return kolab_storage::folder_delete($name); } /** * 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(); if (!is_array($metadata)) { return false; } $metadata = $metadata[$name]; if ($flag) { if (empty($metadata)) { $metadata = array(); } if (empty($metadata['FOLDER'])) { $metadata['FOLDER'] = array(); } if (empty($metadata['FOLDER'][$deviceid])) { $metadata['FOLDER'][$deviceid] = array(); } // 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)) { return true; } $this->folder_meta[$name] = $metadata; return $this->storage->set_metadata($name, array( self::ASYNC_KEY => $this->serialize_metadata($metadata))); } public function device_get($id) { $devices_list = $this->devices_list(); $result = $devices_list[$id]; return $result; } /** * Registers new device on server * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_create($device, $id) { // Fill local cache $this->devices_list(); // Some devices create dummy devices with name "validate" (#1109) // This device entry is used in two initial requests, but later // the device registers a real name. We can remove this dummy entry // on new device creation $this->device_delete('validate'); // Old Kolab_ZPush device parameters // MODE: -1 | 0 | 1 (not set | flatmode | foldermode) // TYPE: device type string // ALIAS: user-friendly device name // Syncroton (kolab_sync_backend_device) uses // ID: internal identifier in syncroton database // TYPE: device type string // ALIAS: user-friendly device name $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata)); $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; // subscribe default set of folders $this->device_init_subscriptions($id); } return $result; } /** * 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->devices_list(); $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 = array(self::ASYNC_KEY => $this->serialize_metadata($metadata)); $result = $this->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) { $device = $this->device_get($id); if (!$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 = array(self::ASYNC_KEY => $metadata); // update meta data $result = $this->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 = array(self::ASYNC_KEY => $this->serialize_metadata($meta)); $res = $this->storage->set_metadata($folder, $metadata); if ($res && $meta) { $this->folder_meta[$folder] = $meta; } } } } return $result; } /** * Subscribe default set of folders on device registration */ private function device_init_subscriptions($deviceid) { // INBOX always exists $this->folder_set('INBOX', $deviceid, 1); $supported_types = array( 'mail.drafts', 'mail.wastebasket', 'mail.sentitems', 'mail.outbox', 'event.default', 'contact.default', 'note.default', 'task.default', 'event', 'contact', 'note', 'task' ); // This default set can be extended by adding following values: $modes = array( 'SUB_PERSONAL' => 1, // all subscribed folders in personal namespace 'ALL_PERSONAL' => 2, // all folders in personal namespace 'SUB_OTHER' => 4, // all subscribed folders in other users namespace 'ALL_OTHER' => 8, // all folders in other users namespace 'SUB_SHARED' => 16, // all subscribed folders in shared namespace 'ALL_SHARED' => 32, // all folders in shared namespace ); $rcube = rcube::get_instance(); $config = $rcube->config; $mode = (int) $config->get('activesync_init_subscriptions'); $folders = array(); // Subscribe to default folders $foldertypes = kolab_storage::folders_typedata(); if (!empty($foldertypes)) { $_foldertypes = array_intersect($foldertypes, $supported_types); // get default folders foreach ($_foldertypes as $folder => $type) { // only personal folders if ($this->storage->folder_namespace($folder) == 'personal') { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); $folders[] = $folder; } } } // we're in default mode, exit if (!$mode) { return; } // below we support additionally all mail folders $supported_types[] = 'mail'; $supported_types[] = 'mail.junkemail'; // get configured special folders $special_folders = array(); $map = array( 'drafts' => 'mail.drafts', 'junk' => 'mail.junkemail', 'sent' => 'mail.sentitems', 'trash' => 'mail.wastebasket', ); foreach ($map as $folder => $type) { if ($folder = $config->get($folder . '_mbox')) { $special_folders[$folder] = $type; } } // get folders list(s) if (($mode & $modes['ALL_PERSONAL']) || ($mode & $modes['ALL_OTHER']) || ($mode & $modes['ALL_SHARED'])) { $all_folders = $this->storage->list_folders(); if (($mode & $modes['SUB_PERSONAL']) || ($mode & $modes['SUB_OTHER']) || ($mode & $modes['SUB_SHARED'])) { $subscribed_folders = $this->storage->list_folders_subscribed(); } } else { $all_folders = $this->storage->list_folders_subscribed(); } foreach ($all_folders as $folder) { // folder already subscribed if (in_array($folder, $folders)) { continue; } $type = $foldertypes[$folder] ?: 'mail'; if ($type == 'mail' && isset($special_folders[$folder])) { $type = $special_folders[$folder]; } if (!in_array($type, $supported_types)) { continue; } $ns = strtoupper($this->storage->folder_namespace($folder)); // subscribe the folder according to configured mode // and folder namespace/subscription status if (($mode & $modes["ALL_$ns"]) || (($mode & $modes["SUB_$ns"]) && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders))) ) { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); } } } /** * Helper method to decode saved IMAP metadata */ private function unserialize_metadata($str) { if (!empty($str)) { // Support old Z-Push annotation format if ($str[0] != '{') { $str = base64_decode($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); // $data = base64_encode($data); return $data; } return null; } /** * Returns Kolab folder type for specified ActiveSync type ID */ public static function type_activesync2kolab($type) { if (!empty(self::$types[$type])) { return self::$types[$type]; } return ''; } /** * Returns ActiveSync folder type for specified Kolab type */ public static function type_kolab2activesync($type) { if ($key = array_search($type, self::$types)) { return $key; } return key(self::$types); } /** * Returns Kolab folder type for specified ActiveSync class name */ public static function class_activesync2kolab($class) { if (!empty(self::$classes[$class])) { return self::$classes[$class]; } return ''; } /** * Returns folder data in Syncroton format */ private function folder_data($folder, $type) { // Folder name parameters $delim = $this->storage->get_hierarchy_delimiter(); $items = explode($delim, $folder); $name = array_pop($items); // Folder UID $folder_id = $this->folder_id($folder, $type); // Folder type $type = self::type_kolab2activesync($type); // fix type, if there's no type annotation it's detected as UNKNOWN // we'll use 'mail' (12) or 'mail.inbox' (2) if ($type == 1) { $type = $folder == 'INBOX' ? 2 : 12; } // Syncroton folder data array return array( 'serverId' => $folder_id, 'parentId' => count($items) ? self::folder_id(implode($delim, $items)) : 0, 'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET), 'type' => $type, // for internal use 'imap_name' => $folder, ); } /** * Builds folder ID based on folder name */ public function folder_id($name, $type = null) { // ActiveSync expects folder identifiers to be max.64 characters // So we can't use just folder name $name = (string) $name; if ($name === '') { return null; } if (isset($this->folder_uids[$name])) { return $this->folder_uids[$name]; } /* @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. There's one inconvenience of this solution: folder name/type change would be handled in ActiveSync as delete + create. // get folders unique identifier $folderdata = $this->storage->get_metadata($name, self::UID_KEY); if ($folderdata && !empty($folderdata[$name])) { $uid = $folderdata[$name][self::UID_KEY]; return $this->folder_uids[$name] = $uid; } */ // Add type to folder UID hash, so type change can be detected by Syncroton $uid = $name . '!!' . ($type !== null ? $type : kolab_storage::folder_type($name)); $uid = md5($uid); return $this->folder_uids[$name] = $uid; } /** * Returns IMAP folder name * * @param string $id Folder identifier * @param string $deviceid Device dentifier * * @return string Folder name (UTF7-IMAP) */ public function folder_id2name($id, $deviceid) { // check in cache first if (!empty($this->folder_uids)) { if (($name = array_search($id, $this->folder_uids)) !== false) { return $name; } } /* @TODO: see folder_id() // get folders unique identifier $folderdata = $this->storage->get_metadata('*', self::UID_KEY); foreach ((array)$folderdata as $folder => $data) { if (!empty($data[self::UID_KEY])) { $uid = $data[self::UID_KEY]; $this->folder_uids[$folder] = $uid; if ($uid == $id) { $name = $folder; } } } */ // get all folders of specified type $folderdata = $this->folder_meta(); if (!is_array($folderdata) || $id === null) { return null; } // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } if ($uid = self::folder_id($folder)) { $this->folder_uids[$folder] = $uid; } if ($uid === $id) { $name = $folder; } } return $name; } /** */ public function modseq_set($deviceid, $folderid, $synctime, $data) { $synctime = $synctime->format('Y-m-d H:i:s'); $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $old_data = $this->modseq[$folderid][$synctime]; if (empty($old_data)) { $this->modseq[$folderid][$synctime] = $data; $data = json_encode($data); $db->set_option('ignore_key_errors', true); $db->query("INSERT INTO `syncroton_modseq` (`device_id`, `folder_id`, `synctime`, `data`)" ." VALUES (?, ?, ?, ?)", $deviceid, $folderid, $synctime, $data); $db->set_option('ignore_key_errors', false); } } public function modseq_get($deviceid, $folderid, $synctime) { $synctime = $synctime->format('Y-m-d H:i:s'); if (empty($this->modseq[$folderid][$synctime])) { $this->modseq[$folderid] = array(); $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_modseq`" ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" ." ORDER BY `synctime` DESC", 0, 1, $deviceid, $folderid, $synctime); if ($row = $db->fetch_assoc()) { $synctime = $row['synctime']; // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format $this->modseq[$folderid][$synctime] = json_decode($row['data'], true); } // Cleanup: remove all records except the current one $db->query("DELETE FROM `syncroton_modseq`" ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", $deviceid, $folderid, $synctime); } return @$this->modseq[$folderid][$synctime]; } /** * Set state of relation objects at specified point in time */ public function relations_state_set($deviceid, $folderid, $synctime, $relations) { $synctime = $synctime->format('Y-m-d H:i:s'); $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $old_data = $this->relations[$folderid][$synctime]; if (empty($old_data)) { $this->relations[$folderid][$synctime] = $relations; $data = rcube_charset::clean(json_encode($relations)); $db->set_option('ignore_key_errors', true); $db->query("INSERT INTO `syncroton_relations_state`" ." (`device_id`, `folder_id`, `synctime`, `data`)" ." VALUES (?, ?, ?, ?)", $deviceid, $folderid, $synctime, $data); $db->set_option('ignore_key_errors', false); } } /** * Get state of relation objects at specified point in time */ public function relations_state_get($deviceid, $folderid, $synctime) { $synctime = $synctime->format('Y-m-d H:i:s'); if (empty($this->relations[$folderid][$synctime])) { $this->relations[$folderid] = array(); $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_relations_state`" ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" ." ORDER BY `synctime` DESC", 0, 1, $deviceid, $folderid, $synctime); if ($row = $db->fetch_assoc()) { $synctime = $row['synctime']; // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format $this->relations[$folderid][$synctime] = json_decode($row['data'], true); } // Cleanup: remove all records except the current one $db->query("DELETE FROM `syncroton_relations_state`" ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", $deviceid, $folderid, $synctime); } return @$this->relations[$folderid][$synctime]; } /** * Return last storage error */ public static function last_error() { return kolab_storage::$last_error; } /** * 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/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php index ac0bb4f..af5f569 100644 --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -1,1644 +1,1646 @@ | | | | 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 | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Email data class for Syncroton */ class kolab_sync_data_email extends kolab_sync_data implements Syncroton_Data_IDataSearch { const MAX_SEARCH_RESULT = 200; /** * Mapping from ActiveSync Email namespace fields */ protected $mapping = array( 'cc' => 'cc', //'contentClass' => 'contentclass', 'dateReceived' => 'internaldate', //'displayTo' => 'displayto', //? //'flag' => 'flag', 'from' => 'from', //'importance' => 'importance', 'internetCPID' => 'charset', //'messageClass' => 'messageclass', 'replyTo' => 'replyto', //'read' => 'read', 'subject' => 'subject', //'threadTopic' => 'threadtopic', 'to' => 'to', ); /** * Special folder type/name map * * @var array */ protected $folder_types = array( 2 => 'Inbox', 3 => 'Drafts', 4 => 'Deleted Items', 5 => 'Sent Items', 6 => 'Outbox', ); /** * Kolab object type * * @var string */ protected $modelName = 'mail'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_INBOX; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'INBOX'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { parent::__construct($device, $syncTimeStamp); $this->storage = rcube::get_instance()->get_storage(); // Outlook 2013 support multi-folder $this->ext_devices[] = 'windowsoutlook15'; if ($this->asversion >= 14) { $this->tag_categories = true; } } /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * * @return Syncroton_Model_Email Email object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $message = $this->getObject($serverId); // error (message doesn't exist?) if (empty($message)) { throw new Syncroton_Exception_NotFound("Message $serverId not found"); } $headers = $message->headers; // rcube_message_header // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = null; switch ($name) { case 'internaldate': $value = self::date_from_kolab(rcube_utils::strtotime($headers->internaldate)); break; case 'cc': case 'to': case 'replyto': case 'from': $addresses = rcube_mime::decode_address_list($headers->$name, null, true, $headers->charset); foreach ($addresses as $idx => $part) { // @FIXME: set name + address or address only? $addresses[$idx] = format_email_recipient($part['mailto'], $part['name']); } $value = implode(',', $addresses); break; case 'subject': $value = $headers->get('subject'); break; case 'charset': $value = self::charset_to_cp($headers->charset); break; } if (empty($value) || is_array($value)) { continue; } if (is_string($value)) { $value = rcube_charset::clean($value); } $result[$key] = $value; } // $result['ConversationId'] = 'FF68022058BD485996BE15F6F6D99320'; // $result['ConversationIndex'] = 'CA2CFA8A23'; // Read flag $result['read'] = intval(!empty($headers->flags['SEEN'])); // Flagged message if (!empty($headers->flags['FLAGGED'])) { // Use FollowUp flag which is used in Android when message is marked with a star $result['flag'] = new Syncroton_Model_EmailFlag(array( 'flagType' => 'FollowUp', 'status' => Syncroton_Model_EmailFlag::STATUS_ACTIVE, )); } // Importance/Priority if ($headers->priority) { if ($headers->priority < 3) { $result['importance'] = 2; // High } else if ($headers->priority > 3) { $result['importance'] = 0; // Low } } // get truncation and body type $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT; $truncateAt = null; $opts = $collection->options; $prefs = $opts['bodyPreferences']; if ($opts['mimeSupport'] == Syncroton_Command_Sync::MIMESUPPORT_SEND_MIME) { $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_MIME; if (isset($prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize'])) { $truncateAt = $prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize']; } else if (isset($opts['mimeTruncation']) && $opts['mimeTruncation'] < Syncroton_Command_Sync::TRUNCATE_NOTHING) { switch ($opts['mimeTruncation']) { case Syncroton_Command_Sync::TRUNCATE_ALL: $truncateAt = 0; break; case Syncroton_Command_Sync::TRUNCATE_4096: $truncateAt = 4096; break; case Syncroton_Command_Sync::TRUNCATE_5120: $truncateAt = 5120; break; case Syncroton_Command_Sync::TRUNCATE_7168: $truncateAt = 7168; break; case Syncroton_Command_Sync::TRUNCATE_10240: $truncateAt = 10240; break; case Syncroton_Command_Sync::TRUNCATE_20480: $truncateAt = 20480; break; case Syncroton_Command_Sync::TRUNCATE_51200: $truncateAt = 51200; break; case Syncroton_Command_Sync::TRUNCATE_102400: $truncateAt = 102400; break; } } } else { // The spec is not very clear, but it looks that if MimeSupport is not set // we can't add Syncroton_Command_Sync::BODY_TYPE_MIME to the supported types // list below (Bug #1688) $types = array( Syncroton_Command_Sync::BODY_TYPE_HTML, Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT, ); // @TODO: if client can support both HTML and TEXT use one of // them which is better according to the real message body type foreach ($types as $type) { if (!empty($prefs[$type])) { if (!empty($prefs[$type]['truncationSize'])) { $truncateAt = $prefs[$type]['truncationSize']; } $preview = (int) $prefs[$type]['preview']; $airSyncBaseType = $type; break; } } } $body_params = array('type' => $airSyncBaseType); // Message body // In Sync examples there's one in which bodyPreferences is not defined // in such case Truncated=1 and there's no body sent to the client // only it's estimated size if (empty($prefs)) { $messageBody = ''; $real_length = $message->size; $truncateAt = 0; $body_length = 0; $isTruncated = 1; } else if ($airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_MIME) { $messageBody = $this->storage->get_raw_body($message->uid); // make the source safe (Bug #2715, #2757) $messageBody = kolab_sync_message::recode_message($messageBody); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } else { $messageBody = $this->getMessageBody($message, $airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_HTML); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } // add Preview element to the Body result if (!empty($preview) && $body_length) { $body_params['preview'] = $this->getPreview($messageBody, $airSyncBaseType, $preview); } // truncate the body if needed if ($truncateAt && $body_length > $truncateAt) { $messageBody = mb_strcut($messageBody, 0, $truncateAt); $body_length = strlen($messageBody); $isTruncated = 1; } if ($isTruncated) { $body_params['truncated'] = 1; $body_params['estimatedDataSize'] = $real_length; } // add Body element to the result $result['body'] = $this->setBody($messageBody, $body_params); // original body type // @TODO: get this value from getMessageBody() $result['nativeBodyType'] = $message->has_html_part() ? 2 : 1; // Message class if ($headers->ctype == 'multipart/signed' && count($message->attachments) == 1 && $message->attachments[0]->mimetype == 'application/pkcs7-signature' ) { $result['messageClass'] = 'IPM.Note.SMIME.MultipartSigned'; } else if ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result['messageClass'] = 'IPM.Note.SMIME'; } else { $result['messageClass'] = 'IPM.Note'; } $result['contentClass'] = 'urn:content-classes:message'; // Categories (Tags) if ($this->tag_categories) { // convert kolab tags into categories $result['categories'] = $this->getKolabTags($message); } // attachments $attachments = array_merge($message->attachments, $message->inline_parts); if (!empty($attachments)) { $result['attachments'] = array(); foreach ($attachments as $attachment) { $att = array(); $filename = rcube_charset::clean($attachment->filename); if (empty($filename) && $attachment->mimetype == 'text/html') { $filename = 'HTML Part'; } $att['displayName'] = $filename; $att['fileReference'] = $serverId . '::' . $attachment->mime_id; $att['method'] = 1; $att['estimatedDataSize'] = $attachment->size; if (!empty($attachment->content_id)) { $att['contentId'] = rcube_charset::clean($attachment->content_id); } if (!empty($attachment->content_location)) { $att['contentLocation'] = rcube_charset::clean($attachment->content_location); } if (in_array($attachment, $message->inline_parts)) { $att['isInline'] = 1; } $result['attachments'][] = new Syncroton_Model_EmailAttachment($att); } } return new Syncroton_Model_Email($result); } /** * Returns properties of a message for Search response * * @param string $longId Message identifier * @param array $options Search options * * @return Syncroton_Model_Email Email object */ public function getSearchEntry($longId, $options) { $collection = new Syncroton_Model_SyncCollection(array( 'options' => $options, )); return $this->getEntry($collection, $longId); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) { // does nothing => you can't add emails via ActiveSync } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_1_DAY_BACK: $mod = '-1 day'; break; case Syncroton_Command_Sync::FILTER_3_DAYS_BACK: $mod = '-3 days'; break; case Syncroton_Command_Sync::FILTER_1_WEEK_BACK: $mod = '-1 week'; break; case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); // RFC3501: IMAP SEARCH $filter[] = 'SINCE ' . $dt->format('d-M-Y'); } return $filter; } /** * Return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = $this->listFolders(); if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } // device doesn't support multiple folders if (!$this->isMultiFolder()) { // We'll return max. one folder of supported type $result = array(); $types = $this->folder_types; foreach ($list as $idx => $folder) { $type = $folder['type'] == 12 ? 2 : $folder['type']; // unknown to Inbox if ($folder_id = $types[$type]) { $result[$folder_id] = array( 'displayName' => $folder_id, 'serverId' => $folder_id, 'parentId' => 0, 'type' => $type, ); } } $list = $result; } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Return list of folders for specified folder ID * * @return array Folder identifiers list */ protected function extractFolders($folder_id) { $list = $this->listFolders(); $result = array(); if (!is_array($list)) { throw new Syncroton_Exception_NotFound('Folder not found'); } // device supports multiple folders? if ($this->isMultiFolder()) { if ($list[$folder_id]) { $result[] = $folder_id; } } else if ($type = array_search($folder_id, $this->folder_types)) { foreach ($list as $id => $folder) { if ($folder['type'] == $type || ($folder_id == 'Inbox' && $folder['type'] == 12)) { $result[] = $id; } } } if (empty($result)) { throw new Syncroton_Exception_NotFound('Folder not found'); } return $result; } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { $msg = $this->parseMessageId($serverId); $dest = $this->extractFolders($dstFolderId); $dest_id = array_shift($dest); $dest_name = $this->backend->folder_id2name($dest_id, $this->device->deviceid); if (empty($msg)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } if ($dest_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$this->storage->move_message($msg['uid'], $dest_name, $msg['foldername'])) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } // Use COPYUID feature (RFC2359) to get the new UID of the copied message $copyuid = $this->storage->conn->data['COPYUID']; if (is_array($copyuid) && ($uid = $copyuid[1])) { return $this->createMessageId($dest_id, $uid); } } /** * add entry from xml data * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry * * @return array */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { // Throw exception here for better handling of unsupported // entry creation, it can be object of class Email or SMS here throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } /** * Update existing message * * @param string $folderId Folder identifier * @param string $serverId Entry identifier * @param Syncroton_Model_IEntry $entry Entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { $msg = $this->parseMessageId($serverId); $message = $this->getObject($serverId); if (empty($message)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $is_flagged = !empty($message->headers->flags['FLAGGED']); // Read status change if (isset($entry->read)) { // here we update only Read flag $flag = (((int)$entry->read != 1) ? 'UN' : '') . 'SEEN'; $this->storage->set_flag($msg['uid'], $flag, $msg['foldername']); } // Flag change if (isset($entry->flag) && (empty($entry->flag) || empty($entry->flag->flagType))) { if ($is_flagged) { $this->storage->set_flag($msg['uid'], 'UNFLAGGED', $msg['foldername']); } } else if (!$is_flagged && !empty($entry->flag)) { if ($entry->flag->flagType && preg_match('/follow\s*up/i', $entry->flag->flagType)) { $this->storage->set_flag($msg['uid'], 'FLAGGED', $msg['foldername']); } } // Categories (Tags) change if (isset($entry->categories)) { $this->setKolabTags($message, $entry->categories); } } /** * delete entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_SyncCollection $collection */ public function deleteEntry($folderId, $serverId, $collection) { $trash = kolab_sync::get_instance()->config->get('trash_mbox'); $msg = $this->parseMessageId($serverId); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } // move message to trash folder if ($collection->deletesAsMoves && strlen($trash) && $trash != $msg['foldername'] && $this->storage->folder_exists($trash) ) { $this->storage->move_message($msg['uid'], $trash, $msg['foldername']); } // set delete flag else { $this->storage->set_flag($msg['uid'], 'DELETED', $msg['foldername']); } } /** * Send an email * * @param mixed $message MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * * @throws Syncroton_Exception_Status */ public function sendEmail($message, $saveInSent) { if (!($message instanceof kolab_sync_message)) { $message = new kolab_sync_message($message); } $sent = $message->send($smtp_error); if (!$sent) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAIL_SUBMISSION_FAILED); } // Save sent message in Sent folder if ($saveInSent) { $sent_folder = kolab_sync::get_instance()->config->get('sent_mbox'); if (strlen($sent_folder) && $this->storage->folder_exists($sent_folder)) { return $this->storage->save_message($sent_folder, $message->source(), '', false, array('SEEN')); } } } /** * Forward an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function forwardEmail($itemId, $body, $saveInSent, $replaceMime) { /* @TODO: The SmartForward command can be applied to a meeting. When SmartForward is applied to a recurring meeting, the InstanceId element (section 2.2.3.83.2) specifies the ID of a particular occurrence in the recurring meeting. If SmartForward is applied to a recurring meeting and the InstanceId element is absent, the server SHOULD forward the entire recurring meeting. If the value of the InstanceId element is invalid, the server responds with Status element (section 2.2.3.162.15) value 104, as specified in section 2.2.4. When the SmartForward command is used for an appointment, the original message is included by the server as an attachment to the outgoing message. When the SmartForward command is used for a normal message or a meeting, the behavior of the SmartForward command is the same as that of the SmartReply command (section 2.2.2.18). */ $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } // Parse message $sync_msg = new kolab_sync_message($body); // forward original message as attachment if (!$replaceMime) { $this->storage->set_folder($msg['foldername']); $attachment = $this->storage->get_raw_body($msg['uid']); if (empty($attachment)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg->add_attachment($attachment, array( 'encoding' => '8bit', 'content_type' => 'message/rfc822', 'disposition' => 'inline', //'name' => 'message.eml', )); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set FORWARDED flag on the replied message if (empty($message->headers->flags['FORWARDED'])) { $this->storage->set_flag($msg['uid'], 'FORWARDED', $msg['foldername']); } } /** * Reply to an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function replyEmail($itemId, $body, $saveInSent, $replaceMime) { $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg = new kolab_sync_message($body); $headers = $sync_msg->headers(); // Add References header if (empty($headers['References'])) { $sync_msg->set_header('References', trim($message->headers->references . ' ' . $message->headers->messageID)); } // Get original message body if (!$replaceMime) { // @TODO: here we're assuming that reply message is in text/plain format // So, original message will be converted to plain text if needed $message_body = $this->getMessageBody($message, false); // Quote original message body $message_body = self::wrap_and_quote(trim($message_body), 72); // Join bodies $sync_msg->append("\n" . ltrim($message_body)); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set ANSWERED flag on the replied message if (empty($message->headers->flags['ANSWERED'])) { $this->storage->set_flag($msg['uid'], 'ANSWERED', $msg['foldername']); } } /** * Search for existing entries * * @param string $folderid * @param array $filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID) { $folders = $this->extractFolders($folderid); $filter_str = 'ALL UNDELETED'; // convert filter into one IMAP search string foreach ($filter as $idx => $filter_item) { if (is_array($filter_item)) { // This is a request for changes since last time // we'll use HIGHESTMODSEQ value from the last Sync if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { $modseq_lasttime = $filter_item[2]; $modseq_data = array(); $modseq = (array) $this->backend->modseq_get($this->device->id, $folderid, $modseq_lasttime); } } else { $filter_str .= ' ' . $filter_item; } } // get members of modified relations $changed_msgs = $this->getChangesByRelations($folderid, $filter); $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; $ts = time(); foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); if ($foldername === null) { continue; } $found++; $this->storage->set_folder($foldername); // Synchronize folder (if it wasn't synced in this request already) if ($this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout() ) { $this->storage->folder_sync($foldername); } // We're in "get changes" mode if (isset($modseq_data)) { $folder_data = $this->storage->folder_data($foldername); $modified = false; // If previous HIGHESTMODSEQ doesn't exist we can't get changes // We can only get folder's HIGHESTMODSEQ value and store it for the next try // Skip search if HIGHESTMODSEQ didn't change if ($folder_data['HIGHESTMODSEQ']) { $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; if ($modseq_data[$foldername] != $modseq[$foldername]) { $modseq_update = true; if ($modseq && $modseq[$foldername]) { $modified = true; $filter_str .= " MODSEQ " . ($modseq[$foldername] + 1); } } } } else { $modified = true; } // We could use messages cache by replacing search() with index() // in some cases. This however is possible only if user has skip_deleted=true, // in his Roundcube preferences, otherwise we'd make often cache re-initialization, // because Roundcube message cache can work only with one skip_deleted // setting at a time. We'd also need to make sure folder_sync() was called // before (see above). // // if ($filter_str == 'ALL UNDELETED') // $search = $this->storage->index($foldername, null, null, true, true); // else if ($modified) { $search = $this->storage->search_once($foldername, $filter_str); if (!($search instanceof rcube_result_index) || $search->is_error()) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } switch ($result_type) { case self::RESULT_COUNT: $result += (int) $search->count(); break; case self::RESULT_UID: if ($uids = $search->get()) { foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_merge($result, $uids); } break; } } // handle relation changes if (!empty($changed_msgs)) { $uids = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); switch ($result_type) { case self::RESULT_COUNT: $result += (int) count($uids); break; case self::RESULT_UID: foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_unique(array_merge($result, $uids)); break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $this->lastsync_folder = $folderid; $this->lastsync_time = $ts; if (!empty($modseq_update)) { $this->backend->modseq_set($this->device->id, $folderid, $this->syncTimeStamp, $modseq_data); // if previous modseq information does not exist save current set as it, // we would at least be able to detect changes since now if (empty($result) && empty($modseq)) { $this->backend->modseq_set($this->device->id, $folderid, $modseq_lasttime, $modseq_data); } } return $result; } /** * Find members (messages) in specified folder */ protected function findRelationMembersInFolder($foldername, $members, $filter) { foreach ($members as $member) { // IMAP URI members if ($url = kolab_storage_config::parse_member_url($member)) { $result[$url['folder']][$url['uid']] = $url['params']; } } // convert filter into one IMAP search string $filter_str = 'ALL UNDELETED'; foreach ($filter as $filter_item) { if (is_string($filter_item)) { $filter_str .= ' ' . $filter_item; } } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $found = array(); // first find messages by UID if (!empty($result[$foldername])) { $index = $storage->search_once($foldername, 'UID ' . rcube_imap_generic::compressMessageSet(array_keys($result[$foldername]))); $found = $index->get(); // remove found messages from the $result if (!empty($found)) { $result[$foldername] = array_diff_key($result[$foldername], array_flip($found)); if (empty($result[$foldername])) { unset($result[$foldername]); } // now apply the current filter to the found messages $index = $storage->search_once($foldername, $filter_str . ' UID ' . rcube_imap_generic::compressMessageSet($found)); $found = $index->get(); } } // search by message parameters if (!empty($result)) { // @TODO: do this search in chunks (for e.g. 25 messages)? $search = ''; $search_count = 0; foreach ($result as $data) { foreach ($data as $p) { $search_params = array(); $search_count++; foreach ($p as $key => $val) { $key = strtoupper($key); // don't search by subject, we don't want false-positives if ($key != 'SUBJECT') { $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); } } $search .= ' (' . implode(' ', $search_params) . ')'; } } $search_str .= ' ' . str_repeat(' OR', $search_count-1) . $search; // search messages in current folder $search = $storage->search_once($foldername, $search_str); $uids = $search->get(); if (!empty($uids)) { // add UIDs into the result $found = array_unique(array_merge($found, $uids)); } } return $found; } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query * * @return Syncroton_Model_StoreResponse Complete Search response */ public function search(Syncroton_Model_StoreRequest $store) { list($folders, $search_str) = $this->parse_search_query($store); if (empty($search_str)) { throw new Exception('Empty/invalid search request'); } if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = array(); // @TODO: caching with Options->RebuildResults support foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null) { continue; } // $this->storage->set_folder($foldername); // $this->storage->folder_sync($foldername); $search = $this->storage->search_once($foldername, $search_str); if (!($search instanceof rcube_result_index)) { continue; } $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult(array( 'longId' => $this->createMessageId($folderid, $uid), 'collectionId' => $folderid, 'class' => 'Email', )); } $result = array_merge($result, $uids); // We don't want to search all folders if we've got already a lot messages if (count($result) >= self::MAX_SEARCH_RESULT) { break; } } $result = array_values($result); $response = new Syncroton_Model_StoreResponse(); // Calculate requested range $start = (int) $store->options['range'][0]; $limit = (int) $store->options['range'][1] + 1; $total = count($result); $response->total = $total; // Get requested chunk of data set if ($total) { if ($start > $total) { $start = $total; } if ($limit > $total) { $limit = max($start+1, $total); } if ($start > 0 || $limit < $total) { $result = array_slice($result, $start, $limit-$start); } $response->range = array($start, $start + count($result) - 1); } // Build result array, convert to ActiveSync format foreach ($result as $idx => $rec) { $rec->properties = $this->getSearchEntry($rec->longId, $store->options); $response->result[] = $rec; unset($result[$idx]); } return $response; } /** * Converts ActiveSync search parameters into IMAP search string */ protected function parse_search_query($store) { $options = $store->options; $query = $store->query; $search_str = ''; $folders = array(); if (empty($query) || !is_array($query)) { return array(); } if (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { $search = $query['and']['freeText']; } if (!empty($query['and']['collections'])) { foreach ($query['and']['collections'] as $collection) { $folders = array_merge($folders, $this->extractFolders($collection)); } } if (!empty($query['and']['greaterThan']) && !empty($query['and']['greaterThan']['dateReceived']) && !empty($query['and']['greaterThan']['value']) ) { $search_str .= ' SINCE ' . $query['and']['greaterThan']['value']->format('d-M-Y'); } if (!empty($query['and']['lessThan']) && !empty($query['and']['lessThan']['dateReceived']) && !empty($query['and']['lessThan']['value']) ) { $search_str .= ' BEFORE ' . $query['and']['lessThan']['value']->format('d-M-Y'); } if ($search !== null) { // @FIXME: should we use TEXT/BODY search? // ActiveSync protocol specification says "indexed fields" $search_keys = array('SUBJECT', 'TO', 'FROM', 'CC'); $search_str .= str_repeat(' OR', count($search_keys)-1); foreach ($search_keys as $key) { $search_str .= sprintf(" %s {%d}\r\n%s", $key, strlen($search), $search); } } if (empty($search_str)) { return array(); } $search_str = 'ALL UNDELETED ' . trim($search_str); // @TODO: DeepTraversal if (empty($folders)) { $folders = $this->listFolders(); - if (is_array($folders)) { - $folders = array_keys($folders); + if (!is_array($folders)) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } + + $folders = array_keys($folders); } return array($folders, $search_str); } /** * Fetches the entry from the backend */ protected function getObject($entryid, $dummy = null, &$folder = null) { $message = $this->parseMessageId($entryid); if (empty($message)) { // @TODO: exception? return null; } // get message $message = new rcube_message($message['uid'], $message['foldername']); return $message && !empty($message->headers) ? $message : null; } /** * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { list($folderid, $uid, $part_id) = explode('::', $fileReference); $message = $this->getObject($fileReference); if (!$message) { throw new Syncroton_Exception_NotFound('Message not found'); } $part = $message->mime_parts[$part_id]; $body = $message->get_part_body($part_id); return new Syncroton_Model_FileReference(array( 'contentType' => $part->mimetype, 'data' => $body, )); } /** * Parses entry ID to get folder name and UID of the message */ protected function parseMessageId($entryid) { // replyEmail/forwardEmail if (is_array($entryid)) { $entryid = $entryid['itemId']; } list($folderid, $uid) = explode('::', $entryid); $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null || $foldername === false) { // @TODO exception? return null; } return array( 'uid' => $uid, 'folderid' => $folderid, 'foldername' => $foldername, ); } /** * Creates entry ID of the message */ public function createMessageId($folderid, $uid) { return $folderid . '::' . $uid; } /** * Returns body of the message in specified format */ protected function getMessageBody($message, $html = false) { if (!is_array($message->parts) && empty($message->body)) { return ''; } if (!empty($message->parts)) { foreach ($message->parts as $part) { // skip no-content and attachment parts (#1488557) if ($part->type != 'content' || !$part->size || $message->is_attachment($part)) { continue; } return $this->getMessagePartBody($message, $part, $html); } } return $this->getMessagePartBody($message, $message, $html); } /** * Returns body of the message part in specified format */ protected function getMessagePartBody($message, $part, $html = false) { // Check if we have enough memory to handle the message in it // @FIXME: we need up to 5x more memory than the body if (!rcube_utils::mem_check($part->size * 5)) { return ''; } $body = $message->get_part_body($part->mime_id, true); // message is cached but not exists, or other error if ($body === false) { return ''; } if ($html) { if ($part->ctype_secondary == 'html') { // charset was converted to UTF-8 in rcube_storage::get_message_part(), // change/add charset specification in HTML accordingly $meta = ''; // remove old meta tag and add the new one, making sure // that it is placed in the head $body = preg_replace('/]+charset=[a-z0-9-_]+[^>]*>/Ui', '', $body); $body = preg_replace('/(]*>)/Ui', '\\1'.$meta, $body, -1, $rcount); if (!$rcount) { $body = '' . $meta . '' . $body; } } else if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); } else { // Roundcube >= 1.2 if (class_exists('rcube_text2html')) { $flowed = $part->ctype_parameters['format'] == 'flowed'; $delsp = $part->ctype_parameters['delsp'] == 'yes'; $options = array('flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp); $text2html = new rcube_text2html($body, false, $options); $body = '' . $text2html->get_html() . ''; } else { $body = '
' . $body . '
'; } } } else { if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); $part->ctype_secondary = 'html'; } if ($part->ctype_secondary == 'html') { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } else { if ($part->ctype_secondary == 'plain' && $part->ctype_parameters['format'] == 'flowed') { $body = rcube_mime::unfold_flowed($body); } } } return $body; } /** * Converts and truncates message body for use in * * @return string Truncated plain text message */ protected function getPreview($body, $type, $size) { if ($type == Syncroton_Command_Sync::BODY_TYPE_HTML) { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } // size limit defined in ActiveSync protocol if ($size > 255) { $size = 255; } return mb_strcut(trim($body), 0, $size); } /** * Returns list of tag names assigned to an email message */ protected function getKolabTags($message, $dummy = null) { // support only messages with message-id if (!($msg_id = $message->headers->get('message-id', false))) { return null; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uid = $message->uid; // get tag objects raleted to specified message-id $tags = $config->get_tags($msg_id); foreach ($tags as $idx => $tag) { // resolve members if it wasn't done recently $force = empty($this->tag_rts[$tag['uid']]) || $this->tag_rts[$tag['uid']] <= time() - $delta; $members = $config->resolve_members($tag, $force); if (empty($members[$folder]) || !in_array($uid, $members[$folder])) { unset($tags[$idx]); } if ($force) { $this->tag_rts[$tag['uid']] = time(); } } $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); // make sure current folder is set correctly again $this->storage->set_folder($folder); return !empty($tags) ? $tags : null; } /** * Set tags to an email message */ protected function setKolabTags($message, $tags) { $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uri = kolab_storage_config::get_message_uri($message->headers, $folder); // for all tag objects... foreach ($config->get_tags() as $relation) { // resolve members if it wasn't done recently $uid = $relation['uid']; $force = empty($this->tag_rts[$uid]) || $this->tag_rts[$uid] <= time() - $delta; if ($force) { $config->resolve_members($relation, $force); $this->tag_rts[$tag['uid']] = time(); } $selected = !empty($tags) && in_array($relation['name'], $tags); $found = !empty($relation['members']) && in_array($uri, $relation['members']); $update = false; // remove member from the relation if ($found && !$selected) { $relation['members'] = array_diff($relation['members'], (array) $uri); $update = true; } // add member to the relation else if (!$found && $selected) { $relation['members'][] = $uri; $update = true; } if ($update) { $config->save($relation, 'relation'); } $tags = array_diff($tags, (array) $relation['name']); } // create new relations if (!empty($tags)) { foreach ($tags as $tag) { $relation = array( 'name' => $tag, 'members' => (array) $uri, 'category' => 'tag', ); $config->save($relation, 'relation'); } } // make sure current folder is set correctly again $this->storage->set_folder($folder); } public static function charset_to_cp($charset) { // @TODO: ????? // The body is converted to utf-8 in get_part_body(), what about headers? return 65001; // UTF-8 $aliases = array( 'asmo708' => 708, 'shiftjis' => 932, 'gb2312' => 936, 'ksc56011987' => 949, 'big5' => 950, 'utf16' => 1200, 'utf16le' => 1200, 'unicodefffe' => 1201, 'utf16be' => 1201, 'johab' => 1361, 'macintosh' => 10000, 'macjapanese' => 10001, 'macchinesetrad' => 10002, 'mackorean' => 10003, 'macarabic' => 10004, 'machebrew' => 10005, 'macgreek' => 10006, 'maccyrillic' => 10007, 'macchinesesimp' => 10008, 'macromanian' => 10010, 'macukrainian' => 10017, 'macthai' => 10021, 'macce' => 10029, 'macicelandic' => 10079, 'macturkish' => 10081, 'maccroatian' => 10082, 'utf32' => 12000, 'utf32be' => 12001, 'chinesecns' => 20000, 'chineseeten' => 20002, 'ia5' => 20105, 'ia5german' => 20106, 'ia5swedish' => 20107, 'ia5norwegian' => 20108, 'usascii' => 20127, 'ibm273' => 20273, 'ibm277' => 20277, 'ibm278' => 20278, 'ibm280' => 20280, 'ibm284' => 20284, 'ibm285' => 20285, 'ibm290' => 20290, 'ibm297' => 20297, 'ibm420' => 20420, 'ibm423' => 20423, 'ibm424' => 20424, 'ebcdickoreanextended' => 20833, 'ibmthai' => 20838, 'koi8r' => 20866, 'ibm871' => 20871, 'ibm880' => 20880, 'ibm905' => 20905, 'ibm00924' => 20924, 'cp1025' => 21025, 'koi8u' => 21866, 'iso88591' => 28591, 'iso88592' => 28592, 'iso88593' => 28593, 'iso88594' => 28594, 'iso88595' => 28595, 'iso88596' => 28596, 'iso88597' => 28597, 'iso88598' => 28598, 'iso88599' => 28599, 'iso885913' => 28603, 'iso885915' => 28605, 'xeuropa' => 29001, 'iso88598i' => 38598, 'iso2022jp' => 50220, 'csiso2022jp' => 50221, 'iso2022jp' => 50222, 'iso2022kr' => 50225, 'eucjp' => 51932, 'euccn' => 51936, 'euckr' => 51949, 'hzgb2312' => 52936, 'gb18030' => 54936, 'isciide' => 57002, 'isciibe' => 57003, 'isciita' => 57004, 'isciite' => 57005, 'isciias' => 57006, 'isciior' => 57007, 'isciika' => 57008, 'isciima' => 57009, 'isciigu' => 57010, 'isciipa' => 57011, 'utf7' => 65000, 'utf8' => 65001, ); $charset = strtolower($charset); $charset = preg_replace(array('/^x-/', '/[^a-z0-9]/'), '', $charset); if (isset($aliases[$charset])) { return $aliases[$charset]; } if (preg_match('/^(ibm|dos|cp|windows|win)[0-9]+/', $charset, $m)) { return substr($charset, strlen($m[1]) + 1); } } /** * Wrap text to a given number of characters per line * but respect the mail quotation of replies messages (>). * Finally add another quotation level by prepending the lines * with > * * @param string $text Text to wrap * @param int $length The line width * * @return string The wrapped text */ protected static function wrap_and_quote($text, $length = 72) { // Function stolen from Roundcube ;) // Rebuild the message body with a maximum of $max chars, while keeping quoted message. $max = min(77, $length + 8); $lines = preg_split('/\r?\n/', trim($text)); $out = ''; foreach ($lines as $line) { // don't wrap already quoted lines if ($line[0] == '>') { $line = '>' . rtrim($line); } else if (mb_strlen($line) > $max) { $newline = ''; foreach (explode("\n", rcube_mime::wordwrap($line, $length - 2)) as $l) { if (strlen($l)) { $newline .= '> ' . $l . "\n"; } else { $newline .= ">\n"; } } $line = rtrim($newline); } else { $line = '> ' . $line; } // Append the line $out .= $line . "\n"; } return $out; } /** * Returns calendar event data from the iTip invitation attached to a mail message */ public function get_invitation_event($messageId) { // Get the mail message object if ($message = $this->getObject($messageId)) { // Parse the message and find iTip attachments $libcal = libcalendaring::get_instance(); $libcal->mail_message_load(array('object' => $message)); $ical_objects = $libcal->get_mail_ical_objects(); // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') { return $event; } } } } }